summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-04-15 16:43:36 +1000
committerGitHub <[email protected]>2026-04-15 06:43:36 +0000
commita992d8b733716148feb392b84b27198141ca5094 (patch)
tree09d1f408abe72dc19f92caaf2f6eb8f03f265cca
parentccaa12ee799a102390bc9dce6dc76e7d2620c448 (diff)
downloadopencode-a992d8b733716148feb392b84b27198141ca5094.tar.gz
opencode-a992d8b733716148feb392b84b27198141ca5094.zip
fix(snapshot): avoid ENAMETOOLONG and improve staging perf via stdin pathspecs (#22560)
-rw-r--r--packages/opencode/src/snapshot/index.ts193
1 files changed, 100 insertions, 93 deletions
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index ef92ddcbf..06c91442a 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<string, string> }) {
+ function* (
+ cmd: string[],
+ opts?: { cwd?: string; env?: Record<string, string>; 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<string>()
+ 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<string>()
+ 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<string>()
- 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 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 allow = all.filter((item) => !ignored.has(item))
+ if (!allow.length) return
+
+ 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