Skip to content

fix(git): preserve local-only commits when fetching an existing branch - #1742

Merged
chuks-qua merged 1 commit into
mainfrom
fix/1730-fetch-preserve-local-commits
Sep 22, 2026
Merged

chuks-qua merged 1 commit into
mainfrom
fix/1730-fetch-preserve-local-commits

Conversation

@chuks-qua

@chuks-qua chuks-qua commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

What

GitRepositoryService.fetchBranchAt previously force-moved local branches to the fetched head: git branch -f for ordinary fetches and a forced +pull/<n>/head:<branch> refspec for PR fetches. A local branch holding unpushed or divergent commits was silently rewound whenever the branch was not checked out.

The method now fetches into FETCH_HEAD, checks ancestry, and moves the local branch only through a single atomic non-forced refspec git fetch origin refs/heads/<branch>:refs/heads/<branch>:

  • Local branch ahead of or equal to the fetched head: left untouched.
  • Local branch strictly behind: fast-forwards.
  • Diverged (including a PR ref colliding with an unrelated local branch): throws Local branch "<b>" has diverged from "<src>"; refusing to overwrite local-only commits before altering the branch.
  • Absent branch: still created (tracked origin/<branch> for ordinary fetches, FETCH_HEAD for PR fetches).

The non-forced refspec also gives checked-out/linked-worktree protection and atomicity for free: Git refuses non-fast-forward moves and refs checked out in any worktree inside the same ref transaction.

Verified with a new real-git regression suite (19 tests over RealGitExecutor and disposable bare remotes: ahead/diverged/behind/absent/equal across ordinary and PR variants, worktree protections, local and remote tag-name collisions, fetch-failure preservation), plus a live proof driving git.fetchBranch over the authenticated WebSocket RPC: 4/4 scenarios pass (ahead preserved, PR collision rejected explicitly, absent created, behind fast-forwarded). Targeted lint and typecheck clean; features/projects/git suite 122/122.

Also included: prNumber in the git.fetchBranch contract tightened to int().positive(), and the verify-mcode feature docs/runtime check updated to cover the new regression file and document the explicit-failure startup behavior.

Why

Fixes #1730. Fetching an existing branch (including the deferred PR fetch at thread startup) could destroy local-only work without any warning or error.

Review Notes

  • No UI changes, no config changes, no migrations.
  • Known residual: FETCH_HEAD is a single shared slot, so two concurrent fetchBranchAt calls on the same repo can race on the ancestry probes. The atomic refspec makes this fail-safe (worst case is a spurious divergence error or a branch created at the wrong head, never lost commits); closing it fully would require per-call temp refs, judged not worth the machinery.
  • Ordinary fetch failure with an existing local branch still preserves the branch and resolves, matching pre-existing behavior for local-only branches.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

fetchBranchAt force-moved local branches to the fetched head via
git branch -f (ordinary) and a forced pull/<n>/head refspec (PR),
silently discarding unpushed or divergent commits. The fetch now
stages into FETCH_HEAD, classifies ancestry, and moves the branch
only through an atomic non-forced refspec: ahead or equal branches
are left alone, behind branches fast-forward, and diverged branches
fail explicitly. Adds real-git regression tests and hardens the
git.fetchBranch prNumber schema.

Fixes #1730
@chuks-qua
chuks-qua merged commit 8bfb81c into main Sep 22, 2026
6 checks passed
@chuks-qua
chuks-qua deleted the fix/1730-fetch-preserve-local-commits branch September 22, 2026 13:44
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.

bug(git): fetching an existing branch silently discards unpushed branch history

1 participant