Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions cmd/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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},
})
}

Expand Down
12 changes: 8 additions & 4 deletions cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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,
Expand Down Expand Up @@ -180,6 +182,8 @@ func runListAll() error {
running = tmux.IsRunning(sessionName)
}

branch, _ := git.CurrentBranch(ws.Path)

if hasFilters {
if listRunning && !running {
continue
Expand All @@ -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
}
Expand All @@ -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,
Expand Down
14 changes: 8 additions & 6 deletions cmd/mcp_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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},
})
}

Expand Down
38 changes: 28 additions & 10 deletions cmd/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
}
Expand Down Expand Up @@ -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))

Expand Down
6 changes: 4 additions & 2 deletions cmd/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 30 additions & 6 deletions internal/gh/gh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
1 change: 0 additions & 1 deletion internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
8 changes: 4 additions & 4 deletions internal/state/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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},
},
}

Expand All @@ -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)
}
}

Expand Down
1 change: 1 addition & 0 deletions internal/tui/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading