Skip to content

fix(core): diff path selections through a private index to avoid ENAMETOOLONG - #53855

Closed
opencode-agent[bot] wants to merge 2 commits into
v2from
diff-pathspec-batches
Closed

opencode-agent[bot] wants to merge 2 commits into
v2from
diff-pathspec-batches

Conversation

@opencode-agent

@opencode-agent opencode-agent Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Git.tree.diff spreads an explicit path selection into the git diff argv. Since #47821 batched the per-file diffs into three calls, a revert that touches many files (SessionRevert passes every file changed since the reverted message) can build a command line past Windows' 32,767-character limit and fail with ENAMETOOLONG (E2BIG on Linux once past ARG_MAX).

#22560 fixed the same class for snapshot staging with --pathspec-from-file=- --pathspec-file-nul, but git diff, diff-tree, and diff-index have no --pathspec-from-file or stdin pathspec input.

Fix

No path ever goes on the command line, so the selection size is unbounded:

  1. git diff --raw -z --no-abbrev --no-renames <from> <to> lists every changed entry with its new mode and object ID.
  2. Selected entries become update-index --index-info records. Selection follows literal pathspec semantics: the path or a parent directory is selected; ./a and a//b normalize to a and a/b; a trailing slash selects only directories and submodules.
  3. In a private GIT_INDEX_FILE: read-tree <from>, then update-index -z --index-info with the records on stdin. The index is now from plus only the selected changes.
  4. The existing name-status/numstat/patch trio runs as diff --cached <from> against that index, so output, patch cap, and parsing are unchanged.

The private index lives in the snapshot Git directory and is removed afterwards; the repository's own index is untouched. Output is filtered to the selected entries, because staging a path over a file/directory conflict drops the other side from the index. Diffs without a selection are unchanged.

Selections are now matched literally (path or parent directory). Previously a selected path containing *, ?, or [ was interpreted as a glob pathspec.

A comment on repositoryArgs records the rule for all Git spawns: never pass an unbounded path list as arguments; use stdin.

Audit of other Git spawns on v2

  • Git.index.refresh (add/rm) and Git.index.ignored (check-ignore) already pass paths on stdin.
  • Git.tree.restore, hasEntry, and the VCS plugin patch helpers pass one path per process; the VCS plugin otherwise uses -- ..
  • No other Git invocation spreads a variable-length path list.

Differential fuzz

A seeded harness compared this implementation with the origin/v2 implementation across random repositories and histories: text/binary/CRLF/empty/large files, executable-bit changes, symlinks, file↔directory swaps, renames, nested repositories (gitlinks), and names with spaces, Unicode, tabs, newlines, quotes, backslashes, leading -/:/!/#, and glob characters. Selections covered exact changed paths, supersets with unchanged/missing paths, parent directories, ., and malformed forms (./, trailing /, case and backslash variants, glob and magic strings), with random context sizes.

  • Against a literal-pathspec baseline (GIT_LITERAL_PATHSPECS=1): 22,310 comparisons (93,648 diff entries), 0 errors, 3 differences, all the same niche: a trailing-slash selection of a path that changed between a regular file and a submodule. origin/v2 reports only the submodule side as added; this reports the type change as modified.
  • Against origin/v2 as-is: the remaining differences are all selections containing glob or magic characters (*, ?, [, ], \, leading :), which origin/v2 interpreted as globs; for example selecting a file named [br] also returned b.
  • Earlier fuzz rounds found and fixed: an unselected deletion reported across a file/directory swap, ./ and trailing-slash normalization, and trailing-slash submodule matching. A deterministic test covers the first two.

Verification

  • New test diffs a ~3 MB selection (30,000 nonexistent paths plus selected added, modified, deleted, and directory-selected files, with an unselected change excluded). It fails on origin/v2 with E2BIG: argument list too long, posix_spawn 'git' and passes here; it also checks the private index is removed and the real index is untouched.
  • bun test test/git.test.ts test/snapshot.test.ts test/session-revert.test.ts test/session-diff.test.ts and bun typecheck pass in packages/core.
  • Not yet run on Windows: the shared Windows box was unreachable over SSH.

Requested by: @Hona (Hona via Slack)

@opencode-agent
opencode-agent Bot force-pushed the diff-pathspec-batches branch from f721cce to d8305fa Compare October 8, 2026 04:55
@opencode-agent opencode-agent Bot changed the title fix(core): batch tree diff pathspecs to avoid ENAMETOOLONG fix(core): diff path selections through a private index to avoid ENAMETOOLONG Oct 8, 2026
@nexxeln

nexxeln commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Closing in favor of #53956. It batches tree.diff selections on every platform with the grouping from #51996, and it keeps your 30,000-path test with you as co-author.

@nexxeln nexxeln closed this Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants