diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ef92ddcbf3..06c91442ac 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -90,12 +90,19 @@ export namespace Snapshot { const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] + const enc = new TextEncoder() + const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0")) + const git = Effect.fnUntraced( - function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + function* ( + cmd: string[], + opts?: { cwd?: string; env?: Record; stdin?: ChildProcess.CommandInput }, + ) { const proc = ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true, + stdin: opts?.stdin, }) const handle = yield* spawner.spawn(proc) const [text, stderr] = yield* Effect.all( @@ -115,6 +122,59 @@ export namespace Snapshot { ), ) + const ignore = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return new Set() + const check = yield* git( + [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--stdin", + "-z", + ], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + if (check.code !== 0 && check.code !== 1) return new Set() + return new Set(check.text.split("\0").filter(Boolean)) + }) + + const drop = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + yield* git( + [ + ...cfg, + ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), + ], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + }) + + const stage = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + const result = yield* git( + [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + if (result.code === 0) return + log.warn("failed to add snapshot files", { + exitCode: result.code, + stderr: result.stderr, + }) + }) + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) @@ -176,60 +236,41 @@ 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)) + // Resolve source-repo ignore rules against the exact candidate set. + // --no-index keeps this pattern-based even when a path is already tracked. + const ignored = yield* ignore(all) // 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, - }) + yield* drop(ignoredFiles) } - if (!filtered.length) return + const allow = all.filter((item) => !ignored.has(item)) + if (!allow.length) return - const large = (yield* Effect.all( - filtered.map((item) => - fs - .stat(path.join(state.directory, item)) - .pipe(Effect.catch(() => Effect.void)) - .pipe( - Effect.map((stat) => { - if (!stat || stat.type !== "File") return - const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size - return size > limit ? item : undefined - }), - ), - ), - { concurrency: 8 }, - )).filter((item): item is string => Boolean(item)) - yield* sync(large) - const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory }) - if (result.code !== 0) { - log.warn("failed to add snapshot files", { - exitCode: result.code, - stderr: result.stderr, - }) - } + const large = new Set( + (yield* Effect.all( + allow.map((item) => + fs + .stat(path.join(state.directory, item)) + .pipe(Effect.catch(() => Effect.void)) + .pipe( + Effect.map((stat) => { + if (!stat || stat.type !== "File") return + const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size + return size > limit ? item : undefined + }), + ), + ), + { concurrency: 8 }, + )).filter((item): item is string => Boolean(item)), + ) + const block = new Set(untracked.filter((item) => large.has(item))) + yield* sync(Array.from(block)) + // Stage only the allowed candidate paths so snapshot updates stay scoped. + yield* stage(allow.filter((item) => !block.has(item))) }) const cleanup = Effect.fnUntraced(function* () { @@ -295,33 +336,14 @@ export namespace Snapshot { .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("\\", "/")), - } - } - } + // Hide ignored-file removals from the user-facing patch output. + const ignored = yield* ignore(files) return { hash, - files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + files: files + .filter((item) => !ignored.has(item)) + .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), } }), ) @@ -672,27 +694,12 @@ export namespace Snapshot { ] }) - // 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) - } + // Hide ignored-file removals from the user-facing diff output. + const ignored = yield* ignore(rows.map((r) => r.file)) + if (ignored.size > 0) { + const filtered = rows.filter((r) => !ignored.has(r.file)) + rows.length = 0 + rows.push(...filtered) } const step = 100