Prevent repo sync from corrupting linked worktrees - #14060
Conversation
Add a new integration-style test for `executeLocalRepoSync` that reproduces syncing a branch (`trunk`) currently checked out in another worktree and verifies the operation succeeds with a clean worktree. This covers a regression-prone Git edge case. The change also adds a `runGit` test helper plus required imports (`os`, `os/exec`, `filepath`, `strings`, and `require`) to set up and validate the repository state.
| } | ||
|
|
||
| err := executeLocalRepoSync(ghrepo.New("OWNER", "REPO"), "origin", opts) | ||
| require.Error(t, err) |
There was a problem hiding this comment.
Started writing this whole test to reproduce the problem, where an error wasn't thrown in this scenario. But now using git branch --force -- …, the user gets an error.
There was a problem hiding this comment.
Pull request overview
This pull request updates gh repo sync’s local-branch update mechanism to avoid corrupting linked worktrees when syncing a branch that’s checked out elsewhere. Instead of mutating refs directly, it delegates worktree-safety checks to Git so the operation is rejected when unsafe, and adds real-Git coverage for the relevant scenarios.
Changes:
- Replace
git update-refusage withgit branch --force -- <branch> <ref>when updating an existing non-current local branch. - Add real-Git tests covering fast-forward/force-update of non-current branches, single-worktree behavior, and rejection/cleanliness when the target branch is checked out in another worktree.
Show a summary per file
| File | Description |
|---|---|
| pkg/cmd/repo/sync/git.go | Switches non-current branch update implementation to git branch --force to make Git enforce worktree safety. |
| pkg/cmd/repo/sync/sync_test.go | Adds real-Git tests exercising non-current branch updates and linked-worktree rejection behavior. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
| require.Equal(t, runGit(t, repoDir, "rev-parse", "FETCH_HEAD"), runGit(t, repoDir, "rev-parse", "trunk")) | ||
| require.Equal(t, "test", runGit(t, repoDir, "branch", "--show-current")) | ||
| require.Empty(t, runGit(t, repoDir, "status", "--porcelain")) | ||
| } |
There was a problem hiding this comment.
The other 3 tests are scenarios suggested by Copilot when I asked about possible regressions in behavior when switching from update-ref to branch --force.
These tests passed before and after the change.
There was a problem hiding this comment.
Review details
Suppressed comments (1)
pkg/cmd/repo/sync/sync_test.go:212
- The local variable name
gitExecutershadows thegitExecutertype in this scope, which makes the test harder to read and can confuse future edits (e.g. adding another composite literal later in the function). Rename the variable to avoid shadowing the type name.
gitClient := &git.Client{RepoDir: repoDir}
gitExecuter := &gitExecuter{client: gitClient}
err := gitExecuter.UpdateBranch("trunk", "FETCH_HEAD")
require.NoError(t, err)
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
Review details
Suppressed comments (1)
pkg/cmd/repo/sync/sync_test.go:226
- The real-git helper inherits the developer/CI machine’s global/system Git config, which can make these tests flaky (e.g. commit.gpgsign=true, init.templatedir, core.hooksPath, etc.). Other tests isolate Git config via GIT_CONFIG_GLOBAL and GIT_CONFIG_NOSYSTEM (see pkg/cmd/auth/shared/gitcredentials/helper_config_test.go:16-27). Consider isolating Git config for all runGit invocations to keep these tests hermetic.
func runGit(t *testing.T, repoDir string, args ...string) string {
t.Helper()
client := &git.Client{RepoDir: repoDir}
cmd, err := client.Command(stdctx.Background(), args...)
require.NoError(t, err)
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
Add shared test helper for isolated Git configuration and use it across credential and repository sync tests.
| } | ||
|
|
||
| // IsolateGitConfig prevents tests from reading global and system Git configuration. | ||
| func IsolateGitConfig(t Environment) { |
There was a problem hiding this comment.
Is there a reason you didn't just accept testing.T here? It seems like this isn't really intended for use for anything other than testing.
`git branch --force` already refuses to move a branch that another worktree has
checked out, but it surfaces a raw git fatal. Detect the case up front with
`git worktree list --porcelain` and return a curated error naming the worktree,
matching the guidance we already give for uncommitted changes.
Detection parses porcelain output rather than matching git's message, which
varies by version ("used by worktree at" on 2.53, "checked out at" on older).
`branch --force` stays as the safety net for states the check can't see, such
as an in-progress bisect.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4f88d532-4a31-47c7-ab50-a3d0b9a77fc4
Reproduces the issue end to end: `main` checked out in the primary worktree while `gh repo sync` runs from a linked worktree. Asserts the sync refuses, the primary worktree's ref and working tree are untouched, and that syncing from the worktree that owns the branch still fast forwards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f88d532-4a31-47c7-ab50-a3d0b9a77fc4
williammartin
left a comment
There was a problem hiding this comment.
I've opened #14076 with some suggestions for an acceptance test and an error message wording improvement. A little bit vibey but shows what I mean. I'll leave it to you to decide what you want to do with this PR. Feel free to push to the branch, or take the commits to yours, or not.
Curate the error when the sync target is checked out in another worktree
There was a problem hiding this comment.
Review details
Suppressed comments (2)
pkg/cmd/repo/sync/sync.go:265
- This adds a pre-check via BranchWorktreePath before mutating the ref, which reintroduces the check-then-update race the PR description says it is avoiding (the branch can become checked out/un-checked-out between the list and the update). A safer approach is to attempt UpdateBranch first (so Git is the source of truth), and only if that fails then call BranchWorktreePath to enrich the error message with the worktree path/tip.
// Updating a branch checked out in another worktree would leave that
// worktree's index and working tree stale, so refuse and point the user at it.
worktreePath, err := git.BranchWorktreePath(branch)
if err != nil {
return err
pkg/cmd/repo/sync/git.go:31
- Minor grammar: “states that check can't see” reads like a missing article and is a bit hard to parse.
// UpdateBranch moves branch to ref. It uses `git branch --force` rather than
// `git update-ref` because update-ref will happily move a branch that is checked out in
// another worktree, leaving that worktree's index and working tree stale. Callers should
// use BranchWorktreePath to detect and explain that case first; this is the safety net
// for states that check can't see, such as an in-progress bisect.
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Lite
Fixes #12927
Description
When
gh repo syncupdates a local branch that is not checked out in the current worktree, it previously usedgit update-ref. If that branch was checked out in another worktree,update-refmoved the shared branch ref without updating that worktree's index or files. The other worktree then incorrectly reported the difference from the newHEADas staged changes.Coming from #14076 (thanks @williammartin 🙇♂️): Before updating an existing non-current branch, inspect
git worktree list --porcelainto determine whether another worktree has the branch checked out. If so, refuse the sync with an error that identifies the worktree and suggests runninggh repo syncfrom there.When the branch is not checked out in another worktree, update it with
git branch --force -- <branch> <ref>. Unlikegit update-ref, this command also refuses to move a branch that Git considers checked out, providing a mutation-time safety net for cases the pre-check may not detect.How did you test this change?
Added unit and real-Git tests covering:
git worktree list --porcelain, including detached and bare worktrees, branch names with slashes, paths containing spaces, and Windows line endings.Added an acceptance test that creates a remote repository and linked worktree, verifies that syncing from the wrong worktree is refused without changing the target worktree, and confirms that syncing from the worktree where the branch is checked out still succeeds.
Ran:
Key points
The explicit worktree check and git branch --force serve different purposes:
git worktree list --porcelainletsghidentify the owning worktree and return a curated, actionable error before attempting the update.git branch --forceremains a safety net at the mutation point. It protects cases the pre-check cannot identify and reduces the impact of state changing between detection and update.There is still a check-then-update window, so the implementation does not rely on the pre-check alone for safety.
The command intentionally refuses instead of modifying another worktree. It directs the user to run
gh repo syncfrom the worktree where the target branch is checked out, preserving the existing dirty-worktree checks and making the directory being changed explicit.Authorship and follow-up
Who wrote this:
Who answers review comments: