Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
94 changes: 87 additions & 7 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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))
Expand Down Expand Up @@ -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("\\", "/")),
}
}),
)
Expand Down Expand Up @@ -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 }))
Expand Down
104 changes: 104 additions & 0 deletions packages/opencode/test/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down
Loading