From 113304a058d569f00f758e0646fa360cf5b052d5 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 12 Apr 2026 13:41:50 -0400 Subject: [PATCH] fix(snapshot): respect gitignore for previously tracked files (#22171) --- packages/opencode/src/snapshot/index.ts | 67 ++++++++++++++-- .../opencode/test/snapshot/snapshot.test.ts | 77 +++++++++++++++++++ 2 files changed, 137 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 834cdde252..3b522a03ea 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -177,8 +177,37 @@ export namespace Snapshot { const all = Array.from(new Set([...tracked, ...untracked])) if (!all.length) return + // Filter out files that are now gitignored even if previously tracked + // Files may have been tracked before being gitignored, so we need to check + // against the source project's current gitignore rules + const checkArgs = [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--", + ...all, + ] + const check = yield* git(checkArgs, { cwd: state.directory }) + const ignored = + check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set() + const filtered = all.filter((item) => !ignored.has(item)) + + // Remove newly-ignored files from snapshot index to prevent re-adding + if (ignored.size > 0) { + const ignoredFiles = Array.from(ignored) + log.info("removing gitignored files from snapshot", { count: ignoredFiles.length }) + yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], { + cwd: state.directory, + }) + } + + if (!filtered.length) return + const large = (yield* Effect.all( - all.map((item) => + filtered.map((item) => fs .stat(path.join(state.directory, item)) .pipe(Effect.catch(() => Effect.void)) @@ -259,14 +288,38 @@ export namespace Snapshot { log.warn("failed to get diff", { hash, exitCode: result.code }) return { hash, files: [] } } + const files = result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + + // Filter out files that are now gitignored + if (files.length > 0) { + const checkArgs = [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--", + ...files, + ] + const check = yield* git(checkArgs, { cwd: state.directory }) + if (check.code === 0) { + const ignored = new Set(check.text.trim().split("\n").filter(Boolean)) + const filtered = files.filter((item) => !ignored.has(item)) + return { + hash, + files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + } + } + } + return { hash, - files: result.text - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), } }), ) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 3cedfb941d..22253ecaba 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -511,6 +511,49 @@ test("circular symlinks", async () => { }) }) +test("source project gitignore is respected - ignored files are not snapshotted", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create gitignore BEFORE any tracking + await Filesystem.write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n") + await Filesystem.write(`${dir}/tracked.txt`, "tracked content") + await Filesystem.write(`${dir}/ignored.ignored`, "ignored content") + await $`mkdir -p ${dir}/build`.quiet() + await Filesystem.write(`${dir}/build/output.js`, "build output") + await Filesystem.write(`${dir}/normal.js`, "normal js") + await $`git add .`.cwd(dir).quiet() + await $`git commit -m init`.cwd(dir).quiet() + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify tracked files and create new ones - some ignored, some not + await Filesystem.write(`${tmp.path}/tracked.txt`, "modified tracked") + await Filesystem.write(`${tmp.path}/new.ignored`, "new ignored") + await Filesystem.write(`${tmp.path}/new-tracked.txt`, "new tracked") + await Filesystem.write(`${tmp.path}/build/new-build.js`, "new build file") + + const patch = await Snapshot.patch(before!) + + // Modified and new tracked files should be in snapshot + expect(patch.files).toContain(fwd(tmp.path, "new-tracked.txt")) + expect(patch.files).toContain(fwd(tmp.path, "tracked.txt")) + + // Ignored files should NOT be in snapshot + expect(patch.files).not.toContain(fwd(tmp.path, "new.ignored")) + expect(patch.files).not.toContain(fwd(tmp.path, "ignored.ignored")) + expect(patch.files).not.toContain(fwd(tmp.path, "build/output.js")) + expect(patch.files).not.toContain(fwd(tmp.path, "build/new-build.js")) + }, + }) +}) + test("gitignore changes", async () => { await using tmp = await bootstrap() await Instance.provide({ @@ -535,6 +578,40 @@ test("gitignore changes", async () => { }) }) +test("files tracked in snapshot but now gitignored are filtered out", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // First, create a file and snapshot it + await Filesystem.write(`${tmp.path}/later-ignored.txt`, "initial content") + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify the file (so it appears in diff-files) + await Filesystem.write(`${tmp.path}/later-ignored.txt`, "modified content") + + // Now add gitignore that would exclude this file + await Filesystem.write(`${tmp.path}/.gitignore`, "later-ignored.txt\n") + + // Also create another tracked file + await Filesystem.write(`${tmp.path}/still-tracked.txt`, "new tracked file") + + const patch = await Snapshot.patch(before!) + + // The file that is now gitignored should NOT appear, even though it was + // previously tracked and modified + expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt")) + + // The gitignore file itself should appear + expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) + + // Other tracked files should appear + expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt")) + }, + }) +}) + test("git info exclude changes", async () => { await using tmp = await bootstrap() await Instance.provide({