From bb2dca6cd22ba685bc882cd3ca1695da77910519 Mon Sep 17 00:00:00 2001 From: milldr Date: Wed, 11 Mar 2026 17:47:41 -0400 Subject: [PATCH 1/2] Handle branch switching on flow render when state.yaml branch changes --- internal/git/git.go | 21 +++ internal/workspace/workspace.go | 65 +++++++- internal/workspace/workspace_test.go | 232 +++++++++++++++++++++++++-- 3 files changed, 301 insertions(+), 17 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index f71c15d..a3b7160 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -23,6 +23,9 @@ type Runner interface { EnsureRemoteRef(ctx context.Context, bareRepo, branch string) error ResetBranch(ctx context.Context, worktreePath, ref string) error IsClean(ctx context.Context, worktreePath string) (bool, error) + CurrentBranch(ctx context.Context, worktreePath string) (string, error) + CheckoutBranch(ctx context.Context, worktreePath, branch string) error + CheckoutNewBranch(ctx context.Context, worktreePath, newBranch, startPoint string) error Rebase(ctx context.Context, worktreePath, onto string) error RebaseAbort(ctx context.Context, worktreePath string) error } @@ -174,6 +177,24 @@ func (r *RealRunner) IsClean(ctx context.Context, worktreePath string) (bool, er return out == "", nil } +// CurrentBranch returns the currently checked-out branch in a worktree. +func (r *RealRunner) CurrentBranch(ctx context.Context, worktreePath string) (string, error) { + r.log().Debug("getting current branch", "path", worktreePath) + return r.output(ctx, "-C", worktreePath, "rev-parse", "--abbrev-ref", "HEAD") +} + +// CheckoutBranch switches to an existing branch in a worktree. +func (r *RealRunner) CheckoutBranch(ctx context.Context, worktreePath, branch string) error { + r.log().Debug("checking out branch", "path", worktreePath, "branch", branch) + return r.run(ctx, "-C", worktreePath, "checkout", branch) +} + +// CheckoutNewBranch creates and switches to a new branch from a start point. +func (r *RealRunner) CheckoutNewBranch(ctx context.Context, worktreePath, newBranch, startPoint string) error { + r.log().Debug("checking out new branch", "path", worktreePath, "branch", newBranch, "start_point", startPoint) + return r.run(ctx, "-C", worktreePath, "checkout", "-b", newBranch, startPoint) +} + // Rebase rebases the current branch onto the given ref. func (r *RealRunner) Rebase(ctx context.Context, worktreePath, onto string) error { r.log().Debug("rebasing", "path", worktreePath, "onto", onto) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index febf90f..c16797a 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -320,20 +320,70 @@ func (s *Service) createWorktree(ctx context.Context, rc *repoRenderContext, pro return nil } -// updateWorktree resets an existing worktree to the latest remote ref for its -// branch so that re-rendering always picks up new upstream commits. +// updateWorktree checks for branch drift and either switches branches or +// updates the existing worktree to the latest remote ref. func (s *Service) updateWorktree(ctx context.Context, rc *repoRenderContext, progress func(msg string)) error { - // Ensure the remote tracking ref exists for this branch so we can - // check if the branch exists on the remote. + currentBranch, err := s.Git.CurrentBranch(ctx, rc.worktreePath) + if err != nil { + return fmt.Errorf("getting current branch for %s: %w", rc.repo.URL, err) + } + + if currentBranch != rc.repo.Branch { + return s.switchWorktreeBranch(ctx, rc, currentBranch, progress) + } + + return s.updateWorktreeRemote(ctx, rc, progress) +} + +// switchWorktreeBranch handles the case where state.yaml specifies a different +// branch than what's currently checked out in the worktree. +func (s *Service) switchWorktreeBranch(ctx context.Context, rc *repoRenderContext, currentBranch string, progress func(msg string)) error { + clean, err := s.Git.IsClean(ctx, rc.worktreePath) + if err != nil { + return fmt.Errorf("checking worktree status for %s: %w", rc.repo.URL, err) + } + if !clean { + s.log().Debug("worktree is dirty, cannot switch branch", "path", rc.worktreePath, "from", currentBranch, "to", rc.repo.Branch) + progress(fmt.Sprintf(" └── %s (%s → %s) dirty, cannot switch branch", rc.repoPath, currentBranch, rc.repo.Branch)) + return nil + } + + exists, err := s.Git.BranchExists(ctx, rc.barePath, rc.repo.Branch) + if err != nil { + return fmt.Errorf("checking branch for %s: %w", rc.repo.URL, err) + } + + if exists { + if err := s.Git.CheckoutBranch(ctx, rc.worktreePath, rc.repo.Branch); err != nil { + return fmt.Errorf("switching branch for %s: %w", rc.repo.URL, err) + } + progress(fmt.Sprintf(" └── %s (%s → %s) switched ✓", rc.repoPath, currentBranch, rc.repo.Branch)) + } else { + baseBranch, err := s.resolveBaseBranch(ctx, rc) + if err != nil { + return err + } + if err := s.Git.EnsureRemoteRef(ctx, rc.barePath, baseBranch); err != nil { + return fmt.Errorf("ensuring remote ref for %s: %w", rc.repo.URL, err) + } + startPoint := "origin/" + baseBranch + if err := s.Git.CheckoutNewBranch(ctx, rc.worktreePath, rc.repo.Branch, startPoint); err != nil { + return fmt.Errorf("creating branch for %s: %w", rc.repo.URL, err) + } + progress(fmt.Sprintf(" └── %s (%s → %s, new branch from %s) switched ✓", rc.repoPath, currentBranch, rc.repo.Branch, baseBranch)) + } + + return s.updateWorktreeRemote(ctx, rc, progress) +} + +// updateWorktreeRemote updates an existing worktree to the latest remote ref. +func (s *Service) updateWorktreeRemote(ctx context.Context, rc *repoRenderContext, progress func(msg string)) error { if err := s.Git.EnsureRemoteRef(ctx, rc.barePath, rc.repo.Branch); err != nil { - // Branch doesn't exist on remote — this is a local-only feature - // branch. Leave it alone. s.log().Debug("worktree exists, no remote branch to update from", "path", rc.worktreePath, "branch", rc.repo.Branch) progress(fmt.Sprintf(" └── %s (%s) exists", rc.repoPath, rc.repo.Branch)) return nil } - // Check if the worktree is clean before resetting clean, err := s.Git.IsClean(ctx, rc.worktreePath) if err != nil { return fmt.Errorf("checking worktree status for %s: %w", rc.repo.URL, err) @@ -345,7 +395,6 @@ func (s *Service) updateWorktree(ctx context.Context, rc *repoRenderContext, pro return nil } - // Reset to the latest remote ref ref := "origin/" + rc.repo.Branch s.log().Debug("updating worktree to latest remote", "path", rc.worktreePath, "ref", ref) if err := s.Git.ResetBranch(ctx, rc.worktreePath, ref); err != nil { diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index d211539..7c6814f 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -27,14 +27,16 @@ type mockRunner struct { resets []string rebases []string aborts []string - - cloneErr error - fetchErr error - addWTErr error - branchExists bool - isClean bool - rebaseErr error - resetErr error + checkouts []string + + cloneErr error + fetchErr error + addWTErr error + branchExists bool + isClean bool + rebaseErr error + resetErr error + currentBranch string } func (m *mockRunner) BareClone(_ context.Context, url, dest string) error { @@ -132,6 +134,32 @@ func (m *mockRunner) RebaseAbort(_ context.Context, worktreePath string) error { return nil } +func (m *mockRunner) CurrentBranch(_ context.Context, _ string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.currentBranch == "" { + return "main", nil + } + return m.currentBranch, nil +} + +func (m *mockRunner) CheckoutBranch(_ context.Context, _, branch string) error { + m.mu.Lock() + m.checkouts = append(m.checkouts, branch) + m.currentBranch = branch + m.mu.Unlock() + return nil +} + +func (m *mockRunner) CheckoutNewBranch(_ context.Context, _, newBranch, startPoint string) error { + m.mu.Lock() + m.checkouts = append(m.checkouts, newBranch) + m.startPoints = append(m.startPoints, startPoint) + m.currentBranch = newBranch + m.mu.Unlock() + return nil +} + func testService(t *testing.T) (*Service, *mockRunner) { t.Helper() dir := t.TempDir() @@ -243,7 +271,7 @@ func TestRender(t *testing.T) { st := state.NewState("Render test", "Render test", []state.Repo{ {URL: "github.com/org/repo-a", Branch: "main", Path: "./repo-a"}, - {URL: "github.com/org/repo-b", Branch: "feat/x", Path: "./repo-b"}, + {URL: "github.com/org/repo-b", Branch: "main", Path: "./repo-b"}, }) if err := svc.Create("render-ws", st); err != nil { @@ -272,6 +300,7 @@ func TestRender(t *testing.T) { mock.remoteRefs = nil mock.resets = nil mock.isClean = true + mock.currentBranch = "main" err = svc.Render(ctx, "render-ws", noop) if err != nil { t.Fatalf("Render (2nd): %v", err) @@ -445,6 +474,7 @@ func TestRenderExistingWorktreeDirtySkip(t *testing.T) { // Second render with dirty worktree should skip reset mock.resets = nil mock.isClean = false + mock.currentBranch = "main" var messages []string err := svc.Render(ctx, "dirty-ws", func(msg string) { messages = append(messages, msg) }) if err != nil { @@ -942,6 +972,190 @@ func TestRenderCreatesClaudeFiles(t *testing.T) { } } +func TestRenderBranchSwitchExistingBranch(t *testing.T) { + svc, mock := testService(t) + ctx := context.Background() + + st := state.NewState("Branch switch test", "Branch switch existing", []state.Repo{ + {URL: "github.com/org/repo", Branch: "feat/old", Path: "./repo"}, + }) + if err := svc.Create("branch-switch", st); err != nil { + t.Fatal(err) + } + + // First render creates worktree on feat/old + mock.branchExists = true + if err := svc.Render(ctx, "branch-switch", noop); err != nil { + t.Fatal(err) + } + + // Change the branch in state and re-render + st.Spec.Repos[0].Branch = "feat/new" + if err := state.Save(svc.Config.StatePath("branch-switch"), st); err != nil { + t.Fatal(err) + } + + mock.checkouts = nil + mock.currentBranch = "feat/old" + mock.isClean = true + mock.branchExists = true + + var messages []string + err := svc.Render(ctx, "branch-switch", func(msg string) { messages = append(messages, msg) }) + if err != nil { + t.Fatalf("Render: %v", err) + } + + if len(mock.checkouts) != 1 || mock.checkouts[0] != "feat/new" { + t.Errorf("checkouts = %v, want [feat/new]", mock.checkouts) + } + + found := false + for _, msg := range messages { + if strings.Contains(msg, "switched") && strings.Contains(msg, "feat/old") && strings.Contains(msg, "feat/new") { + found = true + break + } + } + if !found { + t.Errorf("expected progress message about branch switch, got %v", messages) + } +} + +func TestRenderBranchSwitchNewBranch(t *testing.T) { + svc, mock := testService(t) + ctx := context.Background() + + st := state.NewState("Branch switch new", "Branch switch new branch", []state.Repo{ + {URL: "github.com/org/repo", Branch: "feat/old", Path: "./repo"}, + }) + if err := svc.Create("branch-new", st); err != nil { + t.Fatal(err) + } + + mock.branchExists = true + if err := svc.Render(ctx, "branch-new", noop); err != nil { + t.Fatal(err) + } + + // Change to a branch that doesn't exist + st.Spec.Repos[0].Branch = "feat/brand-new" + if err := state.Save(svc.Config.StatePath("branch-new"), st); err != nil { + t.Fatal(err) + } + + mock.checkouts = nil + mock.startPoints = nil + mock.currentBranch = "feat/old" + mock.isClean = true + mock.branchExists = false + + var messages []string + err := svc.Render(ctx, "branch-new", func(msg string) { messages = append(messages, msg) }) + if err != nil { + t.Fatalf("Render: %v", err) + } + + if len(mock.checkouts) != 1 || mock.checkouts[0] != "feat/brand-new" { + t.Errorf("checkouts = %v, want [feat/brand-new]", mock.checkouts) + } + if len(mock.startPoints) != 1 || mock.startPoints[0] != "origin/main" { + t.Errorf("startPoints = %v, want [origin/main]", mock.startPoints) + } + + found := false + for _, msg := range messages { + if strings.Contains(msg, "new branch from main") { + found = true + break + } + } + if !found { + t.Errorf("expected progress message about new branch, got %v", messages) + } +} + +func TestRenderBranchSwitchDirtySkip(t *testing.T) { + svc, mock := testService(t) + ctx := context.Background() + + st := state.NewState("Branch switch dirty", "Branch switch dirty skip", []state.Repo{ + {URL: "github.com/org/repo", Branch: "feat/old", Path: "./repo"}, + }) + if err := svc.Create("branch-dirty", st); err != nil { + t.Fatal(err) + } + + mock.branchExists = true + if err := svc.Render(ctx, "branch-dirty", noop); err != nil { + t.Fatal(err) + } + + st.Spec.Repos[0].Branch = "feat/new" + if err := state.Save(svc.Config.StatePath("branch-dirty"), st); err != nil { + t.Fatal(err) + } + + mock.checkouts = nil + mock.currentBranch = "feat/old" + mock.isClean = false + + var messages []string + err := svc.Render(ctx, "branch-dirty", func(msg string) { messages = append(messages, msg) }) + if err != nil { + t.Fatalf("Render: %v", err) + } + + if len(mock.checkouts) != 0 { + t.Errorf("checkouts = %d, want 0 (dirty worktree should skip)", len(mock.checkouts)) + } + + found := false + for _, msg := range messages { + if strings.Contains(msg, "dirty") && strings.Contains(msg, "cannot switch") { + found = true + break + } + } + if !found { + t.Errorf("expected progress message about dirty worktree, got %v", messages) + } +} + +func TestRenderBranchSameNoSwitch(t *testing.T) { + svc, mock := testService(t) + ctx := context.Background() + + st := state.NewState("Same branch", "Same branch no switch", []state.Repo{ + {URL: "github.com/org/repo", Branch: "feat/x", Path: "./repo"}, + }) + if err := svc.Create("same-branch", st); err != nil { + t.Fatal(err) + } + + mock.branchExists = true + if err := svc.Render(ctx, "same-branch", noop); err != nil { + t.Fatal(err) + } + + mock.checkouts = nil + mock.resets = nil + mock.currentBranch = "feat/x" + mock.isClean = true + + err := svc.Render(ctx, "same-branch", noop) + if err != nil { + t.Fatalf("Render: %v", err) + } + + if len(mock.checkouts) != 0 { + t.Errorf("checkouts = %d, want 0 (same branch, no switch needed)", len(mock.checkouts)) + } + if len(mock.resets) != 1 { + t.Errorf("resets = %d, want 1 (normal update path)", len(mock.resets)) + } +} + func TestSyncCleanRebase(t *testing.T) { svc, mock := testService(t) ctx := context.Background() From 8b0d41a760d1fbf082f006e41b40fbfa2dacf54b Mon Sep 17 00:00:00 2001 From: milldr Date: Wed, 11 Mar 2026 17:48:58 -0400 Subject: [PATCH 2/2] Enable auto-release on merge to main via release drafter --- .github/workflows/release-drafter.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 8d1563b..2fd63f5 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -25,5 +25,7 @@ jobs: run: sleep 10 - uses: release-drafter/release-drafter@v6 + with: + publish: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}