Skip to content
This repository was archived by the owner on Mar 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "adds a new API to allow retrieving a list of packages affected by files and also by git ref range",
"packageName": "workspace-tools",
"email": "[email protected]",
"dependentChangeType": "patch"
}
24 changes: 23 additions & 1 deletion src/__tests__/getChangedPackages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from "fs";

import { cleanupFixtures, setupFixture, setupLocalRemote } from "../helpers/setupFixture";
import { stageAndCommit, git } from "../git";
import { getChangedPackages } from "../workspaces/getChangedPackages";
import { getChangedPackages, getChangedPackagesBetweenRefs } from "../workspaces/getChangedPackages";

describe("getChangedPackages()", () => {
afterAll(() => {
Expand Down Expand Up @@ -159,4 +159,26 @@ describe("getChangedPackages()", () => {
// assert
expect(changedPkgs).toEqual([]);
});

it("can detect changed packages between two refs", () => {
// arrange
const root = setupFixture("monorepo");

const newFile = path.join(root, "packages/package-a/footest.txt");
fs.writeFileSync(newFile, "hello foo test");
git(["add", newFile], { cwd: root });
stageAndCommit(["packages/package-a/footest.txt"], "test commit in a", root);

const newFile2 = path.join(root, "packages/package-b/footest2.txt");
fs.writeFileSync(newFile2, "hello foo test");
git(["add", newFile2], { cwd: root });
stageAndCommit(["packages/package-b/footest2.txt"], "test commit in b", root);

// act
const changedPkgs = getChangedPackagesBetweenRefs(root, "HEAD^1", "HEAD");

// assert
expect(changedPkgs).toContain("package-b");
expect(changedPkgs).not.toContain("package-a");
});
});
66 changes: 66 additions & 0 deletions src/__tests__/getPackagesByFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import path from "path";
import fs from "fs";

import { cleanupFixtures, setupFixture } from "../helpers/setupFixture";
import { getPackagesByFiles } from "../workspaces/getPackagesByFiles";

describe("getPackagesByFiles()", () => {
afterAll(() => {
cleanupFixtures();
});

it("can find all packages that contain the files in a monorepo", () => {
// arrange
const root = setupFixture("monorepo");

const newFile = path.join(root, "packages/package-a/footest.txt");
fs.writeFileSync(newFile, "hello foo test");

// act
const packages = getPackagesByFiles(root, ["packages/package-a/footest.txt"]);

// assert
expect(packages).toContain("package-a");
expect(packages).not.toContain("package-b");
});

it("can find can ignore changes in a glob pattern", () => {
// arrange
const root = setupFixture("monorepo");

const newFileA = path.join(root, "packages/package-a/footest.txt");
fs.writeFileSync(newFileA, "hello foo test");

const newFileB = path.join(root, "packages/package-b/footest.txt");
fs.writeFileSync(newFileB, "hello foo test");

// act
const packages = getPackagesByFiles(root, ["packages/package-a/footest.txt", "packages/package-b/footest.txt"], ["packages/package-b/**"]);

// assert
expect(packages).toContain("package-a");
expect(packages).not.toContain("package-b");
});

it("can find can handle empty files", () => {
// arrange
const root = setupFixture("monorepo");

// act
const packages = getPackagesByFiles(root, []);

// assert
expect(packages.length).toBe(0);
});

it("can find can handle unrelated files", () => {
// arrange
const root = setupFixture("monorepo");

// act
const packages = getPackagesByFiles(root, ["package.json"]);

// assert
expect(packages.length).toBe(0);
});
});
8 changes: 2 additions & 6 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,13 @@ export function getChanges(branch: string, cwd: string) {
* @param cwd
*/
export function getBranchChanges(branch: string, cwd: string) {
try {
return processGitOutput(git(["--no-pager", "diff", "--name-only", "--relative", branch + "..."], { cwd }));
} catch (e) {
throw gitError(`Cannot gather information about branch changes`, e);
}
return getChangesBetweenRefs(branch, "", [], "", cwd)
}

export function getChangesBetweenRefs(fromRef: string, toRef: string, options: string[], pattern: string, cwd: string) {
try {
return processGitOutput(
git(["--no-pager", "diff", "--relative", "--name-only", ...options, `${fromRef}...${toRef}`, "--", pattern], {
git(["--no-pager", "diff", "--name-only", "--relative", ...options, `${fromRef}...${toRef}`, ...(pattern ? ["--", pattern]: [])], {
cwd,
})
);
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export * from "./workspaces/implementations/pnpm";
export * from "./workspaces/implementations/rush";
export * from "./workspaces/implementations/yarn";
export * from "./workspaces/getChangedPackages";
export * from "./workspaces/getPackagesByFiles";
export * from "./workspaces/listOfWorkspacePackageNames";
export * from "./workspaces/workspaces";
72 changes: 38 additions & 34 deletions src/workspaces/getChangedPackages.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import {
getBranchChanges,
getChangesBetweenRefs,
getDefaultRemoteBranch,
getStagedChanges,
getUnstagedChanges,
getUntrackedChanges,
} from "../git";
import { getWorkspaces } from "./getWorkspaces";
import multimatch from "multimatch";
import path from "path";

import { getPackagesByFiles } from "./getPackagesByFiles";
/**
* Finds all packages that had been changed in the repo under cwd
* Finds all packages that had been changed between two refs in the repo under cwd
*
* executes a "git diff $Target..." to get changes given a merge-base
* executes a "git diff $fromRef...$toRef" to get changes given a merge-base
*
* further explanation with the three dots:
*
Expand All @@ -26,46 +25,51 @@ import path from "path";
*
* @returns string[] of package names that have changed
*/
export function getChangedPackages(
export function getChangedPackagesBetweenRefs(
cwd: string,
target: string | undefined,
fromRef: string,
toRef: string = "",
ignoreGlobs: string[] = []
) {
const workspaceInfo = getWorkspaces(cwd);

target = target || getDefaultRemoteBranch(undefined, cwd);

let changes = [
...new Set([
...(getUntrackedChanges(cwd) || []),
...(getUnstagedChanges(cwd) || []),
...(getBranchChanges(target, cwd) || []),
...(getChangesBetweenRefs(fromRef, toRef, [], "", cwd) || []),
...(getStagedChanges(cwd) || []),
]),
];

const ignoreSet = new Set(multimatch(changes, ignoreGlobs));

changes = changes.filter((change) => !ignoreSet.has(change));

const changeSet = new Set<string>();

for (const change of changes) {
const candidates = workspaceInfo.filter(
(pkgPath) =>
change.indexOf(path.relative(cwd, pkgPath.path).replace(/\\/g, "/")) ===
0
);
return getPackagesByFiles(cwd, changes, ignoreGlobs, true);
}

if (candidates && candidates.length > 0) {
const found = candidates.reduce((found, item) => {
return found.path.length > item.path.length ? found : item;
}, candidates[0]);
changeSet.add(found.name);
} else {
return workspaceInfo.map((pkg) => pkg.name);
}
}
/**
* Finds all packages that had been changed in the repo under cwd
*
* executes a "git diff $Target..." to get changes given a merge-base
*
* further explanation with the three dots:
*
* > git diff [--options] <commit>...<commit> [--] [<path>...]
* >
* > This form is to view the changes on the branch containing and up to
* > the second <commit>, starting at a common ancestor of both
* > <commit>. "git diff A...B" is equivalent to "git diff
* > $(git-merge-base A B) B". You can omit any one of <commit>, which
* > has the same effect as using HEAD instead.
*
* @returns string[] of package names that have changed
*/
export function getChangedPackages(cwd: string, target: string | undefined, ignoreGlobs: string[] = []) {
const targetBranch = target || getDefaultRemoteBranch(undefined, cwd);
let changes = [
...new Set([
...(getUntrackedChanges(cwd) || []),
...(getUnstagedChanges(cwd) || []),
...(getBranchChanges(targetBranch, cwd) || []),
...(getStagedChanges(cwd) || []),
]),
];

return [...changeSet];
return getPackagesByFiles(cwd, changes, ignoreGlobs, true);
}
43 changes: 43 additions & 0 deletions src/workspaces/getPackagesByFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import multimatch from "multimatch";
import path from "path";
import { getWorkspaces } from "./getWorkspaces";

/**
* Given a list of files, finds all packages names that contain those files
*
* @param workspaceRoot - The root of the workspace
* @param files - files to search for
* @param ignoreGlobs - glob patterns to ignore
* @param returnAllPackagesOnNoMatch - if true, will return all packages if no matches are found
* @returns package names that have changed
*/
export function getPackagesByFiles(
workspaceRoot: string,
files: string[],
ignoreGlobs: string[] = [],
returnAllPackagesOnNoMatch: boolean = false
) {
const workspaceInfo = getWorkspaces(workspaceRoot);
const ignoreSet = new Set(multimatch(files, ignoreGlobs));

files = files.filter((change) => !ignoreSet.has(change));

const packages = new Set<string>();

for (const file of files) {
const candidates = workspaceInfo.filter(
(pkgPath) => file.indexOf(path.relative(workspaceRoot, pkgPath.path).replace(/\\/g, "/")) === 0
);

if (candidates && candidates.length > 0) {
const found = candidates.reduce((found, item) => {
return found.path.length > item.path.length ? found : item;
}, candidates[0]);
packages.add(found.name);
} else if (returnAllPackagesOnNoMatch) {
return workspaceInfo.map((pkg) => pkg.name);
}
}

return [...packages];
}