Skip to content

vcs diff reports "no changes" when Git cannot read the repository #50934

Open

Description

@feiiiiii5

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:

  1. Create a repo, commit, git worktree add --detach ../wt HEAD, then write an uncommitted file into ../wt.
  2. Move ../repo/.git/worktrees/wt away, as a prune leaves it. In ../wt, git status --porcelain=v1 -z -- . now exits 128 with no output, while .git remains a pointer file.
  3. Resolve vcs.diff("working") for ../wt: it returns []. Checking the exit code in those two listings turns it into a DiffError instead; bun test test/vcs.test.ts in packages/core is 35 pass with that change, and the new test fails on affa57e40 with error: [].

Two calls I would rather you make than have a drive-by PR assume:

  1. Vcs.status keeps an empty-list fallback (packages/core/src/vcs.ts) and /api/vcs/status declares no error channel, unlike base and diff. 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.
  2. hasHead cannot distinguish an unreadable repository from an unborn one by exit code (both 128); git rev-parse --git-dir can (0 vs 128), if base() should report unavailable rather than null.

Activity

  1. argszero commented on Sep 23, 2026

    @argszero
    Contributor

    This reads the same on the current v2 head (7d126eb1e5bc): all four cited lines are unchanged since affa57e40 (hasHead 312, status 325, diffNames 336/341, stats 351), so the analysis holds there. Two details that seem relevant to the two calls.

    1. The hasHead ambiguity is load-bearing for the diff paths, not only for base(). 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 into track(ctx, undefined, ...) and then files(ctx, undefined, yield* ctx.git.status(...), ...) (:601-602) — it never reaches diffNames, whose exit check (:341) is the only one in the file.

    So exit-code checks in status/stats alone would still leave both the committed and the working paths reporting no changes, unless the probe is fixed too.

    2. status has no error channel at either layer. Adapter.status is Effect.Effect<FileStatus[]> (packages/core/src/vcs.ts:37) with no DiffError, unlike base and diff (:35, :38); and Vcs.status (:188) is wrapped by protect(..., []), while the wrapper that turns provider failures into DiffError accepts only operation: "base" | "diff" (:101). Reporting unavailable from the listings therefore means widening the adapter signature together with the /api/vcs/status declaration and the regenerated client — that is the cost on the table for call 1, as distinct from whether it is wanted.

  2. feiiiiii5 commented on Sep 23, 2026

    @feiiiiii5
    Author

    Thanks, this confirms the hasHead probe is load-bearing: fixing only status/stats would 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.status with an error channel (and regenerating the client) needs maintainer direction, so I won’t bundle that contract change into the issue’s eventual PR.

  3. feiiiiii5 commented on Oct 19, 2026

    @feiiiiii5
    Author

    The V2 (dev) port is up in #57010: the shared hasHead probe now runs git rev-parse --git-dir first and fails with DiffError when the repository cannot be read, so both diff("working") and diff("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.isFailure is false) and passes with the change; bun test test/vcs.test.ts is 37 pass, and bun run typecheck is clean.

  4. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I reproduced this at unit level, on affa57e40 and on the latest dev (fa1260d6c), with git 2.47.3.

    Steps:

    1. Create a repo, commit, add a detached linked worktree, and write an uncommitted file into it.
    2. While the worktree is healthy, vcs.diff("working") lists the file as expected.
    3. Move .git/worktrees/wt away, as a prune would. git status --porcelain=v1 ... -z -- . in the worktree then exits 128 with no output (fatal: not a git repository).
    4. OpenCode still reports success with nothing in it: vcs.diff("working") returns [], vcs.status() returns [] and vcs.base() returns null.

    The cause matches your analysis: the status and stats listings in packages/core/src/plugin/vcs/git.ts don't check Git's exit code, unlike the name-status listing next to them, and hasHead reads any failure as "no commits yet". I've left your two open questions (whether Vcs.status and /api/vcs/status should report the error, and whether to use rev-parse --git-dir for base()) for the maintainers. The script below reproduces it: put it in packages/core/test/ and run bun test from packages/core.

    Reproduction script (vcs-pruned-worktree.test.ts)
    // Repro for anomalyco/opencode#50934: vcs.diff() reports "no changes" when Git cannot read the repository.
    // Run from packages/core: bun test test/vcs-pruned-worktree.test.ts
    import { $ } from "bun"
    import { expect } from "bun:test"
    import fs from "fs/promises"
    import path from "path"
    import { Effect, Exit, Layer } from "effect"
    import { LayerNode } from "@opencode/util/effect/layer-node"
    import { AppProcess } from "@opencode/util/process"
    import { FSUtil } from "@opencode/util/fs-util"
    import { Git } from "@opencode/core/git"
    import { Bus } from "@opencode/core/bus"
    import { Location } from "@opencode/core/location"
    import { AbsolutePath } from "@opencode/core/schema"
    import { Vcs } from "@opencode/core/vcs"
    import { VcsGitPlugin } from "@opencode/core/plugin/vcs/git"
    import { location } from "./fixture/location"
    import { tmpdir } from "./fixture/tmpdir"
    import { it } from "./lib/effect"
    import { host } from "./plugin/host"
    
    async function initGit(directory: string, ...args: string[]) {
      await $`git init -q ${args} ${directory}`.quiet()
      await fs.appendFile(path.join(directory, ".git", "config"), "[commit]\n\tgpgsign = false\n[user]\n\temail = t@t\n\tname = T\n")
    }
    
    const provide = (directory: string) =>
      Effect.provide(
        LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node, Git.node]), {
          replacements: [
            Location.node.replace(
              Layer.succeed(
                Location.Service,
                Location.Service.of(
                  location(
                    { directory: AbsolutePath.make(directory) },
                    {
                      projectDirectory: AbsolutePath.make(directory),
                      vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) },
                    },
                  ),
                ),
              ),
            ),
          ],
        }),
      )
    
    const run = (wt: string) =>
      Effect.gen(function* () {
        const vcs = yield* Vcs.Service
        const context = host()
        yield* VcsGitPlugin.Plugin.effect({ ...context, vcs: { ...context.vcs, transform: vcs.transform, reload: vcs.reload } })
        const working = yield* Effect.exit(vcs.diff("working"))
        const status = yield* Effect.exit(vcs.status())
        const base = yield* Effect.exit(vcs.base())
        return { working, status, base }
      }).pipe(provide(wt))
    
    it.live("pruned linked worktree: diff should not report no changes", () =>
      Effect.gen(function* () {
        const tmp = yield* Effect.promise(() => tmpdir())
        const repo = path.join(tmp.path, "repo")
        const wt = path.join(tmp.path, "wt")
        yield* Effect.promise(async () => {
          await initGit(repo, "-b", "main")
          await fs.writeFile(path.join(repo, "a.txt"), "one\n")
          await $`git add -A && git commit -qm init`.cwd(repo).quiet()
          await $`git worktree add --detach ${wt} HEAD`.cwd(repo).quiet()
          await fs.writeFile(path.join(wt, "uncommitted.txt"), "work in progress\n")
        })
        // Healthy worktree first: change is visible.
        const before = yield* run(wt)
        console.log("healthy diff:", JSON.stringify(Exit.isSuccess(before.working) ? before.working.value.map((x) => x.file) : before.working))
        // Remove the worktree's admin directory, as a prune would.
        yield* Effect.promise(() => fs.rename(path.join(repo, ".git", "worktrees", "wt"), path.join(tmp.path, "moved")))
        const git = yield* Effect.promise(() => $`git status --porcelain=v1 --untracked-files=all --no-renames -z -- .`.cwd(wt).nothrow().quiet())
        console.log("git status exit:", git.exitCode, "stdout:", JSON.stringify(git.stdout.toString()), "stderr:", git.stderr.toString().trim())
        const after = yield* run(wt)
        console.log("pruned diff:", Exit.isSuccess(after.working) ? JSON.stringify(after.working.value) : "FAILURE " + String(after.working.cause))
        console.log("pruned status:", Exit.isSuccess(after.status) ? JSON.stringify(after.status.value) : "FAILURE")
        console.log("pruned base:", Exit.isSuccess(after.base) ? JSON.stringify(after.base.value) : "FAILURE")
        yield* Effect.promise(() => tmp[Symbol.asyncDispose]())
        expect(Exit.isSuccess(before.working) && before.working.value.length).toBeGreaterThan(0)
        // Expected: an error (diff unavailable), not an empty "no changes" list.
        expect(Exit.isFailure(after.working)).toBe(true)
      }),
    )

    Run it: bun vcs-pruned-worktree.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions