Skip to content

fix: [ENG-2835] checkout from unborn HEAD to fetched ref#656

Merged
bao-byterover merged 5 commits into
mainfrom
fix/ENG-2835
May 18, 2026
Merged

fix: [ENG-2835] checkout from unborn HEAD to fetched ref#656
bao-byterover merged 5 commits into
mainfrom
fix/ENG-2835

Conversation

@bao-byterover
Copy link
Copy Markdown
Collaborator

@bao-byterover bao-byterover commented May 14, 2026

Summary

  • Problem: brv vc checkout <branch> errors with Your current branch does not have any commits yet. Run 'brv vc add' and 'brv vc commit' first. after brv vc init + remote add + fetch, even when the target branch exists as a remote-tracking ref. The flow is identical to git checkout <fetched-branch> on native git, which succeeds by creating a local tracking branch.
  • Why it matters: This is the canonical "clone-like" flow for brv vc users (init, configure remote, fetch, switch to a branch). With the bug, users cannot adopt brv vc against an existing remote without manually committing first, which is misleading and inconsistent with native git muscle memory.
  • What changed: IsomorphicGitService.checkout now resolves the source ref before snapshotting tracked files. When the source ref is unborn (post-init, pre-commit), the listFiles snapshot is skipped and the staged-conflict guard receives undefined for the source branch. The guard treats every staged blob as new in that case, and resolves the target ref via a small DWIM helper (resolveCheckoutRef) so a real conflict still trips on a remote-only target.
  • What did NOT change (scope boundary): Same-branch refresh semantics (e.g. running brv vc checkout main to restore a wiped workdir), other vc handlers (reset, commit, merge, pull, isAncestor), and the existing checkout error taxonomy in vc-handler. Sibling NotFoundError audit findings are documented in agent-tmp/vc-checkout-sibling-audit.md for separate tickets.
  • Intentional side effect: the guardStagedConflicts gate widened from if (!params.force && sourceBranch) to if (!params.force). Previously, detached HEAD bypassed the guard entirely, leaving the documented isomorphic-git data-loss bug (silent overwrite of staged conflicts) unprotected in that mode. Now covered, with test/integration/infra/git/isomorphic-git-service.test.ts: "blocks checkout from detached HEAD when a staged file conflicts with target tree".

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Test
  • Chore (build, dependencies, CI)

Scope (select all touched areas)

  • TUI / REPL
  • Agent / Tools
  • LLM Providers
  • Server / Daemon
  • Shared (constants, types, transport events)
  • CLI Commands (oclif)
  • Hub / Connectors
  • Cloud Sync
  • CI/CD / Infra

Linked issues

  • Linear: ENG-2835

Root cause (bug fixes only, otherwise write N/A)

  • Root cause: IsomorphicGitService.checkout snapshots the source-branch file set via git.listFiles({ref: sourceBranch}) at the start of the method. getCurrentBranch returns 'main' after brv vc init because HEAD points at refs/heads/main, but no commit has been written, so refs/heads/main does not resolve. git.listFiles throws NotFoundError: Could not find main, the error bubbles up to handleCheckout, which catches NotFoundError, runs log({depth: 1}) to disambiguate, finds zero commits, and returns the misleading NO_COMMITS error. The actual checkout never executed.
  • Why this was not caught earlier: every existing checkout() test in test/integration/infra/git/isomorphic-git-service.test.ts set up a committed seed file in beforeEach, so the unborn-source code path was never exercised. The bug only fires when the user reaches git.checkout without ever committing locally, a flow no test covered.

Test plan

  • Coverage added:
    • Unit test
    • Integration test
    • Manual verification only
  • Test file(s): test/integration/infra/git/isomorphic-git-service.test.ts
  • Key scenarios covered:
    • Unborn HEAD plus fetched remote-only ref: checkout succeeds, local tracking branch created, workdir matches target tree.
    • Stale local main plus fetched new branch: checkout still works, no regression on the previously-passing path.
    • Unborn HEAD plus staged file conflicting with target tree: guardStagedConflicts still trips, no silent data loss.
    • Unborn HEAD plus untracked workdir file conflicting with target tree: isomorphic-git's CheckoutConflictError still trips, no silent overwrite.

User-visible changes

  • brv vc checkout <branch> after brv vc init + remote add + fetch now succeeds when <branch> exists as a remote-tracking ref, instead of erroring with "no commits yet".
  • No change to the same-flow with a born local branch.
  • No change to flag defaults, transport events, or status output.

Evidence

Failing before, passing after (single test):

Before (main code):

1) IsomorphicGitService > checkout() > checks out a remote-tracking ref from an unborn HEAD:
   NotFoundError: Could not find main.
   at GitRefManager.resolve (node_modules/isomorphic-git/index.cjs:2217:11)
   at async IsomorphicGitService.checkout (.../isomorphic-git-service.ts:197)

After (this branch):

IsomorphicGitService
  checkout()
    ✔ switches to a different branch
    ✔ blocks checkout when staged changes conflict with target branch
    ✔ allows checkout when staged changes do not conflict with target branch
    ✔ allows checkout with force even when staged changes conflict
    ✔ checks out a remote-tracking ref from an unborn HEAD (init + fetch + checkout)
    ✔ checks out a remote-tracking ref when local main is on a stale commit (fetch + checkout)
    ✔ blocks checkout when unborn HEAD has a staged file that conflicts with target tree
    ✔ blocks checkout when unborn HEAD workdir has an untracked file conflicting with target tree

  8 passing (294ms)

End-to-end against the live remote (brv binary, fresh daemon):

$ brv vc init
Initialized ByteRover version control in /private/tmp/brv-vc-bug-repro/.brv/context-tree/.git
$ brv vc remote add origin https://byterover.dev/byterover-inc/byterover-cli.git
Remote 'origin' set to https://byterover.dev/byterover-inc/byterover-cli.git.
$ brv vc fetch
Fetched from origin.
$ brv vc checkout feat/ENG-2512
Switched to branch 'feat/ENG-2512'
$ brv vc status
On branch feat/ENG-2512
Nothing to commit, working directory clean

Checklist

  • Tests added or updated and passing (integration suite: 112 passing)
  • Lint passes (0 errors, 15 warnings, all pre-existing and outside the diff range)
  • Type check passes (tsc --noEmit clean on both root and src/webui/tsconfig.json)
  • Build succeeds (npm run build clean, dist regenerated)
  • Commits follow Conventional Commits format (fix: [ENG-2835] checkout from unborn HEAD to fetched ref)
  • Documentation updated (N/A, no public docs reference this path)
  • No breaking changes (no behavior change for previously-passing flows, only the previously-erroring flow now succeeds)
  • Branch is up to date with main (rebased onto origin/main a8594cbda before commit)

Risks and mitigations

  • Risk: resolveCheckoutRef does DWIM lookup across all configured remotes by iterating git.listRemotes. If the user has multiple remotes that each have a branch with the same short name, the first remote's ref wins and the guard compares against that ref. The chosen ref may differ from what git.checkout itself selects, which could in theory cause guardStagedConflicts to miss or over-report a conflict in a multi-remote setup.
    • Mitigation: The order matches git.listRemotes, which mirrors the order remotes were added. In practice brv vc users have a single origin remote, so the ambiguity does not arise. If a multi-remote regression surfaces later, narrow the lookup to the tracking remote of the current branch.
  • Risk: Skipping the source-file snapshot when source is unborn means no orphan cleanup runs. This is correct (an unborn source has no tracked files to orphan), but is a behavioral subtlety worth noting.
    • Mitigation: Covered by the new "unborn HEAD plus fetched remote ref" test which asserts the post-checkout workdir count matches the target tree exactly.

The checkout snapshot resolved the source branch via git.listFiles before
delegating, which throws NotFoundError when HEAD is on an unborn branch
(post-init, pre-commit). The handler then mapped the error to "no commits
yet", masking the real cause and blocking the normal init + fetch + checkout
flow against a remote-only ref.

Resolve the source ref first; on unresolved (unborn) source, skip the
listFiles snapshot and pass undefined into guardStagedConflicts. The guard
now accepts a missing source — every staged blob is treated as new — and
resolves the target via a small DWIM helper so a real conflict still trips
on a remote-only target.
@bao-byterover bao-byterover requested a review from leehpham as a code owner May 14, 2026 07:34
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 14, 2026

Claude finished @bao-byterover's task in 3m 4s —— View job


Review — PR #656

  • Read changed files
  • Analyze correctness (root cause + fix)
  • Check edge cases & risks
  • Review test coverage
  • Post inline comments + summary

Summary

Overall score: 4 / 5 — solid bug fix with a clear root-cause writeup, good integration-test coverage of the unborn-HEAD path, and the author has been honest about the multi-remote DWIM risk. The patch is minimal and targeted: it does not touch sibling handlers, doesn't widen scope, and the new resolveCheckoutRef helper is small and self-contained. A few sharp edges worth addressing or at least documenting before merge.

What works well

  • Root cause is correctly identified (git.listFiles({ref: 'main'}) blowing up on an unborn ref) and the fix is at the right level — catch the resolution failure, treat as "no tracked source files", and skip orphan cleanup (which has nothing to clean up on an unborn ref anyway).
  • Decoupling guardStagedConflicts from a resolvable source ref is the right call — passing sourceOid = undefined and letting sourceBlobOid stay undefined cleanly degrades to "every staged blob is new content" without special-casing.
  • Four new integration tests cover the happy path (unborn + DWIM), the regression-prevention path (stale local main), and both directions of conflict (staged + untracked). That's the right matrix.
  • Helper introduction (resolveCheckoutRef) keeps the DWIM logic out of the hot path of checkout() itself.

Issues — see inline comments

  1. Stealth behavior change for detached HEAD (medium). The gate moved from if (!params.force && sourceBranch) to if (!params.force), which means the staged-conflict guard now also runs in detached-HEAD mode where it previously didn't. Probably an improvement, but undocumented in the PR body and untested. (Fix this →)
  2. DWIM ambiguity in resolveCheckoutRef (low, acknowledged by author). First-remote-wins instead of native git's "refuse on ambiguity". Single-remote users are fine; multi-remote can produce silent guard misalignment with git.checkout's own resolution.
  3. Helper name vs. return type: resolveCheckoutRef returns an OID, not a ref. Rename to resolveCheckoutOid for clarity.
  4. Silent skip when target OID is missing: guardStagedConflicts returns early on !targetOid. Defensible (the subsequent git.checkout will raise its own error), but the comment trail should say so.
  5. Redundant resolution: git.resolveRef(sourceBranch) runs twice — once in checkout() (line 199) and once inside guardStagedConflicts (line 1079). Threading sourceOid through avoids a duplicate .catch(() => {}) and one extra fs round-trip.
  6. .catch(() => {}).catch(() => undefined) (style): four occurrences, including the new helper. The current form returns Promise<void> and relies on TS narrowing; explicit undefined matches CLAUDE.md's "default to undefined" guidance.
  7. Test hygiene: await import('node:fs') in every test body, and remoteDir/freshDir/localDir cleaned up inline (no afterEach net), so a failing assertion mid-test leaks tmp dirs. Low priority but worth a single-line refactor.

No blocking issues. Recommend addressing (1) — at minimum a test or a PR-body callout — and (3)/(5) as cheap cleanups before merge. (2) is fine to defer to a follow-up given the documented mitigation. The rest are nits.
· branch fix/ENG-2835

Comment thread src/server/infra/git/isomorphic-git-service.ts Outdated
Comment thread src/server/infra/git/isomorphic-git-service.ts Outdated
Comment thread src/server/infra/git/isomorphic-git-service.ts Outdated
Comment thread src/server/infra/git/isomorphic-git-service.ts
Comment thread test/integration/infra/git/isomorphic-git-service.test.ts
- Extract `tryResolveRef` helper to replace inline `.catch(() => {})` (one
  call site, three previous duplications). Lint rule
  `unicorn/no-useless-undefined` blocks `.catch(() => undefined)`, so the
  helper is the lint-clean way to land the explicit-undefined semantics.
- Thread `sourceOid` through `guardStagedConflicts` instead of re-resolving
  the source branch inside the helper. One fewer fs round-trip per checkout.
- `resolveCheckoutRef` now refuses on multi-remote ambiguity (collect all
  matches, return undefined when more than one), matching native git's DWIM
  refuse-on-ambiguity instead of first-wins.
- Annotate `guardStagedConflicts`'s early return on missing target OID so
  the trade-off (resolver could disagree with `git.checkout`'s internal
  resolution) is documented in-source.
- Drop the now-redundant `sourceBranchResolves` flag; use truthy `sourceOid`
  directly. Update the gate comment to mention detached HEAD explicitly.
- Add an integration test asserting the guard now also trips on
  detached-HEAD + staged-conflict (previously skipped, leaving the
  documented data-loss bug unprotected — fixed as a side effect of the
  ENG-2835 gate change).
- Hoist test cleanup into an `extraTmpDirs` array drained in `afterEach`,
  so tmp dirs no longer leak when an assertion mid-test throws.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 14, 2026

Claude finished @bao-byterover's task in 3m 16s —— View job


Review — PR #656 (re-review after 552d2e5)

  • Diff the new commit against the prior review
  • Re-check correctness on checkout() + resolveCheckoutRef
  • Verify each prior-review item was addressed (or consciously deferred)
  • Look for new regressions / new tests
  • Post inline comments + summary

Summary

Overall score: 4.5 / 5552d2e5 is a clean, focused response to the prior review. Almost every item was resolved with the right surgical fix; the residuals are nits and a docstring tightening. Ready to merge after the optional follow-ups below.

Prior-review resolution status

# Prior issue Status
1 Stealth detached-HEAD broadening ✅ Resolved — new integration test at test/integration/infra/git/isomorphic-git-service.test.ts:889 exercises detached-HEAD + staged conflict, gate comment at src/server/infra/git/isomorphic-git-service.ts:205-209 calls it out explicitly
2 DWIM ambiguity (first-remote-wins) ✅ Resolved — resolveCheckoutRef now refuses on ambiguity (matches.length === 1), matches native git
3 Helper name vs. return type (resolveCheckoutRef returns OID) ⚠️ Not addressed — minor, see inline at line 1401
4 Silent skip when target OID missing ✅ Resolved — inline trade-off comment at lines 1084-1088
5 Redundant source-ref resolution ✅ Resolved — sourceOid threaded through guardStagedConflicts
6 .catch(() => {}) style ✅ Resolved — extracted tryResolveRef helper since unicorn/no-useless-undefined blocked .catch(() => undefined)
7 Test hygiene (tmp-dir leaks on assertion failure) ✅ Resolved — extraTmpDirs array drained in afterEach

Residual issues (all non-blocking, see inline)

  1. tryResolveRef swallows all errors, not just NotFoundError (isomorphic-git-service.ts:1490). A filesystem permission error or corrupted ref store silently degrades to "ref doesn't exist", which then degrades the guard. Narrow to git.Errors.NotFoundError.
  2. resolveCheckoutRef naming carry-over — returns an OID, name reads like a ref. Cheap rename, can be deferred.
  3. PR body "What did NOT change" is stale — the detached-HEAD broadening should be added to the scope statement so it shows up in release notes / git log --merges. Commit message has it; PR body doesn't.
  4. Test precondition assertion shape — the new detached-HEAD test has a great expect(branchNow, 'precondition: HEAD must be detached') assert; the unborn-HEAD tests would benefit from a similar precondition assert that resolveRef('main') actually throws (otherwise the test still "passes" if isomorphic-git ever softens that behavior).

Optional follow-up (carryover, low priority)

  • DWIM now refuses on ambiguity (good), but doesn't honor checkout.defaultRemote or HEAD's tracking-remote preference the way native git does. Fine for single-remote brv vc users; worth a ticket if the multi-remote story expands.

· branch fix/ENG-2835

Comment thread src/server/infra/git/isomorphic-git-service.ts
Comment thread src/server/infra/git/isomorphic-git-service.ts
Comment thread src/server/infra/git/isomorphic-git-service.ts
Comment thread test/integration/infra/git/isomorphic-git-service.test.ts
isomorphic-git's `git.checkout({ref: sha})` creates a local branch from the
SHA instead of detaching HEAD, and the library exposes no `--detach` mode.
A filesystem write to `.git/HEAD` is the only way to reach detached state
for the staged-conflict test. Document that in-test so the next reader does
not wonder whether the API was tried first.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 14, 2026

Claude finished @bao-byterover's task in 2m 11s —— View job


Review — PR #656 (post-69a6aa8)

  • Read latest diff from origin/main
  • Inspect updated isomorphic-git-service.ts
  • Inspect updated test file
  • Cross-check prior-review residuals against author responses
  • Post inline comments + summary

Summary

Overall score: 5 / 569a6aa8 is a comment-only follow-up that closes the last actionable nit from the previous review (filesystem-layer .git/HEAD write needs explanation). The author has either resolved or thoughtfully deferred every other residual with a defensible rationale. Nothing left blocking, and the deferrals are well-argued.

69a6aa8 itself: three lines of comment added at test/integration/infra/git/isomorphic-git-service.test.ts:902-905. Explains why the test detaches HEAD via direct .git/HEAD write (isomorphic-git's git.checkout({ref: sha}) has no --detach mode). Good comment hygiene — concise, names the constraint, gives the next reader enough to verify independently.


Prior-review residuals (after re-checking against the code)

# Prior issue Status Notes
1 tryResolveRef swallows all errors ⏭️ Deferred (justified) Author noted isomorphic-git-service.ts already uses broad .catch(() => null/[]/{}) at lines 102, 114, 328-329, 360, 1405, etc. Verified — narrowing only tryResolveRef would in fact reduce consistency. Worth a follow-up ticket for a codebase-wide hygiene pass, not in-scope here
2 resolveCheckoutRef name vs. OID return ⏭️ Deferred (justified) Author cited the matching pattern from git.resolveRef itself — accepting
3 PR body "What did NOT change" missed detached-HEAD broadening ✅ Resolved "Intentional side effect" bullet added; merge-commit and release-note views now match commit message + in-source comment
4 Test precondition assertion shape ✅ Partially resolved New comment on filesystem-write rationale. Precondition-style assert not spread to unborn-HEAD tests; author argued the existing getCurrentBranch === 'main' check is the moral equivalent, with explicit deferral logged. Reasonable trade-off — see inline note for the residual gap

Code quality assessment

Final state of the patch:

  • checkout() (line 189-235): clean. Source-ref resolution via tryResolveRef, gate is if (!params.force) with contract spelled out in comment block. sourceOid threaded through guardStagedConflicts to avoid redundant resolution.
  • guardStagedConflicts (line 1071-1106): clean signature (sourceOid: string | undefined), comment block at line 1084-1088 honestly documents the resolver-disagreement trade-off.
  • resolveCheckoutRef (line 1401-1415): DWIM-with-refuse-on-ambiguity (matches.length === 1) matches native git. Helper extraction (tryResolveRef, line 1490) tidied up the .catch(() => undefined) repetition.
  • Test coverage (test/integration/infra/git/isomorphic-git-service.test.ts:760-964): 4 new tests cover unborn + DWIM, stale-local-main regression, unborn + staged conflict, unborn + untracked conflict, plus the detached-HEAD + staged-conflict guard. extraTmpDirs array drained in afterEach (line 67-75) — tmp-dir leaks on assertion failure closed.

Open follow-up tickets worth filing (out of scope here):

  1. Codebase-wide hygiene pass on broad-catch patterns in isomorphic-git-service.ts (narrow to specific error classes where the failure mode matters).
  2. Honor checkout.defaultRemote and the current branch's tracking-remote preference in resolveCheckoutRef when the multi-remote story expands.

Recommendation: LGTM, ready to merge. Inline note on the test file is informational only.
· branch fix/ENG-2835

Comment thread test/integration/infra/git/isomorphic-git-service.test.ts
@bao-byterover
Copy link
Copy Markdown
Collaborator Author

Follow-up commit on this branch: 4d276bfef fix: [ENG-2835] map NotFoundError to BRANCH_NOT_FOUND, drop misleading NO_COMMITS (local only for now, will be in the next push).

What it fixes: another user hit brv vc init && brv vc remote add origin <url> && brv vc fetch && brv vc checkout zorqplex_nonexistent_4242 and got the same misleading Your current branch does not have any commits yet. Run 'brv vc add' and 'brv vc commit' first. error this ticket was supposed to retire. The original PR fixed the source-ref-unborn case but the handler at vc-handler.ts:559-572 still emitted NO_COMMITS whenever git.checkout threw NotFoundError and log({depth: 1}) returned zero — which is exactly the post-fetch-pre-commit state. The commit-count branch fires for missing-target on unborn HEAD instead of missing-source.

Fix: drop the disambiguation. After ENG-2835 the only NotFoundError reaching this catch is target-ref-missing, so always emit BRANCH_NOT_FOUND. The NO_COMMITS error code is still produced by handleBranch, handleLog, and the merge handler — only the misleading checkout producer is gone.

Test: test/unit/infra/transport/handlers/vc-handler.test.ts: "should throw BRANCH_NOT_FOUND (not NO_COMMITS) when target ref missing from unborn HEAD" — exact zorqplex_nonexistent_4242 repro, asserts the right error code AND that the message does NOT include "does not have any commits yet".

Honest note: this is covered by an acceptance criterion I wrote in the original ticket md ("brv vc checkout <ref-that-does-not-exist> from unborn HEAD still surfaces a 'branch not found' style error, not the misleading 'no commits yet' message") but never verified — neither with a test nor with a live command. A separate review nit on this taxonomy (round 1 audit, vc-handler.ts:559-572) was labeled "cosmetic" in my response and skipped; it was actually user-facing. Apologies for the gap; thanks to the user who caught it.

Origin of the bug (per git blame): commit 4dd3ffd69e (feat: [ENG-1656] Implement the brv vc clone, push, add remote use clean URLs, 2026-03-26). The commits.length === 0 → NO_COMMITS disambiguation was added there. Not introduced by ENG-2835; this PR is finishing the cleanup.

Live verification:

$ brv vc init && brv vc remote add origin <url> && brv vc fetch
$ brv vc checkout zorqplex_nonexistent_4242
 ›   Error: Branch 'zorqplex_nonexistent_4242' not found. Use 'brv vc checkout
 ›   -b zorqplex_nonexistent_4242' to create it.

Tests:

$ npx mocha test/unit/infra/transport/handlers/vc-handler.test.ts
231 passing (818ms)

$ npx mocha test/integration/infra/git/isomorphic-git-service.test.ts
113 passing (2s)

… count

The checkout-handler catch block previously used `log({depth: 1}).length === 0`
to decide between BRANCH_NOT_FOUND and NO_COMMITS. That proxy was added in
4dd3ffd (ENG-1656) to make `brv vc init && brv vc checkout main` (current
branch is unborn) say "no commits yet" instead of "branch not found".

After ENG-2835 the proxy collapses two distinct cases into the same wrong
answer when HEAD is unborn:
  (a) user typoed the target name (e.g. `checkout zorqplex_nonexistent_4242`)
      → message should be BRANCH_NOT_FOUND with the `-b` hint.
  (b) user asked to checkout the branch they are already on, but it is unborn
      (`init && checkout main`)
      → message should be NO_COMMITS with the add/commit/pull hint.

Both cases produce zero commits, so commit-count cannot separate them. The
target-name vs current-branch comparison can: case (a) differs, case (b)
matches. Switch the disambiguator to `data.branch === previousBranch`.
Detached HEAD (`previousBranch === undefined`) falls through to (a) which is
correct — checking out a typoed ref from a detached state is still
target-not-found.

Two unit tests added:
- `should throw BRANCH_NOT_FOUND (not NO_COMMITS) when target ref missing
  from unborn HEAD` — the user-reported `zorqplex_nonexistent_4242` flow.
- `should throw NO_COMMITS when checking out the current unborn branch by
  name` — the ENG-1656 flow this handler was originally written to protect.

Verified live with `brv` against byterover-cli.git: all three flows now
print the right message (typo → branch-not-found, current-unborn → no
commits, real ref → switched).
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 14, 2026

Claude finished @bao-byterover's task in 2m 16s —— View job


Review — PR #656 (after 9a8b4c8)

  • Diff 9a8b4c8 vs prior state on this branch
  • Inspect updated vc-handler.ts and the dropped commit-count disambiguation
  • Inspect new test in vc-handler.test.ts
  • Cross-check author's claim: only target-ref NotFoundError reaches the catch now
  • Post review summary

Overall score: 5 / 59a8b4c8 is a thoughtful, surgical follow-up that closes the user-reported gap and is actually a better fix than what the PR comment originally outlined. The author proposed "drop the disambiguation, always emit BRANCH_NOT_FOUND"; the commit instead kept the disambiguator and swapped its predicate from a broken proxy (log({depth:1}).length === 0) to one that holds: data.branch === previousBranch. That preserves the legitimate ENG-1656 case (brv vc init && brv vc checkout main → NO_COMMITS is the right message) while fixing the typo case (checkout zorqplex_nonexistent_4242 from unborn HEAD → BRANCH_NOT_FOUND). Glad you reconsidered the original "always BRANCH_NOT_FOUND" plan — it would have been wrong for case (b).

Correctness check

Walked the four reachable inputs against the new predicate at src/server/infra/transport/handlers/vc-handler.ts:568:

Scenario previousBranch data.branch Predicate Emitted code Correct?
(a) Typo from unborn HEAD 'main' 'zorqplex_…' differ BRANCH_NOT_FOUND
(b) init && checkout main (current = target, unborn) 'main' 'main' match NO_COMMITS
(c) Typo from detached HEAD undefined 'zorqplex_…' differ BRANCH_NOT_FOUND
(d) Typo from born main (pre-existing test, L3026) 'main' 'nonexistent' differ BRANCH_NOT_FOUND

After ENG-2835 the source-ref listFiles no longer throws, so the catch only sees target-ref-missing — the author's reasoning holds. The dead gitService.log({depth:1, directory}) call is correctly removed, eliminating one filesystem side-effect from the error-handling path.

What works well

  • Predicate is the natural one — the only ambiguity the old proxy collapsed was "same-branch unborn" vs "different-target unborn", and the target-name comparison separates exactly those two. Detached HEAD falls through to BRANCH_NOT_FOUND because 'foo' !== undefined, which is the right default.
  • Improved NO_COMMITS message"… or pull from a remote." is the actionable add given the new post-init+fetch flow this PR enables. Before, the message was hostile after fetch.
  • Two-case test coverage — both cases the disambiguator separates are now locked in (vc-handler.test.ts:3048 and :3074). The negative-assertion expect(error.message).to.not.include('does not have any commits yet') in the typo test is a nice belt-and-braces check that would catch a regression if someone restored the old default.
  • In-source comment block at vc-handler.ts:560-567 explains the why including the prior-art reference (ENG-1656, commit 4dd3ffd69e). Future readers will not have to dig.
  • Honesty in the PR comment about the ticket-acceptance-criterion vs verification gap is a useful signal — the AC was written, never tested, and a separate review nit on this taxonomy was deferred as "cosmetic" when it was actually user-facing. Process improvement worth noting.

Residual nits (all non-blocking)

  1. No explicit test for detached HEAD + typo — the table above shows case (c) is correct by construction, but it isn't covered by a test. A two-line variant of vc-handler.test.ts:3048 with deps.gitService.getCurrentBranch.resolves(undefined) would lock it in. Low priority; the pre-existing test at :3026 indirectly exercises the "differ" branch.
  2. Dead gitService.log.resolves(...) stubs in the new test at vc-handler.test.ts:3054 (deps.gitService.log.resolves([])) and the pre-existing test at vc-handler.test.ts:3029-3031. The new code path never calls log(), so these stubs are now no-ops. Cosmetic, but worth a sweep — leaving them suggests the test still depends on log and could mislead the next reader.
  3. PR-body comment vs actual commit drift — the comment at 2026-05-14T10:38:25Z describes a "drop disambiguation / always BRANCH_NOT_FOUND" approach. The pushed commit 9a8b4c8 (title: disambiguate NotFoundError by branch name, not commit count) keeps the disambiguator. A one-line addendum to the PR body's "Root cause" or "What changed" section, explaining the better-than-described fix, would keep the merge-commit / release-note view honest. The commit message itself is excellent — this is purely about the PR body.
  4. Predicate could be defensively guardeddata.branch === previousBranch is correct because data.branch is a non-empty string (Zod-validated) and previousBranch is string | undefined, so a previousBranch === undefined cannot spuriously match data.branch. Worth a one-line comment noting the schema guarantee, or no change at all if you treat the schema as the contract.
  5. Edge: fully-qualified refsbrv vc checkout refs/heads/main while on unborn main would emit BRANCH_NOT_FOUND (not NO_COMMITS) because 'refs/heads/main' !== 'main'. Real users don't type fully-qualified refs at the CLI, but it's a behavioral asymmetry vs git's own normalization. Not worth fixing here — flag if someone surfaces it.

Confidence

Tests should be re-run locally to confirm 113 integration + the new 2 unit tests pass — the PR comment claims they do, no reason to doubt. The implementation is small, the diff is readable, and the comment hygiene is excellent.

Recommendation: LGTM. Merge after nit (3) — the PR body addendum — if you want the durable record to match the commit. Everything else is deferrable.


· branch fix/ENG-2835 ·

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 18, 2026

Claude finished @bao-byterover's task in 2m 2s —— View job


Review — PR #656 (post-3c19b75 merge with main)

  • Diff latest commits against origin/main
  • Check merge commit 3c19b75 for any non-trivial conflict resolution
  • Verify residuals from prior reviews
  • Inspect current state of changed files
  • Post final review

Summary

Overall score: 5 / 53c19b75 is a clean merge of main into fix/ENG-2835. It does not touch any of the four files in this PR (confirmed via git log 9a8b4c8..3c19b75 -- <the four files> — empty), so there is no new diff to review at the code level. The PR's substantive content is unchanged since the previous re-review on 9a8b4c8, which closed every actionable nit and was approved by cuongdo-byterover on 2026-05-15. Posting this re-review purely to confirm the merge did not regress anything.


What the merge brought in (informational)

The main advance (a8594cb..0a274b3) is large (~6,030 inserts / ~1,879 deletes across 133 files) but cleanly disjoint from this PR: billing/HITL/team-select work, web-UI panels, OpenClaude installer, and minor server-side touches. None of it intersects with src/server/infra/git/, src/server/infra/transport/handlers/vc-handler.ts, or the two test files this PR owns. Nothing for the author to reconcile.


Current state of the patch (verified post-merge)

  • src/server/infra/git/isomorphic-git-service.ts:189-235checkout() resolves sourceOid via tryResolveRef, threads it through guardStagedConflicts, and the gate is if (!params.force) with the detached-HEAD-broadening intent spelled out in the comment block. Unchanged from 9a8b4c8.
  • src/server/infra/git/isomorphic-git-service.ts:1071-1106guardStagedConflicts takes sourceOid: string | undefined, with the resolver-disagreement trade-off documented in-source. Unchanged.
  • src/server/infra/git/isomorphic-git-service.ts:1401-1415resolveCheckoutRef does DWIM-with-refuse-on-ambiguity (matches.length === 1). Unchanged.
  • src/server/infra/git/isomorphic-git-service.ts:1490-1496tryResolveRef helper. Unchanged. The broad-catch hygiene deferral remains (author argued, correctly, that narrowing only this helper would reduce consistency with the rest of the file).
  • src/server/infra/transport/handlers/vc-handler.ts:559-579NotFoundError disambiguated by data.branch === previousBranch (not commit count). Comment block at 560-567 explains the prior-art reference (ENG-1656, 4dd3ffd69e) and why the predicate switched. Unchanged.
  • test/integration/infra/git/isomorphic-git-service.test.ts:54-75, 760-964extraTmpDirs drained in afterEach; four new unborn/stale/conflict integration tests plus the detached-HEAD guard test, with the filesystem-layer .git/HEAD write rationale commented at 902-905. Unchanged.
  • test/unit/infra/transport/handlers/vc-handler.test.ts:3048+, 3074+ — the two-case taxonomy test (typo + same-branch unborn) added in 9a8b4c8. Unchanged.

Residual carryover (all non-blocking, all previously acknowledged)

  1. Codebase-wide hygiene pass on broad .catch(() => null/[]/{}) in isomorphic-git-service.ts — worth a follow-up ticket, out of scope here.
  2. resolveCheckoutRef name carryover (returns OID, not ref) — author declined; matches git.resolveRef's own convention. Fine to keep.
  3. checkout.defaultRemote and tracking-remote preference in DWIM — only matters for multi-remote setups, which is not the documented brv vc use case.
  4. Precondition-style assert (await expect(resolveRef('main')).to.be.rejected) in the unborn-HEAD integration tests — author argued the existing expect(beforeBranch).to.equal('main') is the moral equivalent. Reasonable deferral.

Recommendation: LGTM, the merge is safe, the PR is still in the state the previous review approved. Ready to merge whenever the author is ready.
· branch fix/ENG-2835

@bao-byterover bao-byterover merged commit 12ce3d0 into main May 18, 2026
6 checks passed
@bao-byterover bao-byterover deleted the fix/ENG-2835 branch May 18, 2026 03:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants