Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/scoped-git-diff-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

Improve performance of git diff when given named paths.
71 changes: 70 additions & 1 deletion packages/computer/src/git/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ async function init(): Promise<void> {
}

async function commitFile(path: string, content: string, message: string): Promise<string> {
const slash = path.lastIndexOf("/");
if (slash > 0) {
await memfs.promises.mkdir(`${DIR}/${path.slice(0, slash)}`, { recursive: true });
}
await memfs.promises.writeFile(`${DIR}/${path}`, content);
await git.add({ fs: memfs, dir: DIR, filepath: path });
return git.commit({ fs: memfs, dir: DIR, message, author: AUTHOR });
Expand All @@ -42,14 +46,15 @@ async function stageThenRemove(path: string): Promise<void> {
await memfs.promises.unlink(`${DIR}/${path}`);
}

async function runDiff(opts: { ref?: string } = {}): Promise<string> {
async function runDiff(opts: { ref?: string; paths?: string[] } = {}): Promise<string> {
return diffWith({
git: isomorphicGit,
fs: memfs,
createPatch,
readFile: (path) => memfs.promises.readFile(path) as Promise<Uint8Array | string>,
dir: DIR,
ref: opts.ref,
paths: opts.paths,
});
}

Expand Down Expand Up @@ -156,6 +161,70 @@ describe("diffWith (real isomorphic-git + memfs)", () => {
}
});

it("scopes the status walk to the requested paths", async () => {
await init();
await commitFile("src/a.txt", "one\n", "init a");
await commitFile("vendor/b.txt", "two\n", "init b");
await memfs.promises.writeFile(`${DIR}/src/a.txt`, "one changed\n");
await memfs.promises.writeFile(`${DIR}/vendor/b.txt`, "two changed\n");

const statusSpy = vi.spyOn(git, "statusMatrix");
try {
const out = await runDiff({ paths: ["src"] });
// The walk must be told to stay inside `src` rather than
// scanning the whole tree and filtering afterwards.
expect(statusSpy.mock.calls[0][0]).toMatchObject({ filepaths: ["src"] });
// Result is unchanged by the scoping.
expect(out).toContain("+one changed");
expect(out).not.toContain("two changed");
} finally {
statusSpy.mockRestore();
}
});

it("walks the whole tree when no paths are given", async () => {
await init();
await commitFile("a.txt", "one\n", "init");
await memfs.promises.writeFile(`${DIR}/a.txt`, "one changed\n");

const statusSpy = vi.spyOn(git, "statusMatrix");
try {
await runDiff();
// No scope requested: isomorphic-git's default ('.') must stand.
expect(statusSpy.mock.calls[0][0]).not.toHaveProperty("filepaths");
} finally {
statusSpy.mockRestore();
}
});

it("produces the same diff scoped and unscoped", async () => {
await init();
await commitFile("src/a.txt", "one\n", "init a");
await commitFile("vendor/b.txt", "two\n", "init b");
await memfs.promises.writeFile(`${DIR}/src/a.txt`, "one changed\n");
await memfs.promises.writeFile(`${DIR}/vendor/b.txt`, "two changed\n");

const scoped = await runDiff({ paths: ["src"] });
const full = await runDiff();
// The scoped run must equal the src-only slice of the full run.
expect(scoped).toContain("+one changed");
expect(full).toContain("+one changed");
expect(full).toContain("+two changed");
expect(scoped).not.toContain("+two changed");
});

it("scopes correctly for an exact file path", async () => {
await init();
await commitFile("src/a.txt", "one\n", "init a");
await commitFile("src/b.txt", "two\n", "init b");
await memfs.promises.writeFile(`${DIR}/src/a.txt`, "one changed\n");
await memfs.promises.writeFile(`${DIR}/src/b.txt`, "two changed\n");

const out = await runDiff({ paths: ["src/a.txt"] });
expect(out).toContain("+one changed");
expect(out).not.toContain("two changed");
});

it("respects the `ref` argument when diffing against an older commit", async () => {
await init();
const first = await commitFile("a.txt", "v1\n", "v1");
Expand Down
29 changes: 28 additions & 1 deletion packages/computer/src/git/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface IsomorphicGitDiffClient {
dir: string;
ref?: string;
cache?: object;
/** Prunes the worktree walk to these paths. Omit to walk it all. */
filepaths?: string[];
}): Promise<StatusMatrixRow[]>;
readBlob(args: {
fs: object;
Expand Down Expand Up @@ -166,7 +168,21 @@ async function collectDiffEntries(opts: DiffWithDeps): Promise<DiffEntry[]> {
// requested commit rather than always HEAD. Without this the
// `ref` argument would only affect blob reads, leaving the
// status walk silently skewed.
const status = await opts.git.statusMatrix({ fs: opts.fs, dir, ref, cache: opts.cache });
// Scope the walk when the caller named paths. isomorphic-git prunes
// the traversal to these prefixes instead of visiting the whole
// worktree and handing every path to `map`, which matters once the
// tree carries a synced node_modules. `filepaths` uses the same
// "exact path or directory prefix" rule as makePathFilter below, so
// the filter stays as the authority on what is emitted and this is
// purely a traversal hint.
const scopedPaths = normalizeFilepaths(opts.paths);
const status = await opts.git.statusMatrix({
fs: opts.fs,
dir,
ref,
cache: opts.cache,
...(scopedPaths === undefined ? {} : { filepaths: scopedPaths }),
});
const pathFilter = makePathFilter(opts.paths);
const entries: DiffEntry[] = [];
for (const [filepath, headStatus, workdirStatus] of status) {
Expand Down Expand Up @@ -262,6 +278,17 @@ async function listFilesAt(
return git.listFiles({ fs, dir, ref });
}

// The `filepaths` form isomorphic-git wants: normalized, deduplicated,
// and undefined when the caller asked for no scoping (so the library's
// own default of ['.'] stands). An empty string normalizes to the repo
// root, which would scope to everything — treat it as no scope.
function normalizeFilepaths(paths: string[] | undefined): string[] | undefined {
if (paths === undefined || paths.length === 0) return undefined;
const out = [...new Set(paths.map((p) => normalizePath(p)))];
if (out.some((p) => p === "" || p === ".")) return undefined;
return out;
}

function makePathFilter(paths: string[] | undefined): (p: string) => boolean {
if (paths === undefined || paths.length === 0) return () => true;
// Match either an exact path or a directory prefix. Globs are
Expand Down
Loading