From 143ca05c855da72929ea9be009d9e28419f3923a Mon Sep 17 00:00:00 2001 From: Thomas Carr <9591402+htcarr3@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:15:27 -0500 Subject: [PATCH 1/2] fix(state): remove Branch from stored workspace state Branch was set once at creation and never updated, causing stale values after `git branch -m`. All callsites now query git.CurrentBranch() live. Old state files with "branch" are silently ignored by Go's JSON decoder (no migration needed). --- cmd/archive.go | 10 ++++++--- cmd/list.go | 12 +++++++---- cmd/mcp_tools.go | 14 +++++++------ cmd/new.go | 38 +++++++++++++++++++++++++--------- cmd/repo.go | 6 ++++-- cmd/status.go | 3 --- internal/state/state.go | 1 - internal/state/state_test.go | 8 +++---- internal/tui/messages.go | 1 + internal/tui/model.go | 17 ++++++++------- internal/tui/model_test.go | 10 ++++----- internal/tui/workspace_list.go | 2 +- 12 files changed, 75 insertions(+), 47 deletions(-) diff --git a/cmd/archive.go b/cmd/archive.go index 0c195cb..2a84232 100644 --- a/cmd/archive.go +++ b/cmd/archive.go @@ -69,6 +69,7 @@ func runArchive(cmd *cobra.Command, args []string) error { // Dry run: just report what would happen if archiveDryRun { dirty, _ := git.HasUncommittedChanges(ws.Path) + branch, _ := git.CurrentBranch(ws.Path) result := struct { Action string `json:"action"` Workspace struct { @@ -82,7 +83,7 @@ func runArchive(cmd *cobra.Command, args []string) error { ScriptName string `json:"archive_script,omitempty"` }{Action: "dry_run"} result.Workspace.Name = ws.Name - result.Workspace.Branch = ws.Branch + result.Workspace.Branch = branch result.Workspace.Port = ws.Port result.Workspace.Path = ws.Path result.Dirty = dirty @@ -95,7 +96,7 @@ func runArchive(cmd *cobra.Command, args []string) error { } fmt.Printf("Dry run — would archive workspace %q:\n", ws.Name) fmt.Printf(" Path: %s\n", ws.Path) - fmt.Printf(" Branch: %s\n", ws.Branch) + fmt.Printf(" Branch: %s\n", branch) fmt.Printf(" Port: %d\n", ws.Port) if dirty { fmt.Printf(" Status: dirty (uncommitted changes)\n") @@ -137,6 +138,9 @@ func runArchive(cmd *cobra.Command, args []string) error { } } + // Capture branch before worktree removal + branch, _ := git.CurrentBranch(ws.Path) + // Auto-stop tmux session if running if tmux.Available() == nil { sessionName := tmux.SessionName(tmux.RepoName(rootPath), ws.Name) @@ -187,7 +191,7 @@ func runArchive(cmd *cobra.Command, args []string) error { Branch string `json:"branch"` Port int `json:"port"` Path string `json:"path"` - }{Name: ws.Name, Branch: ws.Branch, Port: ws.Port, Path: ws.Path}, + }{Name: ws.Name, Branch: branch, Port: ws.Port, Path: ws.Path}, }) } diff --git a/cmd/list.go b/cmd/list.go index 09217f2..697925c 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -80,6 +80,8 @@ func runList(cmd *cobra.Command, args []string) error { running = tmux.IsRunning(sessionName) } + branch, _ := git.CurrentBranch(ws.Path) + if hasFilters { if listRunning && !running { continue @@ -91,7 +93,7 @@ func runList(cmd *cobra.Command, args []string) error { } } if listMerged && defaultBranch != "" { - merged, _ := git.IsMerged(ws.Path, ws.Branch, defaultBranch) + merged, _ := git.IsMerged(ws.Path, branch, defaultBranch) if !merged { continue } @@ -100,7 +102,7 @@ func runList(cmd *cobra.Command, args []string) error { items = append(items, workspaceListItem{ Name: ws.Name, - Branch: ws.Branch, + Branch: branch, Port: ws.Port, Path: ws.Path, Running: running, @@ -180,6 +182,8 @@ func runListAll() error { running = tmux.IsRunning(sessionName) } + branch, _ := git.CurrentBranch(ws.Path) + if hasFilters { if listRunning && !running { continue @@ -191,7 +195,7 @@ func runListAll() error { } } if listMerged && defaultBranch != "" { - merged, _ := git.IsMerged(ws.Path, ws.Branch, defaultBranch) + merged, _ := git.IsMerged(ws.Path, branch, defaultBranch) if !merged { continue } @@ -201,7 +205,7 @@ func runListAll() error { items = append(items, workspaceListItem{ Repo: repo.Name, Name: ws.Name, - Branch: ws.Branch, + Branch: branch, Port: ws.Port, Path: ws.Path, Running: running, diff --git a/cmd/mcp_tools.go b/cmd/mcp_tools.go index 12b570f..1c0e702 100644 --- a/cmd/mcp_tools.go +++ b/cmd/mcp_tools.go @@ -264,6 +264,8 @@ func handleWorkspaceList(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal running = tmux.IsRunning(sessionName) } + branch, _ := git.CurrentBranch(ws.Path) + if hasFilters { if filterRunning && !running { continue @@ -275,7 +277,7 @@ func handleWorkspaceList(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal } } if filterMerged && defaultBranch != "" { - merged, _ := git.IsMerged(ws.Path, ws.Branch, defaultBranch) + merged, _ := git.IsMerged(ws.Path, branch, defaultBranch) if !merged { continue } @@ -285,7 +287,7 @@ func handleWorkspaceList(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal items = append(items, workspaceListItem{ Repo: r.Name, Name: ws.Name, - Branch: ws.Branch, + Branch: branch, Port: ws.Port, Path: ws.Path, Running: running, @@ -311,9 +313,6 @@ func handleWorkspaceStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.C defaultBranch, _ := git.DefaultBranch(rootPath) branch, _ := git.CurrentBranch(ws.Path) - if branch == "" { - branch = ws.Branch - } dc, _ := git.DirtyStatus(ws.Path) lastCommit, _ := git.LastCommit(ws.Path) @@ -438,6 +437,9 @@ func handleWorkspaceArchive(ctx context.Context, req mcp.CallToolRequest) (*mcp. return mcpError(fmt.Sprintf("loading state: %v", err)) } + // Capture branch before worktree removal + branch, _ := git.CurrentBranch(ws.Path) + // Safety: check for uncommitted changes if !force { dirty, _ := git.HasUncommittedChanges(ws.Path) @@ -485,7 +487,7 @@ func handleWorkspaceArchive(ctx context.Context, req mcp.CallToolRequest) (*mcp. Branch string `json:"branch"` Port int `json:"port"` Path string `json:"path"` - }{Name: ws.Name, Branch: ws.Branch, Port: ws.Port, Path: ws.Path}, + }{Name: ws.Name, Branch: branch, Port: ws.Port, Path: ws.Path}, }) } diff --git a/cmd/new.go b/cmd/new.go index da8ad70..866ecd4 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -245,19 +245,28 @@ func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, ru planned := state.Workspace{ Name: wsName, Path: wsPath, - Branch: branch, Port: allocatedPort, CreatedAt: time.Now().UTC(), } if jsonout.Enabled { return &planned, jsonout.Write(struct { - Action string `json:"action"` - Workspace state.Workspace `json:"workspace"` - }{Action: "dry_run", Workspace: planned}) + Action string `json:"action"` + Workspace struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Port int `json:"port"` + } `json:"workspace"` + }{Action: "dry_run", Workspace: struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Port int `json:"port"` + }{Name: planned.Name, Path: planned.Path, Branch: branch, Port: planned.Port}}) } fmt.Printf("Dry run — would create workspace:\n") fmt.Printf(" Name: %s\n", planned.Name) - fmt.Printf(" Branch: %s\n", planned.Branch) + fmt.Printf(" Branch: %s\n", branch) fmt.Printf(" Port: %d-%d\n", planned.Port, planned.Port+cfg.PortRange-1) fmt.Printf(" Path: %s\n", planned.Path) return &planned, nil @@ -276,7 +285,6 @@ func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, ru ws := state.Workspace{ Name: wsName, Path: wsPath, - Branch: branch, Port: allocatedPort, CreatedAt: time.Now().UTC(), } @@ -313,16 +321,26 @@ func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, ru if jsonout.Enabled { return &ws, jsonout.Write(struct { - Action string `json:"action"` - Workspace state.Workspace `json:"workspace"` - }{Action: "created", Workspace: ws}) + Action string `json:"action"` + Workspace struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Port int `json:"port"` + } `json:"workspace"` + }{Action: "created", Workspace: struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Port int `json:"port"` + }{Name: ws.Name, Path: ws.Path, Branch: branch, Port: ws.Port}}) } // Print summary fmt.Println() fmt.Printf("Workspace created:\n") fmt.Printf(" Name: %s\n", ws.Name) - fmt.Printf(" Branch: %s\n", ws.Branch) + fmt.Printf(" Branch: %s\n", branch) fmt.Printf(" Ports: %d-%d (%d ports)\n", ws.Port, ws.Port+cfg.PortRange-1, cfg.PortRange) fmt.Printf(" Path: %s\n", shortenHomePath(ws.Path)) diff --git a/cmd/repo.go b/cmd/repo.go index 3cb143e..3601543 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -127,7 +127,8 @@ func runRepoList(cmd *cobra.Command, args []string) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) for _, ws := range st.Workspaces { - _, _ = fmt.Fprintf(w, " %s\t%s\t%d\n", ws.Name, ws.Branch, ws.Port) + branch, _ := git.CurrentBranch(ws.Path) + _, _ = fmt.Fprintf(w, " %s\t%s\t%d\n", ws.Name, branch, ws.Port) } _ = w.Flush() } @@ -154,9 +155,10 @@ func repoWorkspaces(repo registry.Repo) []workspaceListItem { sessionName := tmux.SessionName(repo.Name, ws.Name) running = tmux.IsRunning(sessionName) } + branch, _ := git.CurrentBranch(ws.Path) items = append(items, workspaceListItem{ Name: ws.Name, - Branch: ws.Branch, + Branch: branch, Port: ws.Port, Path: ws.Path, Running: running, diff --git a/cmd/status.go b/cmd/status.go index cca3486..6c4176a 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -68,9 +68,6 @@ func runStatus(cmd *cobra.Command, args []string) error { defaultBranch, _ := git.DefaultBranch(rootPath) branch, _ := git.CurrentBranch(ws.Path) - if branch == "" { - branch = ws.Branch - } dc, _ := git.DirtyStatus(ws.Path) lastCommit, _ := git.LastCommit(ws.Path) diff --git a/internal/state/state.go b/internal/state/state.go index d7f831d..0210c87 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -22,7 +22,6 @@ type State struct { type Workspace struct { Name string `json:"name"` Path string `json:"path"` - Branch string `json:"branch"` Port int `json:"port"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 83484ec..e3fc89c 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -10,7 +10,7 @@ import ( func TestAddAndFind(t *testing.T) { s := &State{} - ws := Workspace{Name: "test-ws", Path: "/tmp/ws", Branch: "main", Port: 5000, CreatedAt: time.Now()} + ws := Workspace{Name: "test-ws", Path: "/tmp/ws", Port: 5000, CreatedAt: time.Now()} if err := s.Add(ws); err != nil { t.Fatal(err) } @@ -222,7 +222,7 @@ func TestSaveAndLoad(t *testing.T) { original := &State{ Workspaces: []Workspace{ - {Name: "ws1", Path: "/tmp/ws1", Branch: "main", Port: 5000, CreatedAt: now}, + {Name: "ws1", Path: "/tmp/ws1", Port: 5000, CreatedAt: now}, }, } @@ -244,8 +244,8 @@ func TestSaveAndLoad(t *testing.T) { t.Fatalf("expected 1 workspace, got %d", len(loaded.Workspaces)) } ws := loaded.Workspaces[0] - if ws.Name != "ws1" || ws.Port != 5000 || ws.Branch != "main" { - t.Errorf("loaded workspace = %+v, want ws1/5000/main", ws) + if ws.Name != "ws1" || ws.Port != 5000 { + t.Errorf("loaded workspace = %+v, want ws1/5000", ws) } } diff --git a/internal/tui/messages.go b/internal/tui/messages.go index d11876c..df184e0 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -31,6 +31,7 @@ type repoItem struct { // workspaceItem is a workspace with live git status. type workspaceItem struct { Workspace state.Workspace + Branch string // live branch from git (not stored in state) DirtyCount git.DirtyCount // staged/modified/untracked counts Merged bool Ahead int // ahead of upstream tracking branch diff --git a/internal/tui/model.go b/internal/tui/model.go index ee96aad..c6a6c1b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -627,7 +627,8 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { items := make([]workspaceItem, len(st.Workspaces)) for i, ws := range st.Workspaces { - items[i] = workspaceItem{Workspace: ws} + branch, _ := git.CurrentBranch(ws.Path) + items[i] = workspaceItem{Workspace: ws, Branch: branch} items[i].PortFree = port.IsFree(ws.Port) if hasTmux { @@ -648,21 +649,21 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { } if defaultBranch != "" { - merged, err := git.IsMerged(ws.Path, ws.Branch, defaultBranch) + merged, err := git.IsMerged(ws.Path, branch, defaultBranch) if err == nil { items[i].Merged = merged } - da, db, err := git.AheadBehind(ws.Path, ws.Branch, defaultBranch) + da, db, err := git.AheadBehind(ws.Path, branch, defaultBranch) if err == nil { items[i].DefaultAhead = da items[i].DefaultBehind = db } } - tracking, err := git.TrackingBranch(ws.Path, ws.Branch) + tracking, err := git.TrackingBranch(ws.Path, branch) if err == nil { - ahead, behind, err := git.AheadBehind(ws.Path, ws.Branch, tracking) + ahead, behind, err := git.AheadBehind(ws.Path, branch, tracking) if err == nil { items[i].Ahead = ahead items[i].Behind = behind @@ -678,10 +679,10 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { } ch := make(chan prResult, len(items)) for i, item := range items { - go func(idx int, ws state.Workspace) { - pr, _ := gh.PRStatus(ws.Path, ws.Branch) + go func(idx int, branch string, ws state.Workspace) { + pr, _ := gh.PRStatus(ws.Path, branch) ch <- prResult{idx: idx, pr: pr} - }(i, item.Workspace) + }(i, item.Branch, item.Workspace) } for range items { res := <-ch diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 3f84207..822e752 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -35,9 +35,9 @@ func seedWorkspaceModel() model { rootPath: "/a", commonDir: "/a/.git", workspaces: []workspaceItem{ - {Workspace: state.Workspace{Name: "ws-one", Branch: "feat-1", Port: 3000}}, - {Workspace: state.Workspace{Name: "ws-two", Branch: "feat-2", Port: 3010}}, - {Workspace: state.Workspace{Name: "ws-three", Branch: "feat-3", Port: 3020}}, + {Workspace: state.Workspace{Name: "ws-one", Port: 3000}, Branch: "feat-1"}, + {Workspace: state.Workspace{Name: "ws-two", Port: 3010}, Branch: "feat-2"}, + {Workspace: state.Workspace{Name: "ws-three", Port: 3020}, Branch: "feat-3"}, }, cursor: 0, } @@ -329,7 +329,7 @@ func TestWorkspacesLoadedMsg(t *testing.T) { m := model{view: viewRepoList, loading: true} workspaces := []workspaceItem{ - {Workspace: state.Workspace{Name: "ws1", Branch: "feat", Port: 3000}}, + {Workspace: state.Workspace{Name: "ws1", Port: 3000}, Branch: "feat"}, } m = updateModel(m, workspacesLoadedMsg{ @@ -378,7 +378,7 @@ func TestArchiveLastWorkspaceClearsLoading(t *testing.T) { {Repo: registry.Repo{Name: "alpha", Path: "/a"}, WorkspaceCount: 1}, }, workspaces: []workspaceItem{ - {Workspace: state.Workspace{Name: "only-ws", Branch: "feat-1", Port: 3000}}, + {Workspace: state.Workspace{Name: "only-ws", Port: 3000}, Branch: "feat-1"}, }, cursor: 0, } diff --git a/internal/tui/workspace_list.go b/internal/tui/workspace_list.go index a730c96..d1420e3 100644 --- a/internal/tui/workspace_list.go +++ b/internal/tui/workspace_list.go @@ -114,7 +114,7 @@ func renderWorkspaceList(m model) string { case m.cursor < len(m.workspaces): item := m.workspaces[m.cursor] var detail strings.Builder - detail.WriteString(renderDetailRow("Branch", item.Workspace.Branch)) + detail.WriteString(renderDetailRow("Branch", item.Branch)) detail.WriteString("\n") detail.WriteString(renderDetailRow("Port", fmt.Sprintf(":%d", item.Workspace.Port))) detail.WriteString("\n") From a8c6f2fc8354f43534f9db5b3ebd2e1de26fd35c Mon Sep 17 00:00:00 2001 From: Thomas Carr <9591402+htcarr3@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:26:25 -0500 Subject: [PATCH 2/2] fix(gh): skip stale PRs when branch names are reused gh pr view matches by branch name, so reusing a branch name after a squash merge would show the old merged PR. Now we fetch headRefOid and verify the PR's head commit is an ancestor of the local branch tip before returning it. --- internal/gh/gh.go | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 08cc9bd..9f80de3 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -23,14 +23,14 @@ func Available() error { // PRStatus returns PR info for the given branch, or nil if no PR exists. // Returns nil, nil for all non-critical failures (gh missing, not a GitHub repo, -// no PR for the branch). +// no PR for the branch, or branch name reuse after merge). func PRStatus(dir, branch string) (*PRInfo, error) { if Available() != nil { return nil, nil } cmd := exec.Command("gh", "pr", "view", branch, - "--json", "number,state,isDraft,reviewDecision,url", + "--json", "number,state,isDraft,reviewDecision,url,headRefOid", "-q", ".") cmd.Dir = dir out, err := cmd.Output() @@ -44,12 +44,36 @@ func PRStatus(dir, branch string) (*PRInfo, error) { return nil, nil } - var pr PRInfo - if err := json.Unmarshal([]byte(trimmed), &pr); err != nil { + var raw struct { + Number int `json:"number"` + State string `json:"state"` + IsDraft bool `json:"isDraft"` + ReviewDecision string `json:"reviewDecision"` + URL string `json:"url"` + HeadRefOid string `json:"headRefOid"` + } + if err := json.Unmarshal([]byte(trimmed), &raw); err != nil { return nil, nil } - if pr.Number == 0 { + if raw.Number == 0 { return nil, nil } - return &pr, nil + + // Verify the PR's head commit is in the branch's history. This prevents + // showing stale PRs when a branch name is reused after a squash merge. + if raw.HeadRefOid != "" { + check := exec.Command("git", "merge-base", "--is-ancestor", raw.HeadRefOid, "HEAD") + check.Dir = dir + if err := check.Run(); err != nil { + return nil, nil + } + } + + return &PRInfo{ + Number: raw.Number, + State: raw.State, + IsDraft: raw.IsDraft, + ReviewDecision: raw.ReviewDecision, + URL: raw.URL, + }, nil }