Repository navigation
Description
Description
vcs.diff() reports "no changes" when Git cannot read the repository, instead of reporting that the diff is unavailable.
In packages/core/src/plugin/vcs/git.ts, two of the three working-copy listings ignore the exit code: status (:325) and stats (:351) take only stdout from the shared run helper, which renders any failure as { exitCode: 1, text: "" } (:185), while the name-status listing right beside them (:336) already raises DiffError("Unable to list Git changes"). On top of that, hasHead (:312) reads any non-zero git rev-parse --verify HEAD as "no commits yet", so the diff path takes the unborn-branch branch and depends entirely on the swallowed listing — which is why diff's own error handling never fires.
Measured with git 2.49.0: in a linked worktree whose admin directory has been pruned, git status --porcelain=v1 --untracked-files=all --no-renames -z -- . exits 128 with empty stdout, whereas a genuinely unborn repository exits 0 and lists its untracked files. Downstream the two look identical, so the TUI review panel renders an empty diff, plugin/host.ts:465 hands plugins an empty list, and the workspace delete check (packages/app/src/settings/workspaces/workspaces.tsx:178) reads a failed status as clean and skips its uncommitted-changes warning before a forced removal.
Plugins
None.
OpenCode version
v2 at affa57e40.
Steps to reproduce
Unit level, in packages/core/test/vcs.test.ts, no live server:
- Create a repo, commit,
git worktree add --detach ../wt HEAD, then write an uncommitted file into../wt. - Move
../repo/.git/worktrees/wtaway, as a prune leaves it. In../wt,git status --porcelain=v1 -z -- .now exits 128 with no output, while.gitremains a pointer file. - Resolve
vcs.diff("working")for../wt: it returns[]. Checking the exit code in those two listings turns it into aDiffErrorinstead;bun test test/vcs.test.tsinpackages/coreis 35 pass with that change, and the new test fails onaffa57e40witherror: [].
Two calls I would rather you make than have a drive-by PR assume:
Vcs.statuskeeps an empty-list fallback (packages/core/src/vcs.ts) and/api/vcs/statusdeclares no error channel, unlikebaseanddiff. The change above makes the diff paths report unavailable; status still reads clean. Reporting failure there too means adding the error to the endpoint and regenerating the client.hasHeadcannot distinguish an unreadable repository from an unborn one by exit code (both 128);git rev-parse --git-dircan (0 vs 128), ifbase()should report unavailable rather thannull.
Activity
argszero commentedon Sep 23, 2026
This reads the same on the current
v2head (7d126eb1e5bc): all four cited lines are unchanged sinceaffa57e40(hasHead312,status325,diffNames336/341,stats351), so the analysis holds there. Two details that seem relevant to the two calls.1. The
hasHeadambiguity is load-bearing for the diff paths, not only forbase(). Every diff route that would otherwise be protected goes through that probe first:diff("committed")returns before listing anything (packages/core/src/plugin/vcs/git.ts:99-100):if (!(yield* git.hasHead(ctx.directory))) return mode === "committed" ? [] : .... An unreadable repository answers[]right there, whatever the listings later do.diff("working")takes its ref from the same probe (:96), so an unreadable repository routes intotrack(ctx, undefined, ...)and thenfiles(ctx, undefined, yield* ctx.git.status(...), ...)(:601-602) — it never reachesdiffNames, whose exit check (:341) is the only one in the file.So exit-code checks in
status/statsalone would still leave both thecommittedand theworkingpaths reporting no changes, unless the probe is fixed too.2.
statushas no error channel at either layer.Adapter.statusisEffect.Effect<FileStatus[]>(packages/core/src/vcs.ts:37) with noDiffError, unlikebaseanddiff(:35,:38); andVcs.status(:188) is wrapped byprotect(..., []), while the wrapper that turns provider failures intoDiffErroraccepts onlyoperation: "base" | "diff"(:101). Reporting unavailable from the listings therefore means widening the adapter signature together with the/api/vcs/statusdeclaration and the regenerated client — that is the cost on the table for call 1, as distinct from whether it is wanted.feiiiiii5 commentedon Sep 23, 2026
Thanks, this confirms the
hasHeadprobe is load-bearing: fixing onlystatus/statswould leave both committed and working diff paths reporting an empty result when the repository is unreadable.I’ll keep the diff fix focused on that shared probe and the two diff routes. The separate decision about widening
Adapter.statuswith an error channel (and regenerating the client) needs maintainer direction, so I won’t bundle that contract change into the issue’s eventual PR.feiiiiii5 commentedon Oct 19, 2026
The V2 (
dev) port is up in #57010: the sharedhasHeadprobe now runsgit rev-parse --git-dirfirst and fails withDiffErrorwhen the repository cannot be read, so bothdiff("working")anddiff("committed")report unavailable instead of an empty diff.status()keeps its empty-list fallback because it has no error channel; that remains the separate contract decision from the issue.Verified with the pruned-admin-directory worktree repro: the new test fails on
ba06ff9e0(Exit.isFailureis false) and passes with the change;bun test test/vcs.test.tsis 37 pass, andbun run typecheckis clean.opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I reproduced this at unit level, on
affa57e40and on the latestdev(fa1260d6c), with git 2.47.3.Steps:
vcs.diff("working")lists the file as expected..git/worktrees/wtaway, as a prune would.git status --porcelain=v1 ... -z -- .in the worktree then exits 128 with no output (fatal: not a git repository).vcs.diff("working")returns[],vcs.status()returns[]andvcs.base()returnsnull.The cause matches your analysis: the
statusandstatslistings inpackages/core/src/plugin/vcs/git.tsdon't check Git's exit code, unlike the name-status listing next to them, andhasHeadreads any failure as "no commits yet". I've left your two open questions (whetherVcs.statusand/api/vcs/statusshould report the error, and whether to userev-parse --git-dirforbase()) for the maintainers. The script below reproduces it: put it inpackages/core/test/and runbun testfrompackages/core.Reproduction script (vcs-pruned-worktree.test.ts)
Run it:
bun vcs-pruned-worktree.test.ts