diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 834cdde252..995e8d3fda 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -177,8 +177,39 @@ 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 + // Use --no-index to check purely against patterns (ignoring whether file is tracked) + const checkArgs = [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--", + ...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 +290,39 @@ 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", + "--no-index", + "--", + ...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("\\", "/")), } }), ) @@ -616,6 +672,30 @@ export namespace Snapshot { } satisfies Row, ] }) + + // Filter out files that are now gitignored + if (rows.length > 0) { + const files = rows.map((r) => r.file) + const checkArgs = [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--", + ...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 = rows.filter((r) => !ignored.has(r.file)) + rows.length = 0 + rows.push(...filtered) + } + } + const step = 100 const patch = (file: string, before: string, after: string) => formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 3cedfb941d..6beec61ed5 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,67 @@ 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 () => { + // a.txt is already committed from bootstrap - track it in snapshot + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify a.txt (so it appears in diff-files) + await Filesystem.write(`${tmp.path}/a.txt`, "modified content") + + // Now add gitignore that would exclude a.txt + await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n") + + // Also modify b.txt which is not gitignored + await Filesystem.write(`${tmp.path}/b.txt`, "also modified") + + const patch = await Snapshot.patch(before!) + + // a.txt is now gitignored and should NOT appear in patch + expect(patch.files).not.toContain(fwd(tmp.path, "a.txt")) + + // .gitignore itself should appear (it's a new file) + expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) + + // b.txt should appear (not gitignored) + expect(patch.files).toContain(fwd(tmp.path, "b.txt")) + }, + }) +}) + +test("gitignore updated between track calls filters from diff", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // First track - a.txt is tracked in snapshot + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify a.txt + await Filesystem.write(`${tmp.path}/a.txt`, "modified content") + + // Now add a.txt to gitignore + await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n") + + // Second track - should not include a.txt even though it changed + const after = await Snapshot.track() + expect(after).toBeTruthy() + + // Verify a.txt is NOT in the diff between snapshots + const diffs = await Snapshot.diffFull(before!, after!) + expect(diffs.some((x) => x.file === "a.txt")).toBe(false) + + // But .gitignore should be in the diff + expect(diffs.some((x) => x.file === ".gitignore")).toBe(true) + }, + }) +}) + test("git info exclude changes", async () => { await using tmp = await bootstrap() await Instance.provide({