From ac0127fa83d3d711bb61ff9b58e343b5d3c7e904 Mon Sep 17 00:00:00 2001 From: bircni Date: Mon, 12 Oct 2026 20:23:02 +0200 Subject: [PATCH] fix(git): refuse to sync a branch checked out in another worktree Signed-off-by: bircni --- internal/git/sync.go | 29 ++++++++++++++++++++++++++++- internal/git/sync_test.go | 18 ++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/internal/git/sync.go b/internal/git/sync.go index 62e74801..8278a876 100644 --- a/internal/git/sync.go +++ b/internal/git/sync.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strings" ) // SyncOptions selects the local branch to fast-forward and the remote whose branch of the same name it follows. @@ -29,7 +30,9 @@ type SyncResult struct { // SyncBranch fast-forwards the local branch to the remote's branch of the same name, creating it // when it is missing, and never changes which branch is checked out. The checked-out branch merges // with --ff-only and needs a clean working tree; another branch moves with update-ref, which fails -// if the branch changed meanwhile. A branch that already contains the remote history is kept. +// if the branch changed meanwhile. A branch that already contains the remote history is kept. A +// branch checked out in another linked worktree is refused, since moving it would leave that +// worktree's files behind its HEAD. func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResult, error) { run := func(args ...string) (string, error) { return runner.Output(ctx, opts.Directory, opts.Prompt, args...) } if opts.Remote == "" || opts.Branch == "" { @@ -47,6 +50,15 @@ func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResul return SyncResult{}, err } checkedOut := head == ref + if !checkedOut { + worktrees, err := run("worktree", "list", "--porcelain") + if err != nil { + return SyncResult{}, err + } + if path := worktreeWithBranch(worktrees, ref); path != "" { + return SyncResult{}, fmt.Errorf("%s is checked out in the worktree %s; sync it from there", opts.Branch, path) + } + } if checkedOut { status, err := run("status", "--porcelain=v1", "--untracked-files=normal") if err != nil { @@ -115,6 +127,21 @@ func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResul return result, nil } +// worktreeWithBranch returns the path of the worktree in git worktree list --porcelain output +// that has ref checked out, or "" when none has. +func worktreeWithBranch(porcelain, ref string) string { + path := "" + for line := range strings.Lines(porcelain) { + line = strings.TrimRight(line, "\r\n") + if value, ok := strings.CutPrefix(line, "worktree "); ok { + path = value + } else if line == "branch "+ref { + return path + } + } + return "" +} + // optional returns "" without an error for a Git command that exits with status 1 to report // that what it looked for does not exist, such as rev-parse --verify --quiet. func optional(output string, err error) (string, error) { diff --git a/internal/git/sync_test.go b/internal/git/sync_test.go index 7129b5fc..98981f44 100644 --- a/internal/git/sync_test.go +++ b/internal/git/sync_test.go @@ -158,6 +158,24 @@ func TestSyncBranchRefusesWhatItCannotFastForward(t *testing.T) { } } +func TestSyncBranchRefusesABranchCheckedOutInAnotherWorktree(t *testing.T) { + f := newSyncFixture(t) + linked := filepath.Join(t.TempDir(), "linked") + f.git(f.work, "worktree", "add", linked, "main") + before := f.git(f.work, "rev-parse", "main") + _, err := f.sync("main") + require.ErrorContains(t, err, "main is checked out in the worktree") + require.Equal(t, before, f.git(f.work, "rev-parse", "main")) + require.Equal(t, "experiment", f.git(f.work, "branch", "--show-current")) +} + +func TestWorktreeWithBranch(t *testing.T) { + porcelain := "worktree /src\nHEAD abc\nbranch refs/heads/experiment\n\nworktree /linked\nHEAD def\nbranch refs/heads/main\n\nworktree /detached\nHEAD 123\ndetached\n" + require.Equal(t, "/linked", worktreeWithBranch(porcelain, "refs/heads/main")) + require.Equal(t, "/src", worktreeWithBranch(porcelain, "refs/heads/experiment")) + require.Empty(t, worktreeWithBranch(porcelain, "refs/heads/other")) +} + func TestSyncBranchValidatesItsInput(t *testing.T) { f := newSyncFixture(t) _, err := SyncBranch(context.Background(), f.runner, SyncOptions{Directory: f.work, Remote: "origin"})