From dd5ad694d3ed3a053646455e22d884c9afc39a3d Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 12 Jun 2026 10:10:46 -0400 Subject: [PATCH] sync: skip trunk fast-forward silently when local branch doesn't exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the trunk branch (e.g. main) doesn't exist locally — only the remote tracking ref (origin/main) exists — `fastForwardTrunk` called `git rev-parse main origin/main` which failed, emitting: ⚠ Could not compare trunk main with remote — skipping trunk update This also caused `stackNeedsRebase` to always return true (since `IsAncestor("main", ...)` errors out), forcing an unnecessary rebase and force-push on every sync. Add a `BranchExists` check at the top of `fastForwardTrunk`. If the local trunk doesn't exist, return silently — there's nothing to fast-forward, and the remote tracking ref is sufficient for rebasing via git's DWIM resolution. --- cmd/sync_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/utils.go | 6 ++++++ 2 files changed, 52 insertions(+) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 83f797e..be43f6b 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -28,6 +28,7 @@ func newSyncMock(tmpDir string, currentBranch string) *git.MockOps { return &git.MockOps{ GitDirFn: func() (string, error) { return tmpDir, nil }, CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + BranchExistsFn: func(name string) bool { return true }, RevParseFn: func(ref string) (string, error) { // Default: origin/ returns same SHA as (no FF needed) if strings.HasPrefix(ref, "origin/") { @@ -403,6 +404,51 @@ func TestSync_TrunkDiverged(t *testing.T) { assert.False(t, pushCalls[0].force, "push should not use force when no rebase") } +// TestSync_NoLocalTrunk_SkipsSilently verifies that when the trunk branch +// does not exist locally (only origin/main exists), sync skips the +// fast-forward silently without emitting a warning. +func TestSync_NoLocalTrunk_SkipsSilently(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + // Trunk does not exist locally. + mock.BranchExistsFn = func(name string) bool { return name != "main" } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.NotContains(t, output, "Could not compare trunk") + assert.NotContains(t, output, "skipping trunk update") + + // Push should still happen + require.Len(t, pushCalls, 1) +} + // TestSync_RebaseConflict_RestoresAll verifies that when a rebase conflict // occurs during sync, all branches are restored to their original state. func TestSync_RebaseConflict_RestoresAll(t *testing.T) { diff --git a/cmd/utils.go b/cmd/utils.go index 1ec6b9e..77c2a96 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -696,6 +696,12 @@ func resolveOriginalRefs(s *stack.Stack) (map[string]string, error) { // fastForwardTrunk fast-forwards the trunk branch to match its remote tracking // branch. Returns true if trunk was updated. func fastForwardTrunk(cfg *config.Config, trunk, remote, currentBranch string) bool { + // If the local trunk branch doesn't exist, there's nothing to + // fast-forward. The remote tracking ref is sufficient for rebasing. + if !git.BranchExists(trunk) { + return false + } + localSHA, remoteSHA := "", "" trunkRefs, trunkErr := git.RevParseMulti([]string{trunk, remote + "/" + trunk}) if trunkErr == nil {