From 601fb3dd73c9c596962b359ed90eec5d0d01e42a Mon Sep 17 00:00:00 2001 From: Francisco Rodrigues Date: Sat, 27 Jun 2026 19:44:59 -0300 Subject: [PATCH 1/2] feat: check out remote branches into tracking worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codeherd could only create worktrees from branches the local clone already knew about, and creation started from whatever state that clone happened to be in. Reviewing a teammate's pushed branch meant fetching and adding a worktree by hand, and even an ordinary new worktree could be based on a stale default branch. A new tracking-creation path fetches a remote branch and adds a worktree whose local branch tracks it. The ref is parsed as [/] with origin as the default, so both "feat-x" and "upstream/feat-x" work while branch names containing slashes stay intact. When the derived local name would collide with an existing branch, creation refuses rather than silently reusing it, and a failed fetch of the requested branch is fatal — there is nothing to check out. Every creation path now freshens its source before branching. The fetch is best-effort and never aborts an ordinary creation, but when a local source branch exists it is fast-forwarded with --ff-only and used as the base point, so the worktree reflects current upstream state without ever dropping un-pushed local commits. Local-only refs, tags and SHAs skip the fetch and are used verbatim. On the CLI, create worktree gains --track, mutually exclusive with --from, and the branch argument becomes optional: when omitted the local name is derived from the remote branch. Shell completion suggests the project's remote-tracking branches. In the TUI, r is rebound from manual refresh to a remote-branch picker. The dashboard already auto-refreshes every few seconds, so the manual binding was redundant; r now fetches all remotes and opens a filterable list, and selecting a branch hands off to the existing create-worktree form, pre-filled and routed to the tracking path. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/completion.go | 25 + cmd/completion_internal_test.go | 29 + cmd/worktree.go | 36 +- cmd/worktree_internal_test.go | 36 + ...6-06-27-checkout-remote-branch-worktree.md | 1834 +++++++++++++++++ ...-checkout-remote-branch-worktree-design.md | 207 ++ internal/tui/form.go | 27 +- internal/tui/form_test.go | 26 + internal/tui/keys.go | 8 +- internal/tui/model.go | 87 +- internal/tui/model_test.go | 20 +- internal/tui/remote_picker.go | 80 + internal/tui/remote_picker_test.go | 75 + internal/worktree/integration_test.go | 89 + internal/worktree/realrunner_test.go | 177 ++ internal/worktree/worktree.go | 317 ++- internal/worktree/worktree_test.go | 286 ++- 17 files changed, 3312 insertions(+), 47 deletions(-) create mode 100644 cmd/worktree_internal_test.go create mode 100644 docs/superpowers/plans/2026-06-27-checkout-remote-branch-worktree.md create mode 100644 docs/superpowers/specs/2026-06-27-checkout-remote-branch-worktree-design.md create mode 100644 internal/tui/remote_picker.go create mode 100644 internal/tui/remote_picker_test.go create mode 100644 internal/worktree/integration_test.go create mode 100644 internal/worktree/realrunner_test.go diff --git a/cmd/completion.go b/cmd/completion.go index ac77482..c504b60 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -104,6 +104,31 @@ func branchNames(entries []worktree.ListEntry) []string { return names } +// completionRemoteBrancher lists a project's remote-tracking branches during +// completion (no fetch). Declared as a var so tests can stub it. +var completionRemoteBrancher = func(cfg *config.Config, project string) ([]worktree.RemoteBranch, error) { + svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{}) + return svc.ListRemoteBranches(project) +} + +// completeRemoteBranches completes the --track flag against the remote-tracking +// branches of the project named in args[0]. +func completeRemoteBranches(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + branches, err := completionRemoteBrancher(loadCompletionConfig(cmd), args[0]) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + refs := make([]string, 0, len(branches)) + for _, b := range branches { + refs = append(refs, b.Ref) + } + sort.Strings(refs) + return refs, cobra.ShellCompDirectiveNoFileComp +} + // completeProjectThenBranch completes the positional (arg 0), // then the positional (arg 1) against that project's worktrees. // Used by commands operating on an existing branch. diff --git a/cmd/completion_internal_test.go b/cmd/completion_internal_test.go index cd0e06b..f4c5343 100644 --- a/cmd/completion_internal_test.go +++ b/cmd/completion_internal_test.go @@ -19,6 +19,35 @@ func withStubConfig(t *testing.T, c *config.Config) { t.Cleanup(func() { loadCompletionConfig = orig }) } +func TestCompleteRemoteBranches(t *testing.T) { + orig := completionRemoteBrancher + t.Cleanup(func() { completionRemoteBrancher = orig }) + completionRemoteBrancher = func(_ *config.Config, project string) ([]worktree.RemoteBranch, error) { + return []worktree.RemoteBranch{ + {Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}, + {Remote: "upstream", Branch: "fix-y", Ref: "upstream/fix-y"}, + }, nil + } + + cmd := (&CreateWorktreeCmd{}).Cobra() + got, directive := completeRemoteBranches(cmd, []string{"myapp"}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v", directive) + } + want := []string{"origin/feat-x", "upstream/fix-y"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("completions = %v, want %v", got, want) + } +} + +func TestCompleteRemoteBranches_noProject(t *testing.T) { + cmd := (&CreateWorktreeCmd{}).Cobra() + got, _ := completeRemoteBranches(cmd, nil, "") + if got != nil { + t.Errorf("expected nil completions with no project, got %v", got) + } +} + func TestCompleteAgents(t *testing.T) { withStubConfig(t, &config.Config{Agents: map[string]config.AgentConfig{ "claude": {Cmd: "claude"}, diff --git a/cmd/worktree.go b/cmd/worktree.go index d5883b0..c9f47fa 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -66,30 +66,40 @@ func (c *ListWorktreeCmd) Run(cmd *cobra.Command, args []string) error { type CreateWorktreeCmd struct { From string + Track string Attach bool Agent string } func (c *CreateWorktreeCmd) Cobra() *cobra.Command { cmd := &cobra.Command{ - Use: "worktree ", + Use: "worktree [branch]", Aliases: []string{"worktrees", "wt"}, Short: "Create a new worktree for a project", - Args: cobra.ExactArgs(2), + Args: cobra.RangeArgs(1, 2), RunE: c.Run, ValidArgsFunction: completeProjectOnly, } cmd.Flags().StringVar(&c.From, "from", "", "base branch to create worktree from") + cmd.Flags().StringVar(&c.Track, "track", "", "remote branch to fetch and check out (e.g. feat-x or upstream/feat-x)") cmd.Flags().BoolVar(&c.Attach, "attach", false, "start a coding session after creation") cmd.Flags().StringVar(&c.Agent, "agent", "", "agent to use for the session (with --attach)") + cmd.MarkFlagsMutuallyExclusive("from", "track") _ = cmd.RegisterFlagCompletionFunc("from", completeBranches) + _ = cmd.RegisterFlagCompletionFunc("track", completeRemoteBranches) _ = cmd.RegisterFlagCompletionFunc("agent", completeAgents) return cmd } func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error { - project, branch := args[0], args[1] - fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, branch) + project := args[0] + posBranch := "" + if len(args) > 1 { + posBranch = args[1] + } + if c.Track == "" && posBranch == "" { + return fmt.Errorf("a argument is required unless --track is given") + } projCfg := cfg.Projects[project] h := hooks.New(projCfg.Hooks) @@ -102,16 +112,24 @@ func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error { svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), h) var result worktree.NewResult var err error - if c.From != "" { - result, err = svc.NewFrom(project, branch, c.From) - } else { - result, err = svc.New(project, branch) + switch { + case c.Track != "": + fmt.Fprintf(cmd.OutOrStdout(), "Checking out %s into a new worktree... ", c.Track) + result, err = svc.NewTracking(project, posBranch, c.Track) + case c.From != "": + fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, posBranch) + result, err = svc.NewFrom(project, posBranch, c.From) + default: + fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, posBranch) + result, err = svc.New(project, posBranch) } if err != nil { fmt.Fprintln(cmd.OutOrStdout()) - return worktreeErr(cmd, project, branch, err) + return worktreeErr(cmd, project, posBranch, err) } + branch := result.Branch + // File copy if len(projCfg.Files) > 0 { copySvc := filecopy.New(h) diff --git a/cmd/worktree_internal_test.go b/cmd/worktree_internal_test.go new file mode 100644 index 0000000..d9bf3fb --- /dev/null +++ b/cmd/worktree_internal_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestCreateWorktree_trackFlagRegistered(t *testing.T) { + c := (&CreateWorktreeCmd{}).Cobra() + if c.Flags().Lookup("track") == nil { + t.Fatal("expected --track flag to be registered") + } +} + +func TestCreateWorktree_trackAndFromMutuallyExclusive(t *testing.T) { + c := (&CreateWorktreeCmd{}).Cobra() + c.SetArgs([]string{"myapp", "--track", "feat-x", "--from", "main"}) + c.SilenceUsage = true + c.SilenceErrors = true + err := c.Execute() + // Cobra's mutual-exclusion error names both flags in the group. + if err == nil || !strings.Contains(err.Error(), "from") || !strings.Contains(err.Error(), "track") { + t.Fatalf("expected mutual-exclusion error, got %v", err) + } +} + +func TestCreateWorktree_branchRequiredWithoutTrack(t *testing.T) { + c := (&CreateWorktreeCmd{}).Cobra() + c.SetArgs([]string{"myapp"}) + c.SilenceUsage = true + c.SilenceErrors = true + err := c.Execute() + if err == nil { + t.Fatal("expected error when no branch and no --track given") + } +} diff --git a/docs/superpowers/plans/2026-06-27-checkout-remote-branch-worktree.md b/docs/superpowers/plans/2026-06-27-checkout-remote-branch-worktree.md new file mode 100644 index 0000000..e933fc5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-checkout-remote-branch-worktree.md @@ -0,0 +1,1834 @@ +# Checkout a Remote Branch into a New Worktree — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users fetch and check out a remote branch into a new tracking worktree (for PR review), and make every worktree *creation* start from an up-to-date source. + +**Architecture:** Extend `internal/worktree`'s `WorktreeRunner` with git fetch / remote-listing / tracking-add primitives, add `Service` methods (`NewTracking`, `RemoteBranches`, `ListRemoteBranches`) plus a `freshenStartPoint` helper that all creation paths use. Surface it on the CLI via a `--track ` flag on `create worktree`, and in the TUI via a new remote-branch picker (`r`) that feeds the existing create-worktree form. + +**Tech Stack:** Go, Cobra (CLI), Bubble Tea v2 + Bubbles `list` + Huh v2 (TUI), git via `os/exec`. + +## Global Constraints + +- Go module `github.com/xico42/codeherd`; build/test via the `Makefile`. +- New code must keep aggregate coverage ≥ 80%. Final gate: `make check` (coverage → integration tests → lint → build). +- Follow existing patterns: struct-per-command CLI, `WorktreeRunner` interface mocked in tests, pure parse helpers tested directly (mirror `parseWorktreePorcelain`). +- Default remote is `origin`, hardcoded (no config knob). +- `--track` and `--from` are mutually exclusive. +- Fetch/fast-forward during creation is **best-effort**: failures are non-fatal and never abort creation (except `NewTracking`'s primary fetch of the requested branch, which is fatal). +- The new worktree must never lose un-pushed local commits: fast-forward is `--ff-only`, and when a local source branch exists the new branch is based off that (possibly fast-forwarded) local branch, not the remote-tracking ref. + +--- + +## File Structure + +- `internal/worktree/worktree.go` — extend `WorktreeRunner`, `RealWorktreeRunner`, add `RemoteBranch` type, `NewResult.Branch`, `ErrLocalBranchExists`, pure helpers `parseRef`/`parseRemoteBranches`, `freshenStartPoint`, and `Service` methods `NewTracking`/`RemoteBranches`/`ListRemoteBranches`. Rewire `New`/`NewFrom`. +- `internal/worktree/worktree_test.go` — extend `mockGit`; update affected tests; add new unit tests. +- `cmd/worktree.go` — `--track` flag, optional positional, mutual exclusion, route to `NewTracking`, use `NewResult.Branch`. +- `cmd/worktree_test.go` (or existing cmd tests) — CLI wiring tests. +- `cmd/completion.go` — `completeRemoteBranches` + `completionRemoteBrancher` var. +- `cmd/completion_test.go` — completion test. +- `internal/tui/keys.go` — replace the `Refresh` binding with a `Remote` binding (`r`). +- `internal/tui/remote_picker.go` (new) — `remotePickerModel`, `remoteBranchItem`. +- `internal/tui/remote_picker_test.go` (new) — picker tests. +- `internal/tui/model.go` — `screenRemotePicker`, `remoteBranchesMsg`, `remotePicker` field, `startRemotePicker`/`fetchRemoteBranchesCmd`/`updateRemotePicker`/`showTrackForm`, View + key dispatch. +- `internal/tui/form.go` — `formContext.tracksRef`/`branch`, `formModel.tracksRef`, prefill + `NewTracking` routing. +- `internal/tui/form_test.go`, `internal/tui/model_test.go` — TUI tests. +- `internal/worktree/integration_test.go` (new) — end-to-end `//go:build integration` test. + +--- + +## Task 1: Extend WorktreeRunner with fetch / remote / tracking primitives + +Adds the git primitives and types with no behavior change to existing flows. Existing tests must still compile and pass (so `mockGit` gains the new methods returning zero values). + +**Files:** +- Modify: `internal/worktree/worktree.go` +- Test: `internal/worktree/worktree_test.go` + +**Interfaces:** +- Produces: + - `type RemoteBranch struct { Remote, Branch, Ref string }` + - `NewResult` gains field `Branch string` + - `var ErrLocalBranchExists = errors.New("local branch already exists")` + - `WorktreeRunner` methods: `Fetch(cloneDir, remote, branch string) error`, `FetchAll(cloneDir string) error`, `FastForward(cloneDir, remote, branch string) error`, `Remotes(cloneDir string) ([]string, error)`, `ListRemoteBranches(cloneDir string) ([]RemoteBranch, error)`, `AddTracking(cloneDir, worktreePath, branch, remoteRef string) error`, `HasLocalBranch(cloneDir, branch string) (bool, error)` + - pure func `parseRemoteBranches(output string) []RemoteBranch` + +- [ ] **Step 1: Write the failing test for `parseRemoteBranches`** + +Add to `internal/worktree/worktree_test.go`: + +```go +func TestParseRemoteBranches(t *testing.T) { + input := "origin/main\norigin/HEAD\norigin/feature/login\nupstream/bugfix\n" + got := parseRemoteBranches(input) + want := []RemoteBranch{ + {Remote: "origin", Branch: "main", Ref: "origin/main"}, + {Remote: "origin", Branch: "feature/login", Ref: "origin/feature/login"}, + {Remote: "upstream", Branch: "bugfix", Ref: "upstream/bugfix"}, + } + if len(got) != len(want) { + t.Fatalf("got %d entries, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("entry %d = %+v, want %+v", i, got[i], want[i]) + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/worktree/ -run TestParseRemoteBranches` +Expected: FAIL — `undefined: parseRemoteBranches`. + +- [ ] **Step 3: Add types, sentinel, interface methods, real implementations, and `parseRemoteBranches`** + +In `internal/worktree/worktree.go`, add to the sentinel `var (...)` block (around line 20): + +```go + ErrLocalBranchExists = errors.New("local branch already exists") +``` + +Add the `Branch` field to `NewResult` (around line 42): + +```go +// NewResult is the result of a successful worktree creation. +type NewResult struct { + Path string + Branch string // resolved local branch name (derived for --track) +} +``` + +Add the `RemoteBranch` type near `WorktreeInfo`: + +```go +// RemoteBranch is one remote-tracking branch (e.g. origin/feature-x). +type RemoteBranch struct { + Remote string + Branch string + Ref string // "/" +} +``` + +Extend the `WorktreeRunner` interface (replace the block at lines 47-53): + +```go +// WorktreeRunner abstracts git worktree operations for testability. +type WorktreeRunner interface { + Add(cloneDir, worktreePath, branch string) error + AddNewBranch(cloneDir, worktreePath, branch string) error + AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint string) error + Remove(cloneDir, worktreePath string) error + List(cloneDir string) ([]WorktreeInfo, error) + + Fetch(cloneDir, remote, branch string) error + FetchAll(cloneDir string) error + FastForward(cloneDir, remote, branch string) error + Remotes(cloneDir string) ([]string, error) + ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) + AddTracking(cloneDir, worktreePath, branch, remoteRef string) error + HasLocalBranch(cloneDir, branch string) (bool, error) +} +``` + +Add the real implementations after `List` (after line 109) and the parser after `parseWorktreePorcelain`: + +```go +func (r *RealWorktreeRunner) Fetch(cloneDir, remote, branch string) error { + cmd := exec.Command("git", "fetch", remote, branch) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git fetch %s %s: %w\n%s", remote, branch, err, out) + } + return nil +} + +func (r *RealWorktreeRunner) FetchAll(cloneDir string) error { + cmd := exec.Command("git", "fetch", "--all", "--prune") + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git fetch --all: %w\n%s", err, out) + } + return nil +} + +// FastForward advances the local branch to / without losing +// local commits. It first tries a non-checkout ref update (works when the +// branch is not checked out); if that fails it falls back to a fast-forward-only +// merge (for the clone's currently checked-out branch). Either failure is +// reported but treated as best-effort by callers. +func (r *RealWorktreeRunner) FastForward(cloneDir, remote, branch string) error { + cmd := exec.Command("git", "fetch", remote, branch+":"+branch) + cmd.Dir = cloneDir + if _, err := cmd.CombinedOutput(); err == nil { + return nil + } + cmd = exec.Command("git", "merge", "--ff-only", remote+"/"+branch) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("fast-forward %s: %w\n%s", branch, err, out) + } + return nil +} + +func (r *RealWorktreeRunner) Remotes(cloneDir string) ([]string, error) { + cmd := exec.Command("git", "remote") + cmd.Dir = cloneDir + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git remote: %w", err) + } + var names []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if s := strings.TrimSpace(line); s != "" { + names = append(names, s) + } + } + return names, nil +} + +func (r *RealWorktreeRunner) ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) { + cmd := exec.Command("git", "for-each-ref", "--format=%(refname:short)", "refs/remotes") + cmd.Dir = cloneDir + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git for-each-ref: %w", err) + } + return parseRemoteBranches(string(out)), nil +} + +func (r *RealWorktreeRunner) AddTracking(cloneDir, worktreePath, branch, remoteRef string) error { + cmd := exec.Command("git", "worktree", "add", "--track", "-b", branch, worktreePath, remoteRef) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git worktree add --track: %w\n%s", err, out) + } + return nil +} + +func (r *RealWorktreeRunner) HasLocalBranch(cloneDir, branch string) (bool, error) { + cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch) + cmd.Dir = cloneDir + err := cmd.Run() + if err == nil { + return true, nil + } + var ee *exec.ExitError + if errors.As(err, &ee) { + return false, nil + } + return false, err +} + +// parseRemoteBranches parses `git for-each-ref --format=%(refname:short) refs/remotes`. +// The remote name is the segment before the first slash; the rest is the branch +// (which may contain slashes). Symbolic */HEAD entries are skipped. +func parseRemoteBranches(output string) []RemoteBranch { + var result []RemoteBranch + for _, line := range strings.Split(output, "\n") { + s := strings.TrimSpace(line) + if s == "" { + continue + } + idx := strings.Index(s, "/") + if idx <= 0 { + continue + } + remote, branch := s[:idx], s[idx+1:] + if branch == "" || branch == "HEAD" { + continue + } + result = append(result, RemoteBranch{Remote: remote, Branch: branch, Ref: s}) + } + return result +} +``` + +- [ ] **Step 4: Extend `mockGit` so existing tests compile** + +In `internal/worktree/worktree_test.go`, replace the `mockGit` struct (lines 111-122) and add the new methods after `List` (after line 140): + +```go +// mockGit records calls and controls return values. +type mockGit struct { + addErr error + addNewErr error + addNewFromErr error + addNewFromCalled bool + addNewFromStartPoint string + removeErr error + listResult []WorktreeInfo + listErr error + addCalled bool + addNewCalled bool + + fetchErr error + fetchCalls [][2]string // {remote, branch} + fetchAllErr error + fetchAllCalled bool + fastForwardErr error + fastForwardCalls [][2]string + remotesResult []string + remotesErr error + listRemoteResult []RemoteBranch + listRemoteErr error + addTrackingErr error + addTrackingCalled bool + addTrackingBranch string + addTrackingRef string + hasLocalBranch bool + hasLocalBranchErr error +} +``` + +```go +func (m *mockGit) Fetch(cloneDir, remote, branch string) error { + m.fetchCalls = append(m.fetchCalls, [2]string{remote, branch}) + return m.fetchErr +} +func (m *mockGit) FetchAll(cloneDir string) error { + m.fetchAllCalled = true + return m.fetchAllErr +} +func (m *mockGit) FastForward(cloneDir, remote, branch string) error { + m.fastForwardCalls = append(m.fastForwardCalls, [2]string{remote, branch}) + return m.fastForwardErr +} +func (m *mockGit) Remotes(cloneDir string) ([]string, error) { + return m.remotesResult, m.remotesErr +} +func (m *mockGit) ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) { + return m.listRemoteResult, m.listRemoteErr +} +func (m *mockGit) AddTracking(cloneDir, worktreePath, branch, remoteRef string) error { + m.addTrackingCalled = true + m.addTrackingBranch = branch + m.addTrackingRef = remoteRef + return m.addTrackingErr +} +func (m *mockGit) HasLocalBranch(cloneDir, branch string) (bool, error) { + return m.hasLocalBranch, m.hasLocalBranchErr +} +``` + +- [ ] **Step 5: Run tests to verify the new parser passes and nothing else broke** + +Run: `go test ./internal/worktree/` +Expected: PASS (existing tests unchanged this task; `TestParseRemoteBranches` passes). + +- [ ] **Step 6: Commit** + +```bash +git add internal/worktree/worktree.go internal/worktree/worktree_test.go +git commit -m "feat(worktree): add fetch/remote/tracking primitives to WorktreeRunner" +``` + +--- + +## Task 2: Pure ref parsing — `parseRef` + +**Files:** +- Modify: `internal/worktree/worktree.go` +- Test: `internal/worktree/worktree_test.go` + +**Interfaces:** +- Produces: `func parseRef(remotes []string, ref string) (remote, branch string, explicit bool)` + +- [ ] **Step 1: Write the failing test** + +```go +func TestParseRef(t *testing.T) { + remotes := []string{"origin", "upstream"} + cases := []struct { + ref string + remote, branch string + explicit bool + }{ + {"feat-x", "origin", "feat-x", false}, + {"feature/login", "origin", "feature/login", false}, + {"origin/feat-x", "origin", "feat-x", true}, + {"upstream/feature/login", "upstream", "feature/login", true}, + {"notaremote/x", "origin", "notaremote/x", false}, + } + for _, tc := range cases { + gotR, gotB, gotE := parseRef(remotes, tc.ref) + if gotR != tc.remote || gotB != tc.branch || gotE != tc.explicit { + t.Errorf("parseRef(%q) = (%q,%q,%v), want (%q,%q,%v)", + tc.ref, gotR, gotB, gotE, tc.remote, tc.branch, tc.explicit) + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/worktree/ -run TestParseRef` +Expected: FAIL — `undefined: parseRef`. + +- [ ] **Step 3: Implement `parseRef`** + +Add to `internal/worktree/worktree.go`: + +```go +// parseRef splits a user-supplied ref into a remote and branch. When ref is +// "/" and matches a configured remote, it returns +// (remote, rest, true). Otherwise it defaults to ("origin", ref, false), which +// keeps branch names containing slashes (e.g. feature/login) intact. +func parseRef(remotes []string, ref string) (remote, branch string, explicit bool) { + if idx := strings.Index(ref, "/"); idx > 0 { + candidate := ref[:idx] + for _, r := range remotes { + if r == candidate { + return candidate, ref[idx+1:], true + } + } + } + return "origin", ref, false +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/worktree/ -run TestParseRef` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/worktree/worktree.go internal/worktree/worktree_test.go +git commit -m "feat(worktree): add parseRef ref-splitting helper" +``` + +--- + +## Task 3: Fresh source on creation — `freshenStartPoint` + rewire `New`/`NewFrom` + +Adds the freshness helper and routes both existing creation paths through it. This **changes behavior** of `New` (create-new fallback) and `NewFrom`, so two existing tests are updated. + +**Files:** +- Modify: `internal/worktree/worktree.go` +- Test: `internal/worktree/worktree_test.go` + +**Interfaces:** +- Consumes: `parseRef` (Task 2); `WorktreeRunner.Fetch/FastForward/Remotes/HasLocalBranch` (Task 1). +- Produces: `func (s *Service) freshenStartPoint(cloneDir, src string) string`; `New`/`NewFrom` now populate `NewResult.Branch` and base new branches off the freshened start point. + +- [ ] **Step 1: Write failing tests for the helper and the rewired paths** + +Add new tests and **replace** the two existing tests noted below. + +New helper tests: + +```go +func TestFreshenStartPoint_localBranchPreferred(t *testing.T) { + git := &mockGit{hasLocalBranch: true} + svc, _ := makeService(t, git, &mockTmuxRunner{}) + got := svc.freshenStartPoint("/clone", "main") + if got != "main" { + t.Errorf("start point = %q, want %q", got, "main") + } + if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "main"} { + t.Errorf("fetch calls = %v", git.fetchCalls) + } + if len(git.fastForwardCalls) != 1 || git.fastForwardCalls[0] != [2]string{"origin", "main"} { + t.Errorf("fast-forward calls = %v", git.fastForwardCalls) + } +} + +func TestFreshenStartPoint_noLocalBranchUsesRemoteTracking(t *testing.T) { + git := &mockGit{hasLocalBranch: false} + svc, _ := makeService(t, git, &mockTmuxRunner{}) + got := svc.freshenStartPoint("/clone", "feat-x") + if got != "origin/feat-x" { + t.Errorf("start point = %q, want %q", got, "origin/feat-x") + } + if len(git.fastForwardCalls) != 0 { + t.Errorf("did not expect fast-forward, got %v", git.fastForwardCalls) + } +} + +func TestFreshenStartPoint_fetchFailsFallsBackToRaw(t *testing.T) { + git := &mockGit{fetchErr: fmt.Errorf("no such branch")} + svc, _ := makeService(t, git, &mockTmuxRunner{}) + got := svc.freshenStartPoint("/clone", "v1.2.3") + if got != "v1.2.3" { + t.Errorf("start point = %q, want %q", got, "v1.2.3") + } +} + +func TestFreshenStartPoint_explicitRemoteRef(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin", "upstream"}} + svc, _ := makeService(t, git, &mockTmuxRunner{}) + got := svc.freshenStartPoint("/clone", "upstream/feat-x") + if got != "upstream/feat-x" { + t.Errorf("start point = %q, want %q", got, "upstream/feat-x") + } + if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"upstream", "feat-x"} { + t.Errorf("fetch calls = %v", git.fetchCalls) + } +} +``` + +**Replace** `TestService_New_branchNotFound_fallsBackToAddNew` (lines 227-241) with: + +```go +func TestService_New_branchNotFound_createsFromFreshDefault(t *testing.T) { + git := &mockGit{addErr: fmt.Errorf("invalid reference"), hasLocalBranch: false} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.New("myapp", "new-feature") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !git.addNewFromCalled { + t.Error("expected AddNewBranchFrom to be called on Add failure") + } + if git.addNewFromStartPoint != "origin/main" { + t.Errorf("start point = %q, want %q", git.addNewFromStartPoint, "origin/main") + } + if result.Branch != "new-feature" { + t.Errorf("branch = %q, want %q", result.Branch, "new-feature") + } +} +``` + +**Replace** `TestService_New_withFromBranch` (lines 243-264) with: + +```go +func TestService_New_withFromBranch(t *testing.T) { + git := &mockGit{hasLocalBranch: false} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.NewFrom("myapp", "my-feature", "feature-auth") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !git.addNewFromCalled { + t.Error("expected AddNewBranchFrom to be called") + } + if git.addNewFromStartPoint != "origin/feature-auth" { + t.Errorf("start point = %q, want %q", git.addNewFromStartPoint, "origin/feature-auth") + } + if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "feature-auth"} { + t.Errorf("fetch calls = %v", git.fetchCalls) + } + expectedPath := cloneDirPath(tmpDir) + "__worktrees/my-feature" + if result.Path != expectedPath { + t.Errorf("path = %q, want %q", result.Path, expectedPath) + } +} +``` + +- [ ] **Step 2: Run to verify failures** + +Run: `go test ./internal/worktree/ -run 'TestFreshenStartPoint|TestService_New_branchNotFound_createsFromFreshDefault|TestService_New_withFromBranch'` +Expected: FAIL — `freshenStartPoint` undefined and start-point/`Branch` assertions unmet. + +- [ ] **Step 3: Implement `freshenStartPoint` and rewire `New`/`NewFrom`** + +Add the helper to `internal/worktree/worktree.go`: + +```go +// freshenStartPoint fetches updates for the source ref and returns the start +// point a new worktree branch should be based on. It prefers a fast-forwarded +// local branch (to preserve un-pushed commits), falling back to the +// remote-tracking ref, or the raw ref when the source is not on a remote +// (tags, SHAs, local-only branches). All git failures here are best-effort. +func (s *Service) freshenStartPoint(cloneDir, src string) string { + remotes, _ := s.git.Remotes(cloneDir) + remote, branch, explicit := parseRef(remotes, src) + if explicit { + _ = s.git.Fetch(cloneDir, remote, branch) + return src + } + if err := s.git.Fetch(cloneDir, "origin", src); err != nil { + return src + } + if has, _ := s.git.HasLocalBranch(cloneDir, src); has { + _ = s.git.FastForward(cloneDir, "origin", src) + return src + } + return "origin/" + src +} +``` + +In `New` (lines 203-214), replace the create block and the return: + +```go + addErr := s.git.Add(cloneDir, worktreePath, branch) + if addErr != nil { + src := p.DefaultBranch + if src == "" { + src = "main" + } + startPoint := s.freshenStartPoint(cloneDir, src) + if err := s.git.AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint); err != nil { + return NewResult{}, fmt.Errorf("failed to create worktree (add: %v; add -b from %s: %w)", addErr, startPoint, err) + } + } + + if err := s.hook.Trigger(semconv.HookPostWorktree, attrs, worktreePath); err != nil { + return NewResult{}, fmt.Errorf("post-worktree hook: %w", err) + } + + return NewResult{Path: worktreePath, Branch: branch}, nil +``` + +In `NewFrom` (lines 253-261), replace the add + return: + +```go + startPoint := s.freshenStartPoint(cloneDir, fromBranch) + if err := s.git.AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint); err != nil { + return NewResult{}, fmt.Errorf("creating worktree from %s: %w", startPoint, err) + } + + if err := s.hook.Trigger(semconv.HookPostWorktree, attrs, worktreePath); err != nil { + return NewResult{}, fmt.Errorf("post-worktree hook: %w", err) + } + + return NewResult{Path: worktreePath, Branch: branch}, nil +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/worktree/` +Expected: PASS (all worktree tests, including the rewritten ones). + +- [ ] **Step 5: Commit** + +```bash +git add internal/worktree/worktree.go internal/worktree/worktree_test.go +git commit -m "feat(worktree): base new worktrees on a freshly-fetched source" +``` + +--- + +## Task 4: Service methods — `NewTracking`, `ListRemoteBranches`, `RemoteBranches` + +**Files:** +- Modify: `internal/worktree/worktree.go` +- Test: `internal/worktree/worktree_test.go` + +**Interfaces:** +- Consumes: `parseRef`, `WorktreeRunner.{Remotes,Fetch,HasLocalBranch,AddTracking,FetchAll,ListRemoteBranches}`, `resolvePaths`. +- Produces: + - `func (s *Service) NewTracking(project, branch, ref string) (NewResult, error)` + - `func (s *Service) ListRemoteBranches(project string) ([]RemoteBranch, error)` + - `func (s *Service) RemoteBranches(project string) ([]RemoteBranch, error)` + +- [ ] **Step 1: Write failing tests** + +```go +func TestService_NewTracking_success(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin"}} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.NewTracking("myapp", "", "feat-x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "feat-x"} { + t.Errorf("fetch calls = %v", git.fetchCalls) + } + if !git.addTrackingCalled || git.addTrackingBranch != "feat-x" || git.addTrackingRef != "origin/feat-x" { + t.Errorf("AddTracking branch=%q ref=%q", git.addTrackingBranch, git.addTrackingRef) + } + if result.Branch != "feat-x" { + t.Errorf("branch = %q, want feat-x", result.Branch) + } + expectedPath := cloneDirPath(tmpDir) + "__worktrees/feat-x" + if result.Path != expectedPath { + t.Errorf("path = %q, want %q", result.Path, expectedPath) + } +} + +func TestService_NewTracking_overrideName(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin", "upstream"}} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.NewTracking("myapp", "review", "upstream/feat-x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if git.addTrackingBranch != "review" || git.addTrackingRef != "upstream/feat-x" { + t.Errorf("AddTracking branch=%q ref=%q", git.addTrackingBranch, git.addTrackingRef) + } + if result.Branch != "review" { + t.Errorf("branch = %q, want review", result.Branch) + } +} + +func TestService_NewTracking_localBranchExists(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin"}, hasLocalBranch: true} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + _, err := svc.NewTracking("myapp", "", "feat-x") + if !errors.Is(err, ErrLocalBranchExists) { + t.Errorf("expected ErrLocalBranchExists, got %v", err) + } + if git.addTrackingCalled { + t.Error("AddTracking should not be called when the branch already exists") + } +} + +func TestService_NewTracking_fetchFails(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin"}, fetchErr: fmt.Errorf("no such ref")} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + _, err := svc.NewTracking("myapp", "", "feat-x") + if err == nil { + t.Fatal("expected error when fetch fails") + } + if git.addTrackingCalled { + t.Error("AddTracking should not run after a failed fetch") + } +} + +func TestService_NewTracking_notCloned(t *testing.T) { + svc, _ := makeService(t, &mockGit{remotesResult: []string{"origin"}}, &mockTmuxRunner{}) + _, err := svc.NewTracking("myapp", "", "feat-x") + if !errors.Is(err, ErrNotCloned) { + t.Errorf("expected ErrNotCloned, got %v", err) + } +} + +func TestService_RemoteBranches_fetchesThenLists(t *testing.T) { + git := &mockGit{listRemoteResult: []RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + got, err := svc.RemoteBranches("myapp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !git.fetchAllCalled { + t.Error("expected FetchAll to be called") + } + if len(got) != 1 || got[0].Ref != "origin/feat-x" { + t.Errorf("branches = %+v", got) + } +} + +func TestService_ListRemoteBranches_noFetch(t *testing.T) { + git := &mockGit{listRemoteResult: []RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + got, err := svc.ListRemoteBranches("myapp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if git.fetchAllCalled { + t.Error("ListRemoteBranches must not fetch") + } + if len(got) != 1 { + t.Errorf("branches = %+v", got) + } +} +``` + +- [ ] **Step 2: Run to verify failures** + +Run: `go test ./internal/worktree/ -run 'NewTracking|RemoteBranches'` +Expected: FAIL — methods undefined. + +- [ ] **Step 3: Implement the three Service methods** + +Add a small clone-dir helper and the methods to `internal/worktree/worktree.go`: + +```go +// cloneDirFor returns the clone directory for a project without needing a +// branch (unlike resolvePaths, which also computes a worktree path). +func (s *Service) cloneDirFor(project string) (string, error) { + p, ok := s.cfg.Projects[project] + if !ok { + return "", fmt.Errorf("project %q is not configured", project) + } + repoPath, err := config.RepoPath(p.Repo) + if err != nil { + return "", fmt.Errorf("parsing repo URL: %w", err) + } + return semconv.CloneDir(s.cfg.Defaults.ProjectsDir, repoPath), nil +} + +// NewTracking fetches a remote branch and creates a worktree whose local branch +// tracks it. ref is "[/]" (default remote origin). When branch +// is empty the local name is derived from the remote branch; otherwise it +// overrides the derived name. +func (s *Service) NewTracking(project, branch, ref string) (NewResult, error) { + p, ok := s.cfg.Projects[project] + if !ok { + return NewResult{}, fmt.Errorf("project %q is not configured", project) + } + + cloneDir, err := s.cloneDirFor(project) + if err != nil { + return NewResult{}, err + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return NewResult{}, fmt.Errorf("%w: %s", ErrNotCloned, project) + } + + remotes, _ := s.git.Remotes(cloneDir) + remote, remoteBranch, _ := parseRef(remotes, ref) + localName := branch + if localName == "" { + localName = remoteBranch + } + remoteRef := remote + "/" + remoteBranch + + _, worktreesRoot, worktreePath, err := s.resolvePaths(project, localName) + if err != nil { + return NewResult{}, err + } + if _, err := os.Stat(worktreePath); err == nil { + return NewResult{}, fmt.Errorf("%w: %s/%s", ErrWorktreeExists, project, localName) + } + + if has, _ := s.git.HasLocalBranch(cloneDir, localName); has { + return NewResult{}, fmt.Errorf("%w: %s", ErrLocalBranchExists, localName) + } + + if err := s.git.Fetch(cloneDir, remote, remoteBranch); err != nil { + return NewResult{}, fmt.Errorf("fetching %s: %w", remoteRef, err) + } + + attrs := map[string]string{ + semconv.HookAttrProject: project, + semconv.HookAttrBranch: localName, + semconv.HookAttrRepo: p.Repo, + semconv.HookAttrCloneDir: cloneDir, + semconv.HookAttrWorktreePath: worktreePath, + } + + if err := s.hook.Trigger(semconv.HookPreWorktree, attrs, worktreePath); err != nil { + return NewResult{}, fmt.Errorf("pre-worktree hook: %w", err) + } + + if err := os.MkdirAll(worktreesRoot, 0o755); err != nil { + return NewResult{}, fmt.Errorf("creating worktrees dir: %w", err) + } + + if err := s.git.AddTracking(cloneDir, worktreePath, localName, remoteRef); err != nil { + return NewResult{}, fmt.Errorf("creating tracking worktree for %s: %w", remoteRef, err) + } + + if err := s.hook.Trigger(semconv.HookPostWorktree, attrs, worktreePath); err != nil { + return NewResult{}, fmt.Errorf("post-worktree hook: %w", err) + } + + return NewResult{Path: worktreePath, Branch: localName}, nil +} + +// ListRemoteBranches returns the remote-tracking branches for a project without +// fetching (suitable for shell completion). +func (s *Service) ListRemoteBranches(project string) ([]RemoteBranch, error) { + cloneDir, err := s.cloneDirFor(project) + if err != nil { + return nil, err + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotCloned, project) + } + return s.git.ListRemoteBranches(cloneDir) +} + +// RemoteBranches fetches all remotes (best-effort) then lists remote-tracking +// branches — used by the TUI picker so the list reflects current remote state. +func (s *Service) RemoteBranches(project string) ([]RemoteBranch, error) { + cloneDir, err := s.cloneDirFor(project) + if err != nil { + return nil, err + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotCloned, project) + } + _ = s.git.FetchAll(cloneDir) + return s.git.ListRemoteBranches(cloneDir) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/worktree/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/worktree/worktree.go internal/worktree/worktree_test.go +git commit -m "feat(worktree): add NewTracking and remote-branch listing" +``` + +--- + +## Task 5: CLI `--track` flag on `create worktree` + +**Files:** +- Modify: `cmd/worktree.go` +- Test: `cmd/worktree_test.go` (create if absent; otherwise add to the existing cmd test file) + +**Interfaces:** +- Consumes: `worktree.Service.NewTracking`, `worktree.NewResult.Branch` (Task 4). +- Produces: `CreateWorktreeCmd.Track` field; `--track` flag; `create worktree` accepting 1-2 positionals. + +- [ ] **Step 1: Write the failing test** + +Add to `cmd/worktree_test.go` (package `cmd`). These tests exercise the Cobra wiring without hitting git. + +```go +package cmd + +import ( + "strings" + "testing" +) + +func TestCreateWorktree_trackFlagRegistered(t *testing.T) { + c := (&CreateWorktreeCmd{}).Cobra() + if c.Flags().Lookup("track") == nil { + t.Fatal("expected --track flag to be registered") + } +} + +func TestCreateWorktree_trackAndFromMutuallyExclusive(t *testing.T) { + c := (&CreateWorktreeCmd{}).Cobra() + c.SetArgs([]string{"myapp", "--track", "feat-x", "--from", "main"}) + c.SilenceUsage = true + c.SilenceErrors = true + err := c.Execute() + if err == nil || !strings.Contains(err.Error(), "exclusive") { + t.Fatalf("expected mutual-exclusion error, got %v", err) + } +} + +func TestCreateWorktree_branchRequiredWithoutTrack(t *testing.T) { + c := (&CreateWorktreeCmd{}).Cobra() + c.SetArgs([]string{"myapp"}) + c.SilenceUsage = true + c.SilenceErrors = true + err := c.Execute() + if err == nil { + t.Fatal("expected error when no branch and no --track given") + } +} +``` + +Note: `TestCreateWorktree_branchRequiredWithoutTrack` reaches `Run`, which reads the package `cfg` global. If `cfg` is nil in the cmd test package, guard by setting a minimal `cfg` in the test, mirroring how sibling cmd tests set it up (check existing `cmd` tests for the pattern, e.g. a `setTestConfig` helper or direct `cfg = &config.Config{...}` assignment). Use the same approach already present in the package. + +- [ ] **Step 2: Run to verify failures** + +Run: `go test ./cmd/ -run TestCreateWorktree` +Expected: FAIL — `track` flag missing / no mutual exclusion / branch not required. + +- [ ] **Step 3: Implement the flag, args, and routing** + +In `cmd/worktree.go`, update the struct (lines 67-71): + +```go +type CreateWorktreeCmd struct { + From string + Track string + Attach bool + Agent string +} +``` + +Update `Cobra()` (lines 73-88): + +```go +func (c *CreateWorktreeCmd) Cobra() *cobra.Command { + cmd := &cobra.Command{ + Use: "worktree [branch]", + Aliases: []string{"worktrees", "wt"}, + Short: "Create a new worktree for a project", + Args: cobra.RangeArgs(1, 2), + RunE: c.Run, + ValidArgsFunction: completeProjectOnly, + } + cmd.Flags().StringVar(&c.From, "from", "", "base branch to create worktree from") + cmd.Flags().StringVar(&c.Track, "track", "", "remote branch to fetch and check out (e.g. feat-x or upstream/feat-x)") + cmd.Flags().BoolVar(&c.Attach, "attach", false, "start a coding session after creation") + cmd.Flags().StringVar(&c.Agent, "agent", "", "agent to use for the session (with --attach)") + cmd.MarkFlagsMutuallyExclusive("from", "track") + _ = cmd.RegisterFlagCompletionFunc("from", completeBranches) + _ = cmd.RegisterFlagCompletionFunc("track", completeRemoteBranches) + _ = cmd.RegisterFlagCompletionFunc("agent", completeAgents) + return cmd +} +``` + +Replace the top of `Run` (lines 90-113) so it handles the optional positional and `--track`, and uses `result.Branch` afterward: + +```go +func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error { + project := args[0] + posBranch := "" + if len(args) > 1 { + posBranch = args[1] + } + if c.Track == "" && posBranch == "" { + return fmt.Errorf("a argument is required unless --track is given") + } + + projCfg := cfg.Projects[project] + h := hooks.New(projCfg.Hooks) + + var cloneDir string + if repoPath, rpErr := config.RepoPath(projCfg.Repo); rpErr == nil { + cloneDir = filepath.Join(cfg.Defaults.ProjectsDir, repoPath) + } + + svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), h) + var result worktree.NewResult + var err error + switch { + case c.Track != "": + fmt.Fprintf(cmd.OutOrStdout(), "Checking out %s into a new worktree... ", c.Track) + result, err = svc.NewTracking(project, posBranch, c.Track) + case c.From != "": + fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, posBranch) + result, err = svc.NewFrom(project, posBranch, c.From) + default: + fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, posBranch) + result, err = svc.New(project, posBranch) + } + if err != nil { + fmt.Fprintln(cmd.OutOrStdout()) + return worktreeErr(cmd, project, posBranch, err) + } + + branch := result.Branch +``` + +The remaining body (file copy, template processing, "done"/Path print, and the `--attach` block) is unchanged and already references the local `branch` variable — it now carries the resolved (possibly derived) name. Verify no other reference to the old positional `branch` remains in the function. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd/ -run TestCreateWorktree` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/worktree.go cmd/worktree_test.go +git commit -m "feat(cmd): add --track to create worktree for remote checkout" +``` + +--- + +## Task 6: Shell completion for `--track` + +**Files:** +- Modify: `cmd/completion.go` +- Test: `cmd/completion_test.go` + +**Interfaces:** +- Consumes: `worktree.Service.ListRemoteBranches`, `worktree.RemoteBranch` (Task 4). +- Produces: `var completionRemoteBrancher func(*config.Config, string) ([]worktree.RemoteBranch, error)`; `func completeRemoteBranches(...)` (referenced by Task 5). + +- [ ] **Step 1: Write the failing test** + +Add to `cmd/completion_test.go`: + +```go +func TestCompleteRemoteBranches(t *testing.T) { + orig := completionRemoteBrancher + t.Cleanup(func() { completionRemoteBrancher = orig }) + completionRemoteBrancher = func(_ *config.Config, project string) ([]worktree.RemoteBranch, error) { + return []worktree.RemoteBranch{ + {Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}, + {Remote: "upstream", Branch: "fix-y", Ref: "upstream/fix-y"}, + }, nil + } + + cmd := (&CreateWorktreeCmd{}).Cobra() + got, directive := completeRemoteBranches(cmd, []string{"myapp"}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v", directive) + } + want := []string{"origin/feat-x", "upstream/fix-y"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("completions = %v, want %v", got, want) + } +} + +func TestCompleteRemoteBranches_noProject(t *testing.T) { + cmd := (&CreateWorktreeCmd{}).Cobra() + got, _ := completeRemoteBranches(cmd, nil, "") + if got != nil { + t.Errorf("expected nil completions with no project, got %v", got) + } +} +``` + +Match the import list to the existing `cmd/completion_test.go` (it already imports `cobra`, `config`, and `worktree` for sibling tests; add any missing ones). + +- [ ] **Step 2: Run to verify failures** + +Run: `go test ./cmd/ -run TestCompleteRemoteBranches` +Expected: FAIL — `completionRemoteBrancher`/`completeRemoteBranches` undefined. + +- [ ] **Step 3: Implement the completion** + +Add to `cmd/completion.go` (the file already imports `sort`, `cobra`, `config`, `hooks`, `tmux`, `worktree`): + +```go +// completionRemoteBrancher lists a project's remote-tracking branches during +// completion (no fetch). Declared as a var so tests can stub it. +var completionRemoteBrancher = func(cfg *config.Config, project string) ([]worktree.RemoteBranch, error) { + svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{}) + return svc.ListRemoteBranches(project) +} + +// completeRemoteBranches completes the --track flag against the remote-tracking +// branches of the project named in args[0]. +func completeRemoteBranches(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + branches, err := completionRemoteBrancher(loadCompletionConfig(cmd), args[0]) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + refs := make([]string, 0, len(branches)) + for _, b := range branches { + refs = append(refs, b.Ref) + } + sort.Strings(refs) + return refs, cobra.ShellCompDirectiveNoFileComp +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd/ -run TestCompleteRemoteBranches` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/completion.go cmd/completion_test.go +git commit -m "feat(cmd): complete --track against remote-tracking branches" +``` + +--- + +## Task 7: TUI remote-branch picker + +Rebinds `r` from manual refresh to the remote-branch picker, and adds the picker model/screen and the async fetch+list command. Selecting a branch transitions to the create-worktree form (wired in Task 8; this task wires it to a temporary handoff and tests the picker in isolation). + +**Files:** +- Modify: `internal/tui/keys.go`, `internal/tui/model.go` +- Create: `internal/tui/remote_picker.go`, `internal/tui/remote_picker_test.go` + +**Interfaces:** +- Consumes: `worktree.Service.RemoteBranches`, `worktree.RemoteBranch` (Task 4). +- Produces: `screenRemotePicker` const; `remoteBranchesMsg` type; `Model.remotePicker` field; `remotePickerModel` with `setBranches`/`selected`; `Model.startRemotePicker`/`fetchRemoteBranchesCmd`/`updateRemotePicker`/`showTrackForm`; `keyMap.Remote`. + +- [ ] **Step 1: Write failing tests for the picker model** + +Create `internal/tui/remote_picker_test.go`: + +```go +package tui + +import ( + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/worktree" +) + +func TestRemotePicker_setAndSelect(t *testing.T) { + p := newRemotePicker("myapp", &config.Config{}, nil) + if !p.loading { + t.Error("picker should start in loading state") + } + p.setBranches([]worktree.RemoteBranch{ + {Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}, + {Remote: "origin", Branch: "fix-y", Ref: "origin/fix-y"}, + }) + if p.loading { + t.Error("picker should leave loading state after setBranches") + } + rb, ok := p.selected() + if !ok || rb.Ref != "origin/feat-x" { + t.Errorf("selected = %+v ok=%v, want origin/feat-x", rb, ok) + } +} + +func TestRemotePicker_emptySelectNone(t *testing.T) { + p := newRemotePicker("myapp", &config.Config{}, nil) + p.setBranches(nil) + if _, ok := p.selected(); ok { + t.Error("expected no selection on empty picker") + } +} +``` + +- [ ] **Step 2: Run to verify failures** + +Run: `go test ./internal/tui/ -run TestRemotePicker` +Expected: FAIL — `newRemotePicker` undefined. + +- [ ] **Step 3: Create the picker model** + +Create `internal/tui/remote_picker.go`: + +```go +package tui + +import ( + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/tmux" + "github.com/xico42/codeherd/internal/worktree" +) + +// remoteBranchItem adapts a RemoteBranch to the bubbles list.Item interface. +type remoteBranchItem struct { + rb worktree.RemoteBranch +} + +func (i remoteBranchItem) FilterValue() string { return i.rb.Ref } +func (i remoteBranchItem) Title() string { return i.rb.Ref } +func (i remoteBranchItem) Description() string { return "" } + +// remotePickerModel shows a filterable list of remote-tracking branches. +type remotePickerModel struct { + list list.Model + project string + loading bool + errText string + cfg *config.Config + tmuxClient *tmux.Client +} + +func newRemotePicker(project string, cfg *config.Config, tmuxClient *tmux.Client) *remotePickerModel { + l := list.New(nil, list.NewDefaultDelegate(), maxWidth, 20) + l.Title = "Remote branches" + l.SetShowHelp(false) + l.SetFilteringEnabled(true) + l.DisableQuitKeybindings() + return &remotePickerModel{ + list: l, + project: project, + loading: true, + cfg: cfg, + tmuxClient: tmuxClient, + } +} + +func (p *remotePickerModel) setBranches(branches []worktree.RemoteBranch) { + items := make([]list.Item, len(branches)) + for i, b := range branches { + items[i] = remoteBranchItem{rb: b} + } + p.list.SetItems(items) + p.loading = false +} + +func (p *remotePickerModel) selected() (worktree.RemoteBranch, bool) { + it, ok := p.list.SelectedItem().(remoteBranchItem) + if !ok { + return worktree.RemoteBranch{}, false + } + return it.rb, true +} + +func (p *remotePickerModel) Update(msg tea.Msg) (*remotePickerModel, tea.Cmd) { + var cmd tea.Cmd + p.list, cmd = p.list.Update(msg) + return p, cmd +} + +func (p *remotePickerModel) View() string { + switch { + case p.loading: + return "Fetching remote branches…" + case p.errText != "": + return "Error: " + p.errText + "\n\nEsc: cancel" + case len(p.list.Items()) == 0: + return "No remote branches found (try again after pushing/fetching).\n\nEsc: cancel" + default: + return p.list.View() + } +} +``` + +- [ ] **Step 4: Run picker model tests to verify they pass** + +Run: `go test ./internal/tui/ -run TestRemotePicker` +Expected: PASS. + +- [ ] **Step 5: Write failing test for the key binding and message handling** + +Add to `internal/tui/remote_picker_test.go`: + +```go +func TestRemoteBranchesMsg_populatesPicker(t *testing.T) { + m := Model{screen: screenRemotePicker, remotePicker: newRemotePicker("myapp", &config.Config{}, nil)} + updated, _ := m.Update(remoteBranchesMsg{ + project: "myapp", + branches: []worktree.RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}, + }) + mm := updated.(Model) + if mm.remotePicker.loading { + t.Error("expected picker to leave loading after branches arrive") + } + if got := len(mm.remotePicker.list.Items()); got != 1 { + t.Errorf("items = %d, want 1", got) + } +} + +func TestRemoteKeyBindingPresent(t *testing.T) { + k := defaultKeyMap() + if len(k.Remote.Keys()) == 0 { + t.Fatal("expected Remote binding to have keys") + } + if k.Remote.Keys()[0] != "r" { + t.Errorf("Remote key = %q, want r", k.Remote.Keys()[0]) + } +} + +func TestRefreshKeyBindingRemoved(t *testing.T) { + // 'r' now opens the remote picker; manual refresh is gone (auto-refresh + // covers it). The keyMap must no longer carry a Refresh binding — this test + // fails to compile if the field is reintroduced, which is the intent. + k := defaultKeyMap() + for _, b := range k.FullHelp() { + for _, kb := range b { + for _, key := range kb.Keys() { + if key == "r" && kb.Help().Desc == "refresh" { + t.Error("manual refresh binding should be removed") + } + } + } + } +} +``` + +- [ ] **Step 6: Run to verify failures** + +Run: `go test ./internal/tui/ -run 'TestRemoteBranchesMsg|TestRemoteKeyBindingPresent'` +Expected: FAIL — `screenRemotePicker`/`remoteBranchesMsg`/`keyMap.Remote` undefined. + +- [ ] **Step 7: Wire the binding, screen, message, and handlers** + +In `internal/tui/keys.go`, **remove** the `Refresh` field from `keyMap` (line 11) and **add** the `Remote` field in its place: + +```go + Remote key.Binding +``` + +**Remove** the `Refresh` binding from `defaultKeyMap()` (lines 40-43) and **add** the `Remote` binding (after the `New` binding): + +```go + Remote: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "remote"), + ), +``` + +In `FullHelp()`, replace `k.Refresh` with `k.Remote` in the second row (line 70): + +```go + {k.New, k.Remote, k.Delete}, +``` + +Note: `refreshCmd()` (the data refresh method) stays — it is still driven by the 3-second `tickCmd` and other callers. Only the manual-refresh *key binding* is removed. + +In `internal/tui/model.go`: + +Add the screen const (in the `const (...)` block at line 24): + +```go + screenRemotePicker +``` + +Add the message type (near the other message types, ~line 39): + +```go +type remoteBranchesMsg struct { + project string + branches []worktree.RemoteBranch + err error +} +``` + +Add the field to `Model` (near `agentPicker`, ~line 80): + +```go + // Remote-branch picker sub-model. + remotePicker *remotePickerModel +``` + +Handle the message in the top-level `Update` switch (add a case alongside the others, before the `// Route to sub-screens.` block): + +```go + case remoteBranchesMsg: + if m.remotePicker != nil && m.remotePicker.project == msg.project { + if msg.err != nil { + m.remotePicker.errText = msg.err.Error() + m.remotePicker.loading = false + } else { + m.remotePicker.setBranches(msg.branches) + } + } + return m, nil +``` + +Add routing in the screen switch (in `Update`, the `switch m.screen` block): + +```go + case screenRemotePicker: + return m.updateRemotePicker(msg) +``` + +In `updateList`, **replace** the existing `Refresh` dispatch case (lines 276-277) with the `Remote` dispatch: + +```go + case key.Matches(msg, m.keys.Remote): + return m.startRemotePicker() +``` + +(Removing `case key.Matches(msg, m.keys.Refresh): return m, m.refreshCmd()` — the only reference to `m.keys.Refresh`. `keys_test.go` and `model_test.go` reference only `refreshCmd`/refresh behavior, not the binding, so they are unaffected.) + +Add the view branch in `View()` (in the `switch m.screen`): + +```go + case screenRemotePicker: + if m.remotePicker != nil { + content = m.remotePicker.View() + } +``` + +Add the handlers at the end of `internal/tui/model.go`: + +```go +// startRemotePicker opens the remote-branch picker for the selected item's +// project and kicks off an async fetch + list. +func (m Model) startRemotePicker() (tea.Model, tea.Cmd) { + sel := m.selectedItem() + if sel == nil || sel.Project == "" { + return m, nil + } + m.remotePicker = newRemotePicker(sel.Project, m.cfg, m.tmuxClient) + m.screen = screenRemotePicker + return m, m.fetchRemoteBranchesCmd(sel.Project) +} + +// fetchRemoteBranchesCmd fetches all remotes (best-effort) and lists the +// remote-tracking branches for the project. +func (m Model) fetchRemoteBranchesCmd(project string) tea.Cmd { + cfg := m.cfg + tmuxClient := m.tmuxClient + return func() tea.Msg { + wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmuxClient, &hooks.NoOp{}) + branches, err := wtSvc.RemoteBranches(project) + return remoteBranchesMsg{project: project, branches: branches, err: err} + } +} + +// updateRemotePicker handles input while the remote-branch picker is shown. +func (m Model) updateRemotePicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok { + if m.remotePicker.list.FilterState() != list.Filtering { + switch keyMsg.String() { + case "esc": + m.screen = screenList + m.remotePicker = nil + return m, nil + case "enter": + if rb, ok := m.remotePicker.selected(); ok { + project := m.remotePicker.project + m.remotePicker = nil + return m.showTrackForm(project, rb) + } + return m, nil + } + } + } + var cmd tea.Cmd + m.remotePicker, cmd = m.remotePicker.Update(msg) + return m, cmd +} +``` + +`showTrackForm` is added in Task 8. For this task, add a temporary stub at the end of `model.go` so the package compiles and the picker tests pass: + +```go +// showTrackForm opens the create-worktree form pre-filled to track a remote +// branch. (Filled in by the form-integration task.) +func (m Model) showTrackForm(project string, rb worktree.RemoteBranch) (tea.Model, tea.Cmd) { + m.screen = screenList + return m, nil +} +``` + +Ensure `internal/tui/model.go` imports `"charm.land/bubbles/v2/list"` (already imported) and `"github.com/xico42/codeherd/internal/hooks"` (already imported). + +- [ ] **Step 8: Run TUI tests to verify they pass** + +Run: `go test ./internal/tui/` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add internal/tui/keys.go internal/tui/keys_test.go internal/tui/model.go internal/tui/remote_picker.go internal/tui/remote_picker_test.go +git commit -m "feat(tui): rebind r to remote-branch picker with async fetch" +``` + +--- + +## Task 8: TUI form — track a remote branch from the picker + +Replaces the Task 7 `showTrackForm` stub with a real handoff into the existing create-worktree form, pre-filled and routed to `NewTracking`. + +**Files:** +- Modify: `internal/tui/form.go`, `internal/tui/model.go` +- Test: `internal/tui/form_test.go` + +**Interfaces:** +- Consumes: `worktree.Service.NewTracking` (Task 4); `remotePickerModel.selected` (Task 7). +- Produces: `formContext.tracksRef`/`formContext.branch`; `formModel.tracksRef`; real `Model.showTrackForm`. + +- [ ] **Step 1: Write failing tests** + +Add to `internal/tui/form_test.go`: + +```go +func TestNewFormModel_trackPrefill(t *testing.T) { + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + ctx := formContext{project: "myapp", tracksRef: "origin/feat-x", branch: "feat-x"} + m := newFormModel(ctx, cfg, nil) + if m.tracksRef != "origin/feat-x" { + t.Errorf("tracksRef = %q, want origin/feat-x", m.tracksRef) + } + if m.branch != "feat-x" { + t.Errorf("prefilled branch = %q, want feat-x", m.branch) + } +} + +func TestShowTrackForm_opensFormScreen(t *testing.T) { + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + m := Model{cfg: cfg} + updated, _ := m.showTrackForm("myapp", worktree.RemoteBranch{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}) + mm := updated.(Model) + if mm.screen != screenForm { + t.Errorf("screen = %d, want screenForm", mm.screen) + } + if mm.form == nil || mm.form.tracksRef != "origin/feat-x" { + t.Errorf("form not set up to track; form=%+v", mm.form) + } +} +``` + +- [ ] **Step 2: Run to verify failures** + +Run: `go test ./internal/tui/ -run 'TestNewFormModel_trackPrefill|TestShowTrackForm'` +Expected: FAIL — `formContext.tracksRef`/`formModel.tracksRef` undefined; stub `showTrackForm` doesn't set the form. + +- [ ] **Step 3: Add `tracksRef` to the form and route submission** + +In `internal/tui/form.go`, add to `formModel` (after `branch`, ~line 21): + +```go + tracksRef string +``` + +add to `formContext` (lines 34-37): + +```go +type formContext struct { + project string + baseBranch string + tracksRef string + branch string // optional pre-fill for the branch input +} +``` + +In `newFormModel`, set the prefill and tracksRef before building the group (after line 46): + +```go + m.tracksRef = ctx.tracksRef + if ctx.branch != "" { + m.branch = ctx.branch + } +``` + +Change the Note so it reflects tracking when active (lines 51-53): + +```go + huh.NewNote(). + Title("New Worktree"). + Description(worktreeFormDescription(ctx)), +``` + +and add the helper at the end of `form.go`: + +```go +func worktreeFormDescription(ctx formContext) string { + if ctx.tracksRef != "" { + return fmt.Sprintf("Project: %s\nTracks: %s", ctx.project, ctx.tracksRef) + } + return fmt.Sprintf("Project: %s\nBase: %s", ctx.project, ctx.baseBranch) +} +``` + +In `submit()` (lines 117-152), capture `tracksRef` and route to `NewTracking`. Replace the captured-vars block and the service-call block: + +```go +func (f *formModel) submit() tea.Cmd { + branch := strings.TrimSpace(f.branch) + project := f.project + baseBranch := f.baseBranch + tracksRef := f.tracksRef + attach := f.attach + agent := f.agent + cfg := f.cfg + tmuxClient := f.tmuxClient + projCfg := cfg.Projects[project] + + return func() tea.Msg { + h := hooks.New(projCfg.Hooks) + projSvc := projectpkg.NewService(cfg, projectpkg.NewRealGitRunner(), h) + _ = projSvc.Clone(project) + + wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmuxClient, h) + var result worktree.NewResult + var err error + switch { + case tracksRef != "": + result, err = wtSvc.NewTracking(project, branch, tracksRef) + case baseBranch != "": + result, err = wtSvc.NewFrom(project, branch, baseBranch) + default: + result, err = wtSvc.New(project, branch) + } + if err != nil { + return errMsg{err: err} + } + + return worktreeCreatedMsg{ + project: project, + branch: result.Branch, + path: result.Path, + attach: attach, + agent: agent, + } + } +} +``` + +- [ ] **Step 4: Replace the `showTrackForm` stub in `model.go`** + +In `internal/tui/model.go`, replace the temporary `showTrackForm` from Task 7 with: + +```go +// showTrackForm opens the create-worktree form pre-filled to track the selected +// remote branch, deriving the local branch name from it. +func (m Model) showTrackForm(project string, rb worktree.RemoteBranch) (tea.Model, tea.Cmd) { + ctx := formContext{project: project, tracksRef: rb.Ref, branch: rb.Branch} + m.form = newFormModel(ctx, m.cfg, m.tmuxClient) + m.screen = screenForm + return m, m.form.Init() +} +``` + +- [ ] **Step 5: Run TUI tests to verify they pass** + +Run: `go test ./internal/tui/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/tui/form.go internal/tui/form_test.go internal/tui/model.go +git commit -m "feat(tui): track remote branch via pre-filled create form" +``` + +--- + +## Task 9: Integration test + full verification + +End-to-end test against real git (and the isolated tmux pattern), then the full project gate. + +**Files:** +- Create: `internal/worktree/integration_test.go` + +**Interfaces:** +- Consumes: `worktree.Service.NewTracking`, `RealWorktreeRunner` (Tasks 1-4). + +- [ ] **Step 1: Write the integration test** + +Create `internal/worktree/integration_test.go`. It builds a real "remote" repo with a branch, clones it into the codeherd layout, then checks the branch out via `NewTracking` and asserts the worktree branch tracks the remote. + +```go +//go:build integration + +package worktree + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/hooks" + "github.com/xico42/codeherd/internal/tmux" +) + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func TestNewTracking_endToEnd(t *testing.T) { + root := t.TempDir() + + // Build an upstream repo with a PR branch. + remote := filepath.Join(root, "remote") + if err := os.MkdirAll(remote, 0o755); err != nil { + t.Fatal(err) + } + git(t, remote, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(remote, "README.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, remote, "add", ".") + git(t, remote, "commit", "-m", "init") + git(t, remote, "checkout", "-b", "feat-x") + if err := os.WriteFile(filepath.Join(remote, "feature.txt"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, remote, "add", ".") + git(t, remote, "commit", "-m", "feature") + git(t, remote, "checkout", "main") + + // Clone into the codeherd layout: /github.com/user/myapp. + projectsDir := filepath.Join(root, "projects") + cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp") + if err := os.MkdirAll(filepath.Dir(cloneDir), 0o755); err != nil { + t.Fatal(err) + } + git(t, root, "clone", remote, cloneDir) + + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: projectsDir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + svc := NewService(cfg, NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{}) + + result, err := svc.NewTracking("myapp", "", "feat-x") + if err != nil { + t.Fatalf("NewTracking: %v", err) + } + if result.Branch != "feat-x" { + t.Errorf("branch = %q, want feat-x", result.Branch) + } + if _, err := os.Stat(filepath.Join(result.Path, "feature.txt")); err != nil { + t.Errorf("expected feature.txt in worktree: %v", err) + } + + branch := strings.TrimSpace(git(t, result.Path, "rev-parse", "--abbrev-ref", "HEAD")) + if branch != "feat-x" { + t.Errorf("worktree branch = %q, want feat-x", branch) + } + upstream := strings.TrimSpace(git(t, result.Path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")) + if upstream != "origin/feat-x" { + t.Errorf("upstream = %q, want origin/feat-x", upstream) + } +} +``` + +- [ ] **Step 2: Run the integration test** + +Run: `go test -tags integration ./internal/worktree/ -run TestNewTracking_endToEnd -v` +Expected: PASS. + +- [ ] **Step 3: Run the full gate** + +Run: `make check` +Expected: coverage ≥ 80%, integration tests pass, lint clean, build succeeds. Fix any coverage shortfall by adding focused unit tests in the affected package, and any lint issues inline. + +- [ ] **Step 4: Commit** + +```bash +git add internal/worktree/integration_test.go +git commit -m "test(worktree): end-to-end remote-branch checkout integration" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- "Track remote branch, same name, origin default/omittable" → Task 4 `NewTracking` + `parseRef` (Task 2). ✅ +- "Ref parsed as `[/]`, remote only if configured; slashes preserved" → Task 2 `parseRef` + tests. ✅ +- "`--track`/`--from` mutually exclusive" → Task 5 `MarkFlagsMutuallyExclusive` + test. ✅ +- "CLI `--track`, positional optional + override, composes with attach/agent" → Task 5. ✅ +- "Completion suggests remote branches" → Task 6. ✅ +- "Fresh source on creation: fetch + FF local + base off remote-tracking; FF best-effort/non-fatal; preserve un-pushed commits; local-only refs skip fetch" → Task 1 `FastForward`, Task 3 `freshenStartPoint` + tests. ✅ (Refinement vs spec: FF prefers the *local* branch as the base point so un-pushed commits are never dropped — see Global Constraints; the spec's "base off remote-tracking ref" is used only when no local branch exists.) +- "WorktreeRunner additions (Fetch/FetchAll/FastForward/Remotes/ListRemoteBranches/AddTracking) + RemoteBranch type" → Task 1. `HasLocalBranch` added beyond the spec to implement the "local branch already exists" error cleanly. ✅ +- "Error handling: fetch failure fatal for track, local-branch-exists error, mutual-exclusion error" → Tasks 4, 5. ✅ +- "TUI `r` picker: blocking-ish async fetch, filterable list, empty message, select → pre-filled form → NewTracking" → Tasks 7, 8. ✅ (`r` is rebound from manual refresh to the remote picker; the manual-refresh binding is removed since the dashboard auto-refreshes every 3s.) +- "Testing: unit (parse, ordering, derivation, errors, conflict), CLI, TUI, integration; coverage ≥ 80%" → Tasks 1-9. ✅ +- Out-of-scope items (PR numbers, configurable remote, async background refresh) correctly omitted. ✅ + +**Deviations from spec, intentional and noted in-plan:** +1. The fast-forward courtesy uses the *local* branch as the base point when it exists (never the remote-tracking ref), to avoid dropping un-pushed local commits — a correctness fix surfaced during planning. +2. No `NewResult.Notice` for skipped fast-forwards: FF is silent best-effort. The freshness guarantee still holds because either the local branch is fast-forwarded or (when absent) the remote-tracking ref is used. +3. TUI keybinding is `r`, rebound from the manual dashboard refresh (which is removed — auto-refresh runs every 3s). `refreshCmd` itself is unchanged. +4. Added `WorktreeRunner.HasLocalBranch` to back the "local branch already exists" error. + +**Placeholder scan:** No TBD/TODO/"handle errors appropriately"; every code step shows complete code. ✅ + +**Type consistency:** `RemoteBranch{Remote,Branch,Ref}`, `NewResult{Path,Branch}`, `NewTracking(project, branch, ref)`, `RemoteBranches(project)`, `ListRemoteBranches(project)`, `freshenStartPoint(cloneDir, src)`, `parseRef(remotes, ref)`, `remotePickerModel.{setBranches,selected}`, `formContext.{tracksRef,branch}` are used identically across tasks. ✅ diff --git a/docs/superpowers/specs/2026-06-27-checkout-remote-branch-worktree-design.md b/docs/superpowers/specs/2026-06-27-checkout-remote-branch-worktree-design.md new file mode 100644 index 0000000..a94f076 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-checkout-remote-branch-worktree-design.md @@ -0,0 +1,207 @@ +# Checkout a remote branch into a new worktree + +**Status:** Design approved +**Date:** 2026-06-27 +**Scope:** `worktree` resource, across CLI and TUI + +## Problem + +To review someone else's PR, you want a worktree that has the PR's branch +checked out — fetched fresh from the remote, tracking it, under the same name. +Today `codeherd` cannot do this: + +- There is **no `git fetch` anywhere** in the codebase. After the initial + clone, remote-tracking refs go stale. +- `create worktree --from ` always creates a **new, untracked** branch + from a start point, and passes the ref straight to `git worktree add` with no + fetch — so `--from origin/feat-x` only works if that ref already happens to be + present locally. + +This design adds the ability to fetch and check out a remote branch into a new +tracking worktree, and — as a coherent extension — makes **all** worktree +creation start from an up-to-date source. + +## Core behavior + +### Checking out a remote branch (`--track`) + +Fetch a branch from a remote, then create a worktree whose local branch +**tracks** that remote branch, using the **same name** by default: + +``` +git fetch +git worktree add --track -b / +``` + +- Default remote is `origin` and is omittable. +- A ref is parsed as `[/]`. The leading segment is treated as a + remote **only if it matches an actual configured remote** (from `git remote`); + otherwise the whole ref is the branch on `origin`. This keeps branch names + containing slashes (`feature/login`) working. +- Distinct from `--from`, which creates a *new, untracked* branch from a start + point. `--track` and `--from` are **mutually exclusive** — supplying both is an + error. + +### Fresh source on every creation + +Worktree **creation** (default / `--from` / `--track`) starts from an +up-to-date source. List and delete never fetch. + +A plain `git fetch origin` updates the remote-tracking ref (`origin/main`) but +does **not** move local `main`. So for a source branch that exists on a remote +(the common case): + +1. **Fetch** the source's remote (default `origin`). +2. **Fast-forward the local source branch** as a courtesy: + - source branch *not* checked out in the clone → + `git fetch :` (updates the local ref only if it is a + fast-forward; harmless no-op otherwise). + - source branch *is* the clone's checked-out branch → + `git merge --ff-only /`. +3. **Base the new worktree branch off the remote-tracking ref** `/` + — so it is fresh even when step 2's fast-forward is skipped. +4. If the fast-forward cannot happen (diverged or dirty clone), **do not fail** — + proceed with the fresh worktree off `/` and surface a + non-fatal notice. The local source branch just stays put. + +For `--from` pointing at a **local-only ref** (tag, SHA, or a branch with no +upstream): use it directly as the start point, no fetch — there is nothing +remote to refresh. + +For `--track `: the source *is* the remote branch, so step 2's local-source +fast-forward does not apply; fetch the remote branch and add the tracking +worktree. + +## CLI surface + +Add `--track ` to `create worktree`: + +``` +ch create worktree myapp --track feat-x # local feat-x → origin/feat-x +ch create worktree myapp --track upstream/feat-x # local feat-x → upstream/feat-x +ch create worktree myapp myname --track origin/feat-x # local myname → origin/feat-x +``` + +- Positional `` becomes **optional** when `--track` is given (the local + name is derived from the ref); when provided, it **overrides** the derived + local name. +- Composes with `--attach` and `--agent` exactly as today. +- Mutually exclusive with `--from`. +- Shell completion for `--track` suggests remote branches + (`git for-each-ref refs/remotes`, skipping `*/HEAD`). + +### Error handling + +- **Fetch failure** (no such remote branch, network error) → surfaced verbatim + with context, creation aborts. +- **Local branch already exists** (derived or overridden name) → clear error, + stop. No silent reuse. +- **`--from` + `--track` together** → validation error. + +## `internal/worktree` changes + +Extend the `WorktreeRunner` interface: + +```go +type WorktreeRunner interface { + // existing + Add(cloneDir, worktreePath, branch string) error + AddNewBranch(cloneDir, worktreePath, branch string) error + AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint string) error + Remove(cloneDir, worktreePath string) error + List(cloneDir string) ([]WorktreeInfo, error) + + // new + Fetch(cloneDir, remote, branch string) error // git fetch + FetchAll(cloneDir string) error // git fetch --all --prune + FastForward(cloneDir, remote, branch string) error // FF local to / + Remotes(cloneDir string) ([]string, error) // git remote + ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) + AddTracking(cloneDir, worktreePath, branch, remoteRef string) error // git worktree add --track -b ... +} +``` + +New types (live in `internal/worktree`): + +```go +type RemoteBranch struct { + Remote string // e.g. "origin" + Branch string // e.g. "feature/login" + Ref string // e.g. "origin/feature/login" +} +``` + +New `Service` methods: + +- `NewTracking(project, branch, ref string) (NewResult, error)` — resolve + remote+branch (using `Remotes` to decide whether the leading segment is a + remote), derive/override the local name, fetch the targeted branch, then + `AddTracking`. Reuses the existing path/semconv resolution and the + post-creation file-copy/template processing already applied by `New`/`NewFrom`. +- A freshness helper used by `New`/`NewFrom`/`NewTracking` that performs the + fetch + best-effort fast-forward + remote-tracking base-point selection + described in **Fresh source on every creation**. `FastForward` returning a + "not fast-forwardable / dirty" condition is treated as non-fatal (notice, not + error). + +Ref resolution detail: `FastForward` for a non-checked-out branch is the +`git fetch :` form; for the checked-out branch it is +`git merge --ff-only`. The Service decides which based on the clone's current +branch. Distinguishing "FF skipped because diverged/dirty" (non-fatal) from a +real error is part of the helper's contract and must be unit-tested. + +## TUI + +Keybinding **`r`** — "checkout remote branch" — available from a project +row, or from a worktree/agent row (using that row's project). `r` was +previously bound to a manual dashboard refresh; that binding is **removed** +because the dashboard already auto-refreshes every 3 seconds, freeing `r` for +this feature. + +The picker: + +1. Run `FetchAll` for the project's clone, showing a transient `fetching…` + state (blocking; simplest and always-fresh). +2. Show a **filterable list** of remote branches (`/`) from + `ListRemoteBranches`. Empty list → friendly "no remote branches found (try + again after pushing/fetching)" message. +3. On select → open the **existing create-worktree form, pre-filled**: + - `branch` = derived local name (editable), + - a read-only note `Tracks: /`, + - the usual `attach` / `agent` fields. +4. Submit routes to `Service.NewTracking`. + +Implementation notes: + +- Add a small "remote branch list" model/view alongside the existing form. +- Thread a `tracksRef string` field through `formModel`; submission selects + `NewTracking` when set, otherwise the existing `New` / `NewFrom` routing. +- The `r` binding is registered in `internal/tui/keys.go` and dispatched in + `internal/tui/model.go` next to the existing `n` (new worktree) handling. + +## Testing + +- **`internal/worktree` unit tests** (mock `WorktreeRunner`): + - ref parsing — `origin` default, explicit remote, branch names with slashes, + leading segment that is *not* a configured remote. + - fetch-then-add ordering for `NewTracking`. + - local-name derivation and positional override. + - "local branch already exists" error. + - freshness helper: base-point is `/`; FF attempted; FF + skipped/diverged is non-fatal; local-only `--from` ref skips fetch. +- **CLI tests** (`cmd`): `--track` flag wiring, optional positional, `--track` + + `--from` mutual-exclusion error, completion suggests remote branches. +- **TUI tests**: picker select → pre-filled form (`tracksRef` set, derived + name) → routes to `NewTracking`; empty remote-branch list message. +- **Integration test** (real git + tmux, isolated `CODEHERD_TMUX_SOCKET` under + `t.TempDir()`, `//go:build integration`): set up a clone with a remote branch, + check it out end-to-end, assert the worktree exists and its branch tracks the + remote. +- Keep aggregate coverage ≥ 80% (`make check`). + +## Out of scope (YAGNI) + +- GitHub PR-number resolution (`--pr 123` / `pull/123/head`). +- Configurable default remote name (hardcoded `origin`). +- Async/background picker refresh (blocking fetch on open is sufficient for now). +- Fetching before read-only or destructive worktree operations. diff --git a/internal/tui/form.go b/internal/tui/form.go index a6f1403..d7ec870 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -25,6 +25,7 @@ type formModel struct { // Context (read-only) project string baseBranch string + tracksRef string // Dependencies for creating per-project services cfg *config.Config @@ -34,6 +35,8 @@ type formModel struct { type formContext struct { project string baseBranch string + tracksRef string + branch string // optional pre-fill for the branch input } func newFormModel(ctx formContext, cfg *config.Config, tmuxClient *tmux.Client) *formModel { @@ -45,12 +48,17 @@ func newFormModel(ctx formContext, cfg *config.Config, tmuxClient *tmux.Client) tmuxClient: tmuxClient, } + m.tracksRef = ctx.tracksRef + if ctx.branch != "" { + m.branch = ctx.branch + } + agents := cfg.AgentNames() group1 := huh.NewGroup( huh.NewNote(). Title("New Worktree"). - Description(fmt.Sprintf("Project: %s\nBase: %s", ctx.project, ctx.baseBranch)), + Description(worktreeFormDescription(ctx)), huh.NewInput(). Title("Branch name"). Placeholder("feature-name"). @@ -118,6 +126,7 @@ func (f *formModel) submit() tea.Cmd { branch := strings.TrimSpace(f.branch) project := f.project baseBranch := f.baseBranch + tracksRef := f.tracksRef attach := f.attach agent := f.agent cfg := f.cfg @@ -132,9 +141,12 @@ func (f *formModel) submit() tea.Cmd { wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmuxClient, h) var result worktree.NewResult var err error - if baseBranch != "" { + switch { + case tracksRef != "": + result, err = wtSvc.NewTracking(project, branch, tracksRef) + case baseBranch != "": result, err = wtSvc.NewFrom(project, branch, baseBranch) - } else { + default: result, err = wtSvc.New(project, branch) } if err != nil { @@ -143,7 +155,7 @@ func (f *formModel) submit() tea.Cmd { return worktreeCreatedMsg{ project: project, - branch: branch, + branch: result.Branch, path: result.Path, attach: attach, agent: agent, @@ -151,6 +163,13 @@ func (f *formModel) submit() tea.Cmd { } } +func worktreeFormDescription(ctx formContext) string { + if ctx.tracksRef != "" { + return fmt.Sprintf("Project: %s\nTracks: %s", ctx.project, ctx.tracksRef) + } + return fmt.Sprintf("Project: %s\nBase: %s", ctx.project, ctx.baseBranch) +} + // showForm transitions the model to the form screen. func (m Model) showForm() (tea.Model, tea.Cmd) { sel := m.selectedItem() diff --git a/internal/tui/form_test.go b/internal/tui/form_test.go index 0085e7c..ae2517f 100644 --- a/internal/tui/form_test.go +++ b/internal/tui/form_test.go @@ -8,8 +8,34 @@ import ( tea "charm.land/bubbletea/v2" "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/worktree" ) +func TestNewFormModel_trackPrefill(t *testing.T) { + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + ctx := formContext{project: "myapp", tracksRef: "origin/feat-x", branch: "feat-x"} + m := newFormModel(ctx, cfg, nil) + if m.tracksRef != "origin/feat-x" { + t.Errorf("tracksRef = %q, want origin/feat-x", m.tracksRef) + } + if m.branch != "feat-x" { + t.Errorf("prefilled branch = %q, want feat-x", m.branch) + } +} + +func TestShowTrackForm_opensFormScreen(t *testing.T) { + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + m := Model{cfg: cfg} + updated, _ := m.showTrackForm("myapp", worktree.RemoteBranch{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}) + mm := updated.(Model) + if mm.screen != screenForm { + t.Errorf("screen = %d, want screenForm", mm.screen) + } + if mm.form == nil || mm.form.tracksRef != "origin/feat-x" { + t.Errorf("form not set up to track; form=%+v", mm.form) + } +} + var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) func stripANSI(s string) string { diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 4686aa9..bc6447e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -8,7 +8,7 @@ type keyMap struct { Clone key.Binding New key.Binding Delete key.Binding - Refresh key.Binding + Remote key.Binding Help key.Binding Quit key.Binding NextProfile key.Binding @@ -37,9 +37,9 @@ func defaultKeyMap() keyMap { key.WithKeys("d"), key.WithHelp("d", "delete"), ), - Refresh: key.NewBinding( + Remote: key.NewBinding( key.WithKeys("r"), - key.WithHelp("r", "refresh"), + key.WithHelp("r", "remote"), ), Help: key.NewBinding( key.WithKeys("?"), @@ -67,7 +67,7 @@ func (k keyMap) ShortHelp() []key.Binding { func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Attach, k.Shell, k.Clone}, - {k.New, k.Delete, k.Refresh}, + {k.New, k.Delete, k.Remote}, {k.Help, k.Quit}, } } diff --git a/internal/tui/model.go b/internal/tui/model.go index b0077bf..994dd02 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -26,6 +26,7 @@ const ( screenForm screenConfirmDelete screenAgentPicker + screenRemotePicker ) const maxWidth = 80 @@ -44,6 +45,11 @@ type worktreeCreatedMsg struct { attach bool agent string } +type remoteBranchesMsg struct { + project string + branches []worktree.RemoteBranch + err error +} // Model is the top-level Bubble Tea model. type Model struct { @@ -79,6 +85,9 @@ type Model struct { // Agent picker sub-model. agentPicker *agentPickerModel + // Remote-branch picker sub-model. + remotePicker *remotePickerModel + // Profile state. registry is nil when profile mode is off. // profileCache memoizes per-profile services built on demand by // the switch flow; the initial active profile is seeded on construction. @@ -231,6 +240,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.startSessionAfterCreate(msg) } return m, m.refreshCmd() + + case remoteBranchesMsg: + if m.remotePicker != nil && m.remotePicker.project == msg.project { + if msg.err != nil { + m.remotePicker.errText = msg.err.Error() + m.remotePicker.loading = false + } else { + m.remotePicker.setBranches(msg.branches) + } + } + return m, nil } // Route to sub-screens. @@ -241,6 +261,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateForm(msg) case screenAgentPicker: return m.updateAgentPicker(msg) + case screenRemotePicker: + return m.updateRemotePicker(msg) default: return m.updateList(msg) } @@ -273,8 +295,8 @@ func (m Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Delete): return m.startDelete() - case key.Matches(msg, m.keys.Refresh): - return m, m.refreshCmd() + case key.Matches(msg, m.keys.Remote): + return m.startRemotePicker() case key.Matches(msg, m.keys.NextProfile): return m.switchProfile(+1) @@ -306,6 +328,10 @@ func (m Model) View() tea.View { if m.agentPicker != nil { content = m.agentPicker.View() } + case screenRemotePicker: + if m.remotePicker != nil { + content = m.remotePicker.View() + } default: content = m.viewList() } @@ -541,3 +567,60 @@ func indexOf(names []string, target string) int { } return -1 } + +// startRemotePicker opens the remote-branch picker for the selected item's +// project and kicks off an async fetch + list. +func (m Model) startRemotePicker() (tea.Model, tea.Cmd) { + sel := m.selectedItem() + if sel == nil || sel.Project == "" { + return m, nil + } + m.remotePicker = newRemotePicker(sel.Project, m.cfg, m.tmuxClient) + m.screen = screenRemotePicker + return m, m.fetchRemoteBranchesCmd(sel.Project) +} + +// fetchRemoteBranchesCmd fetches all remotes (best-effort) and lists the +// remote-tracking branches for the project. +func (m Model) fetchRemoteBranchesCmd(project string) tea.Cmd { + cfg := m.cfg + tmuxClient := m.tmuxClient + return func() tea.Msg { + wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmuxClient, &hooks.NoOp{}) + branches, err := wtSvc.RemoteBranches(project) + return remoteBranchesMsg{project: project, branches: branches, err: err} + } +} + +// updateRemotePicker handles input while the remote-branch picker is shown. +func (m Model) updateRemotePicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok { + if m.remotePicker.list.FilterState() != list.Filtering { + switch keyMsg.String() { + case "esc": + m.screen = screenList + m.remotePicker = nil + return m, nil + case "enter": + if rb, ok := m.remotePicker.selected(); ok { + project := m.remotePicker.project + m.remotePicker = nil + return m.showTrackForm(project, rb) + } + return m, nil + } + } + } + var cmd tea.Cmd + m.remotePicker, cmd = m.remotePicker.Update(msg) + return m, cmd +} + +// showTrackForm opens the create-worktree form pre-filled to track the selected +// remote branch, deriving the local branch name from it. +func (m Model) showTrackForm(project string, rb worktree.RemoteBranch) (tea.Model, tea.Cmd) { + ctx := formContext{project: project, tracksRef: rb.Ref, branch: rb.Branch} + m.form = newFormModel(ctx, m.cfg, m.tmuxClient) + m.screen = screenForm + return m, m.form.Init() +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index cb9f09c..60a459f 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -297,6 +297,7 @@ func TestModel_selectedItem_exists(t *testing.T) { got := m.selectedItem() if got == nil { t.Fatal("selectedItem() = nil, want item") + return } if got.Project != "myapp" { t.Errorf("project = %q, want %q", got.Project, "myapp") @@ -358,16 +359,23 @@ func TestModel_updateList_helpToggle(t *testing.T) { } } -func TestModel_updateList_refresh(t *testing.T) { - m := Model{screen: screenList} - m.list = newList(nil) +func TestModel_updateList_remoteOpensPicker(t *testing.T) { + m := Model{screen: screenList, cfg: &config.Config{}} + m.list = newList([]list.Item{Item{Project: "myapp", Branch: "main", Group: groupWorktree}}) m.keys = defaultKeyMap() - // Press 'r' to refresh. + // Press 'r' to open the remote-branch picker for the selected project. msg := tea.KeyPressMsg(tea.Key{Code: 'r', Text: "r"}) - _, cmd := m.Update(msg) + updated, cmd := m.Update(msg) + um := updated.(Model) + if um.screen != screenRemotePicker { + t.Errorf("screen = %d, want screenRemotePicker", um.screen) + } + if um.remotePicker == nil { + t.Error("expected remotePicker to be set") + } if cmd == nil { - t.Error("refresh key should return a non-nil Cmd") + t.Error("remote key should return a non-nil fetch Cmd") } } diff --git a/internal/tui/remote_picker.go b/internal/tui/remote_picker.go new file mode 100644 index 0000000..076e2bd --- /dev/null +++ b/internal/tui/remote_picker.go @@ -0,0 +1,80 @@ +package tui + +import ( + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/tmux" + "github.com/xico42/codeherd/internal/worktree" +) + +// remoteBranchItem adapts a RemoteBranch to the bubbles list.Item interface. +type remoteBranchItem struct { + rb worktree.RemoteBranch +} + +func (i remoteBranchItem) FilterValue() string { return i.rb.Ref } +func (i remoteBranchItem) Title() string { return i.rb.Ref } +func (i remoteBranchItem) Description() string { return "" } + +// remotePickerModel shows a filterable list of remote-tracking branches. +type remotePickerModel struct { + list list.Model + project string + loading bool + errText string + cfg *config.Config + tmuxClient *tmux.Client +} + +func newRemotePicker(project string, cfg *config.Config, tmuxClient *tmux.Client) *remotePickerModel { + l := list.New(nil, list.NewDefaultDelegate(), maxWidth, 20) + l.Title = "Remote branches" + l.SetShowHelp(false) + l.SetFilteringEnabled(true) + l.DisableQuitKeybindings() + return &remotePickerModel{ + list: l, + project: project, + loading: true, + cfg: cfg, + tmuxClient: tmuxClient, + } +} + +func (p *remotePickerModel) setBranches(branches []worktree.RemoteBranch) { + items := make([]list.Item, len(branches)) + for i, b := range branches { + items[i] = remoteBranchItem{rb: b} + } + p.list.SetItems(items) + p.loading = false +} + +func (p *remotePickerModel) selected() (worktree.RemoteBranch, bool) { + it, ok := p.list.SelectedItem().(remoteBranchItem) + if !ok { + return worktree.RemoteBranch{}, false + } + return it.rb, true +} + +func (p *remotePickerModel) Update(msg tea.Msg) (*remotePickerModel, tea.Cmd) { + var cmd tea.Cmd + p.list, cmd = p.list.Update(msg) + return p, cmd +} + +func (p *remotePickerModel) View() string { + switch { + case p.loading: + return "Fetching remote branches…" + case p.errText != "": + return "Error: " + p.errText + "\n\nEsc: cancel" + case len(p.list.Items()) == 0: + return "No remote branches found (try again after pushing/fetching).\n\nEsc: cancel" + default: + return p.list.View() + } +} diff --git a/internal/tui/remote_picker_test.go b/internal/tui/remote_picker_test.go new file mode 100644 index 0000000..970a844 --- /dev/null +++ b/internal/tui/remote_picker_test.go @@ -0,0 +1,75 @@ +package tui + +import ( + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/worktree" +) + +func TestRemotePicker_setAndSelect(t *testing.T) { + p := newRemotePicker("myapp", &config.Config{}, nil) + if !p.loading { + t.Error("picker should start in loading state") + } + p.setBranches([]worktree.RemoteBranch{ + {Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}, + {Remote: "origin", Branch: "fix-y", Ref: "origin/fix-y"}, + }) + if p.loading { + t.Error("picker should leave loading state after setBranches") + } + rb, ok := p.selected() + if !ok || rb.Ref != "origin/feat-x" { + t.Errorf("selected = %+v ok=%v, want origin/feat-x", rb, ok) + } +} + +func TestRemotePicker_emptySelectNone(t *testing.T) { + p := newRemotePicker("myapp", &config.Config{}, nil) + p.setBranches(nil) + if _, ok := p.selected(); ok { + t.Error("expected no selection on empty picker") + } +} + +func TestRemoteBranchesMsg_populatesPicker(t *testing.T) { + m := Model{screen: screenRemotePicker, remotePicker: newRemotePicker("myapp", &config.Config{}, nil)} + updated, _ := m.Update(remoteBranchesMsg{ + project: "myapp", + branches: []worktree.RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}, + }) + mm := updated.(Model) + if mm.remotePicker.loading { + t.Error("expected picker to leave loading after branches arrive") + } + if got := len(mm.remotePicker.list.Items()); got != 1 { + t.Errorf("items = %d, want 1", got) + } +} + +func TestRemoteKeyBindingPresent(t *testing.T) { + k := defaultKeyMap() + if len(k.Remote.Keys()) == 0 { + t.Fatal("expected Remote binding to have keys") + } + if k.Remote.Keys()[0] != "r" { + t.Errorf("Remote key = %q, want r", k.Remote.Keys()[0]) + } +} + +func TestRefreshKeyBindingRemoved(t *testing.T) { + // 'r' now opens the remote picker; manual refresh is gone (auto-refresh + // covers it). The keyMap must no longer carry a Refresh binding — this test + // fails to compile if the field is reintroduced, which is the intent. + k := defaultKeyMap() + for _, b := range k.FullHelp() { + for _, kb := range b { + for _, key := range kb.Keys() { + if key == "r" && kb.Help().Desc == "refresh" { + t.Error("manual refresh binding should be removed") + } + } + } + } +} diff --git a/internal/worktree/integration_test.go b/internal/worktree/integration_test.go new file mode 100644 index 0000000..c9e9378 --- /dev/null +++ b/internal/worktree/integration_test.go @@ -0,0 +1,89 @@ +//go:build integration + +package worktree + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/hooks" + "github.com/xico42/codeherd/internal/tmux" +) + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func TestNewTracking_endToEnd(t *testing.T) { + root := t.TempDir() + + // Build an upstream repo with a PR branch. + remote := filepath.Join(root, "remote") + if err := os.MkdirAll(remote, 0o755); err != nil { + t.Fatal(err) + } + git(t, remote, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(remote, "README.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, remote, "add", ".") + git(t, remote, "commit", "-m", "init") + git(t, remote, "checkout", "-b", "feat-x") + if err := os.WriteFile(filepath.Join(remote, "feature.txt"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, remote, "add", ".") + git(t, remote, "commit", "-m", "feature") + git(t, remote, "checkout", "main") + + // Clone into the codeherd layout: /github.com/user/myapp. + projectsDir := filepath.Join(root, "projects") + cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp") + if err := os.MkdirAll(filepath.Dir(cloneDir), 0o755); err != nil { + t.Fatal(err) + } + git(t, root, "clone", remote, cloneDir) + + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: projectsDir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + svc := NewService(cfg, NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{}) + + result, err := svc.NewTracking("myapp", "", "feat-x") + if err != nil { + t.Fatalf("NewTracking: %v", err) + } + if result.Branch != "feat-x" { + t.Errorf("branch = %q, want feat-x", result.Branch) + } + if _, err := os.Stat(filepath.Join(result.Path, "feature.txt")); err != nil { + t.Errorf("expected feature.txt in worktree: %v", err) + } + + branch := strings.TrimSpace(git(t, result.Path, "rev-parse", "--abbrev-ref", "HEAD")) + if branch != "feat-x" { + t.Errorf("worktree branch = %q, want feat-x", branch) + } + upstream := strings.TrimSpace(git(t, result.Path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")) + if upstream != "origin/feat-x" { + t.Errorf("upstream = %q, want origin/feat-x", upstream) + } +} diff --git a/internal/worktree/realrunner_test.go b/internal/worktree/realrunner_test.go new file mode 100644 index 0000000..d52d7b0 --- /dev/null +++ b/internal/worktree/realrunner_test.go @@ -0,0 +1,177 @@ +package worktree + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// runGit runs git in dir with a deterministic identity, failing the test on error. +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +// realRunnerRepos builds an upstream repo (with main + feat-x branches) and a +// clone of it, returning the clone dir. Skips when git is unavailable. +func realRunnerRepos(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + root := t.TempDir() + + remote := filepath.Join(root, "remote") + if err := os.MkdirAll(remote, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, remote, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(remote, "README.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, remote, "add", ".") + runGit(t, remote, "commit", "-m", "init") + runGit(t, remote, "checkout", "-b", "feat-x") + if err := os.WriteFile(filepath.Join(remote, "feature.txt"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, remote, "add", ".") + runGit(t, remote, "commit", "-m", "feature") + runGit(t, remote, "checkout", "main") + + clone := filepath.Join(root, "clone") + runGit(t, root, "clone", remote, clone) + return clone +} + +func TestRealWorktreeRunner_RemotesAndBranches(t *testing.T) { + clone := realRunnerRepos(t) + r := NewRealWorktreeRunner() + + remotes, err := r.Remotes(clone) + if err != nil { + t.Fatalf("Remotes: %v", err) + } + if len(remotes) != 1 || remotes[0] != "origin" { + t.Errorf("remotes = %v, want [origin]", remotes) + } + + branches, err := r.ListRemoteBranches(clone) + if err != nil { + t.Fatalf("ListRemoteBranches: %v", err) + } + got := make(map[string]bool) + for _, b := range branches { + got[b.Ref] = true + } + if !got["origin/main"] || !got["origin/feat-x"] { + t.Errorf("remote branches = %+v, want origin/main and origin/feat-x", branches) + } +} + +func TestRealWorktreeRunner_HasLocalBranch(t *testing.T) { + clone := realRunnerRepos(t) + r := NewRealWorktreeRunner() + + has, err := r.HasLocalBranch(clone, "main") + if err != nil || !has { + t.Errorf("HasLocalBranch(main) = %v, %v; want true, nil", has, err) + } + has, err = r.HasLocalBranch(clone, "nonexistent") + if err != nil || has { + t.Errorf("HasLocalBranch(nonexistent) = %v, %v; want false, nil", has, err) + } +} + +func TestRealWorktreeRunner_FetchAndFastForward(t *testing.T) { + clone := realRunnerRepos(t) + r := NewRealWorktreeRunner() + + if err := r.Fetch(clone, "origin", "feat-x"); err != nil { + t.Errorf("Fetch: %v", err) + } + if err := r.FetchAll(clone); err != nil { + t.Errorf("FetchAll: %v", err) + } + // main is checked out and already up to date — fast-forward is a no-op. + if err := r.FastForward(clone, "origin", "main"); err != nil { + t.Errorf("FastForward: %v", err) + } + if err := r.Fetch(clone, "origin", "no-such-branch"); err == nil { + t.Error("Fetch of missing branch should error") + } +} + +func TestRealWorktreeRunner_AddTrackingAndList(t *testing.T) { + clone := realRunnerRepos(t) + r := NewRealWorktreeRunner() + + if err := r.Fetch(clone, "origin", "feat-x"); err != nil { + t.Fatalf("Fetch: %v", err) + } + wtPath := filepath.Join(filepath.Dir(clone), "wt-feat-x") + if err := r.AddTracking(clone, wtPath, "feat-x", "origin/feat-x"); err != nil { + t.Fatalf("AddTracking: %v", err) + } + if _, err := os.Stat(filepath.Join(wtPath, "feature.txt")); err != nil { + t.Errorf("expected feature.txt in tracking worktree: %v", err) + } + + wts, err := r.List(clone) + if err != nil { + t.Fatalf("List: %v", err) + } + var found bool + for _, w := range wts { + if w.Branch == "feat-x" { + found = true + } + } + if !found { + t.Errorf("List did not include feat-x worktree: %+v", wts) + } +} + +func TestRealWorktreeRunner_AddNewBranchFromAndRemove(t *testing.T) { + clone := realRunnerRepos(t) + r := NewRealWorktreeRunner() + + wtPath := filepath.Join(filepath.Dir(clone), "wt-new") + if err := r.AddNewBranchFrom(clone, wtPath, "new-branch", "main"); err != nil { + t.Fatalf("AddNewBranchFrom: %v", err) + } + if _, err := os.Stat(wtPath); err != nil { + t.Errorf("expected worktree dir: %v", err) + } + if err := r.Remove(clone, wtPath); err != nil { + t.Errorf("Remove: %v", err) + } + + // AddNewBranch (no start point) on a second worktree. + wtPath2 := filepath.Join(filepath.Dir(clone), "wt-new2") + if err := r.AddNewBranch(clone, wtPath2, "new-branch2"); err != nil { + t.Errorf("AddNewBranch: %v", err) + } +} + +func TestRealWorktreeRunner_AddExistingBranch(t *testing.T) { + clone := realRunnerRepos(t) + r := NewRealWorktreeRunner() + + // Create a local branch to check out into a worktree via Add. + runGit(t, clone, "branch", "local-x", "main") + wtPath := filepath.Join(filepath.Dir(clone), "wt-local-x") + if err := r.Add(clone, wtPath, "local-x"); err != nil { + t.Errorf("Add: %v", err) + } +} diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 401ea05..121fcfb 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -18,10 +18,11 @@ import ( // Sentinel errors returned by Service methods. var ( - ErrNotCloned = errors.New("project not cloned") - ErrWorktreeExists = errors.New("worktree already exists") - ErrWorktreeNotFound = errors.New("worktree not found") - ErrSessionRunning = errors.New("session is running") + ErrNotCloned = errors.New("project not cloned") + ErrWorktreeExists = errors.New("worktree already exists") + ErrWorktreeNotFound = errors.New("worktree not found") + ErrSessionRunning = errors.New("session is running") + ErrLocalBranchExists = errors.New("local branch already exists") ) // WorktreeInfo holds data from a single git worktree entry. @@ -40,7 +41,15 @@ type ListEntry struct { // NewResult is the result of a successful worktree creation. type NewResult struct { - Path string + Path string + Branch string // resolved local branch name (derived for --track) +} + +// RemoteBranch is one remote-tracking branch (e.g. origin/feature-x). +type RemoteBranch struct { + Remote string + Branch string + Ref string // "/" } // WorktreeRunner abstracts git worktree operations for testability. @@ -50,6 +59,14 @@ type WorktreeRunner interface { AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint string) error Remove(cloneDir, worktreePath string) error List(cloneDir string) ([]WorktreeInfo, error) + + Fetch(cloneDir, remote, branch string) error + FetchAll(cloneDir string) error + FastForward(cloneDir, remote, branch string) error + Remotes(cloneDir string) ([]string, error) + ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) + AddTracking(cloneDir, worktreePath, branch, remoteRef string) error + HasLocalBranch(cloneDir, branch string) (bool, error) } // RealWorktreeRunner runs git worktree commands via os/exec. @@ -108,6 +125,99 @@ func (r *RealWorktreeRunner) List(cloneDir string) ([]WorktreeInfo, error) { return parseWorktreePorcelain(string(out)), nil } +func (r *RealWorktreeRunner) Fetch(cloneDir, remote, branch string) error { + cmd := exec.Command("git", "fetch", remote, branch) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git fetch %s %s: %w\n%s", remote, branch, err, out) + } + return nil +} + +func (r *RealWorktreeRunner) FetchAll(cloneDir string) error { + cmd := exec.Command("git", "fetch", "--all", "--prune") + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git fetch --all: %w\n%s", err, out) + } + return nil +} + +// FastForward advances the local branch to / without losing +// local commits. It first tries a non-checkout ref update (works when the +// branch is not checked out); if that fails it falls back to a fast-forward-only +// merge (for the clone's currently checked-out branch). Either failure is +// reported but treated as best-effort by callers. +func (r *RealWorktreeRunner) FastForward(cloneDir, remote, branch string) error { + refspec := branch + ":" + branch + cmd := exec.Command("git", "fetch", remote, refspec) + cmd.Dir = cloneDir + if _, err := cmd.CombinedOutput(); err == nil { + return nil + } + remoteRef := remote + "/" + branch + cmd = exec.Command("git", "merge", "--ff-only", remoteRef) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("fast-forward %s: %w\n%s", branch, err, out) + } + return nil +} + +func (r *RealWorktreeRunner) Remotes(cloneDir string) ([]string, error) { + cmd := exec.Command("git", "remote") + cmd.Dir = cloneDir + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git remote: %w", err) + } + var names []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if s := strings.TrimSpace(line); s != "" { + names = append(names, s) + } + } + return names, nil +} + +func (r *RealWorktreeRunner) ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) { + cmd := exec.Command("git", "for-each-ref", "--format=%(refname:short)", "refs/remotes") + cmd.Dir = cloneDir + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git for-each-ref: %w", err) + } + return parseRemoteBranches(string(out)), nil +} + +func (r *RealWorktreeRunner) AddTracking(cloneDir, worktreePath, branch, remoteRef string) error { + cmd := exec.Command("git", "worktree", "add", "--track", "-b", branch, worktreePath, remoteRef) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git worktree add --track: %w\n%s", err, out) + } + return nil +} + +func (r *RealWorktreeRunner) HasLocalBranch(cloneDir, branch string) (bool, error) { + ref := "refs/heads/" + branch + cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref) + cmd.Dir = cloneDir + err := cmd.Run() + if err == nil { + return true, nil + } + var ee *exec.ExitError + if errors.As(err, &ee) { + return false, nil + } + return false, fmt.Errorf("git show-ref %s: %w", ref, err) +} + // parseWorktreePorcelain parses the output of `git worktree list --porcelain`. // Blocks are separated by blank lines. func parseWorktreePorcelain(output string) []WorktreeInfo { @@ -135,6 +245,45 @@ func parseWorktreePorcelain(output string) []WorktreeInfo { return result } +// parseRemoteBranches parses `git for-each-ref --format=%(refname:short) refs/remotes`. +// The remote name is the segment before the first slash; the rest is the branch +// (which may contain slashes). Symbolic */HEAD entries are skipped. +func parseRemoteBranches(output string) []RemoteBranch { + var result []RemoteBranch + for _, line := range strings.Split(output, "\n") { + s := strings.TrimSpace(line) + if s == "" { + continue + } + idx := strings.Index(s, "/") + if idx <= 0 { + continue + } + remote, branch := s[:idx], s[idx+1:] + if branch == "" || branch == "HEAD" { + continue + } + result = append(result, RemoteBranch{Remote: remote, Branch: branch, Ref: s}) + } + return result +} + +// parseRef splits a user-supplied ref into a remote and branch. When ref is +// "/" and matches a configured remote, it returns +// (remote, rest, true). Otherwise it defaults to ("origin", ref, false), which +// keeps branch names containing slashes (e.g. feature/login) intact. +func parseRef(remotes []string, ref string) (remote, branch string, explicit bool) { + if idx := strings.Index(ref, "/"); idx > 0 { + candidate := ref[:idx] + for _, r := range remotes { + if r == candidate { + return candidate, ref[idx+1:], true + } + } + } + return "origin", ref, false +} + // Service provides worktree management operations. type Service struct { cfg *config.Config @@ -164,6 +313,28 @@ func (s *Service) resolvePaths(project, branch string) (cloneDir, worktreesRoot, return cloneDir, worktreesRoot, worktreePath, nil } +// freshenStartPoint fetches updates for the source ref and returns the start +// point a new worktree branch should be based on. It prefers a fast-forwarded +// local branch (to preserve un-pushed commits), falling back to the +// remote-tracking ref, or the raw ref when the source is not on a remote +// (tags, SHAs, local-only branches). All git failures here are best-effort. +func (s *Service) freshenStartPoint(cloneDir, src string) string { + remotes, _ := s.git.Remotes(cloneDir) + remote, branch, explicit := parseRef(remotes, src) + if explicit { + _ = s.git.Fetch(cloneDir, remote, branch) + return src + } + if err := s.git.Fetch(cloneDir, "origin", src); err != nil { + return src + } + if has, _ := s.git.HasLocalBranch(cloneDir, src); has { + _ = s.git.FastForward(cloneDir, "origin", src) + return src + } + return "origin/" + src +} + // New creates a new git worktree for the given project and branch. func (s *Service) New(project, branch string) (NewResult, error) { p, ok := s.cfg.Projects[project] @@ -202,8 +373,13 @@ func (s *Service) New(project, branch string) (NewResult, error) { addErr := s.git.Add(cloneDir, worktreePath, branch) if addErr != nil { - if err := s.git.AddNewBranch(cloneDir, worktreePath, branch); err != nil { - return NewResult{}, fmt.Errorf("failed to create worktree (add: %v; add -b: %w)", addErr, err) + src := p.DefaultBranch + if src == "" { + src = "main" + } + startPoint := s.freshenStartPoint(cloneDir, src) + if err := s.git.AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint); err != nil { + return NewResult{}, fmt.Errorf("failed to create worktree (add: %v; add -b from %s: %w)", addErr, startPoint, err) } } @@ -211,7 +387,7 @@ func (s *Service) New(project, branch string) (NewResult, error) { return NewResult{}, fmt.Errorf("post-worktree hook: %w", err) } - return NewResult{Path: worktreePath}, nil + return NewResult{Path: worktreePath, Branch: branch}, nil } // NewFrom creates a new git worktree branching from the given start point. @@ -250,15 +426,134 @@ func (s *Service) NewFrom(project, branch, fromBranch string) (NewResult, error) return NewResult{}, fmt.Errorf("creating worktrees dir: %w", err) } - if err := s.git.AddNewBranchFrom(cloneDir, worktreePath, branch, fromBranch); err != nil { - return NewResult{}, fmt.Errorf("creating worktree from %s: %w", fromBranch, err) + startPoint := s.freshenStartPoint(cloneDir, fromBranch) + if err := s.git.AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint); err != nil { + return NewResult{}, fmt.Errorf("creating worktree from %s: %w", startPoint, err) } if err := s.hook.Trigger(semconv.HookPostWorktree, attrs, worktreePath); err != nil { return NewResult{}, fmt.Errorf("post-worktree hook: %w", err) } - return NewResult{Path: worktreePath}, nil + return NewResult{Path: worktreePath, Branch: branch}, nil +} + +// cloneDirFor returns the clone directory for a project without needing a +// branch (unlike resolvePaths, which also computes a worktree path). +func (s *Service) cloneDirFor(project string) (string, error) { + p, ok := s.cfg.Projects[project] + if !ok { + return "", fmt.Errorf("project %q is not configured", project) + } + repoPath, err := config.RepoPath(p.Repo) + if err != nil { + return "", fmt.Errorf("parsing repo URL: %w", err) + } + return semconv.CloneDir(s.cfg.Defaults.ProjectsDir, repoPath), nil +} + +// NewTracking fetches a remote branch and creates a worktree whose local branch +// tracks it. ref is "[/]" (default remote origin). When branch +// is empty the local name is derived from the remote branch; otherwise it +// overrides the derived name. +func (s *Service) NewTracking(project, branch, ref string) (NewResult, error) { + p, ok := s.cfg.Projects[project] + if !ok { + return NewResult{}, fmt.Errorf("project %q is not configured", project) + } + + cloneDir, err := s.cloneDirFor(project) + if err != nil { + return NewResult{}, err + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return NewResult{}, fmt.Errorf("%w: %s", ErrNotCloned, project) + } + + remotes, _ := s.git.Remotes(cloneDir) + remote, remoteBranch, _ := parseRef(remotes, ref) + localName := branch + if localName == "" { + localName = remoteBranch + } + remoteRef := remote + "/" + remoteBranch + + _, worktreesRoot, worktreePath, err := s.resolvePaths(project, localName) + if err != nil { + return NewResult{}, err + } + if _, err := os.Stat(worktreePath); err == nil { + return NewResult{}, fmt.Errorf("%w: %s/%s", ErrWorktreeExists, project, localName) + } + + if has, _ := s.git.HasLocalBranch(cloneDir, localName); has { + return NewResult{}, fmt.Errorf("%w: %s", ErrLocalBranchExists, localName) + } + + if err := s.git.Fetch(cloneDir, remote, remoteBranch); err != nil { + return NewResult{}, fmt.Errorf("fetching %s: %w", remoteRef, err) + } + + attrs := map[string]string{ + semconv.HookAttrProject: project, + semconv.HookAttrBranch: localName, + semconv.HookAttrRepo: p.Repo, + semconv.HookAttrCloneDir: cloneDir, + semconv.HookAttrWorktreePath: worktreePath, + } + + if err := s.hook.Trigger(semconv.HookPreWorktree, attrs, worktreePath); err != nil { + return NewResult{}, fmt.Errorf("pre-worktree hook: %w", err) + } + + if err := os.MkdirAll(worktreesRoot, 0o755); err != nil { + return NewResult{}, fmt.Errorf("creating worktrees dir: %w", err) + } + + if err := s.git.AddTracking(cloneDir, worktreePath, localName, remoteRef); err != nil { + return NewResult{}, fmt.Errorf("creating tracking worktree for %s: %w", remoteRef, err) + } + + if err := s.hook.Trigger(semconv.HookPostWorktree, attrs, worktreePath); err != nil { + return NewResult{}, fmt.Errorf("post-worktree hook: %w", err) + } + + return NewResult{Path: worktreePath, Branch: localName}, nil +} + +// ListRemoteBranches returns the remote-tracking branches for a project without +// fetching (suitable for shell completion). +func (s *Service) ListRemoteBranches(project string) ([]RemoteBranch, error) { + cloneDir, err := s.cloneDirFor(project) + if err != nil { + return nil, err + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotCloned, project) + } + branches, err := s.git.ListRemoteBranches(cloneDir) + if err != nil { + return nil, fmt.Errorf("listing remote branches: %w", err) + } + return branches, nil +} + +// RemoteBranches fetches all remotes (best-effort) then lists remote-tracking +// branches — used by the TUI picker so the list reflects current remote state. +func (s *Service) RemoteBranches(project string) ([]RemoteBranch, error) { + cloneDir, err := s.cloneDirFor(project) + if err != nil { + return nil, err + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotCloned, project) + } + _ = s.git.FetchAll(cloneDir) + branches, err := s.git.ListRemoteBranches(cloneDir) + if err != nil { + return nil, fmt.Errorf("listing remote branches: %w", err) + } + return branches, nil } // List returns worktree entries for all configured projects, or just the named one. diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 3627a87..58986a9 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -107,6 +107,94 @@ func TestParseWorktreePorcelain_noTrailingNewline(t *testing.T) { } } +func TestParseRemoteBranches(t *testing.T) { + input := "origin/main\norigin/HEAD\norigin/feature/login\nupstream/bugfix\n" + got := parseRemoteBranches(input) + want := []RemoteBranch{ + {Remote: "origin", Branch: "main", Ref: "origin/main"}, + {Remote: "origin", Branch: "feature/login", Ref: "origin/feature/login"}, + {Remote: "upstream", Branch: "bugfix", Ref: "upstream/bugfix"}, + } + if len(got) != len(want) { + t.Fatalf("got %d entries, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("entry %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestFreshenStartPoint_localBranchPreferred(t *testing.T) { + git := &mockGit{hasLocalBranch: true} + svc, _ := makeService(t, git, &mockTmuxRunner{}) + got := svc.freshenStartPoint("/clone", "main") + if got != "main" { + t.Errorf("start point = %q, want %q", got, "main") + } + if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "main"} { + t.Errorf("fetch calls = %v", git.fetchCalls) + } + if len(git.fastForwardCalls) != 1 || git.fastForwardCalls[0] != [2]string{"origin", "main"} { + t.Errorf("fast-forward calls = %v", git.fastForwardCalls) + } +} + +func TestFreshenStartPoint_noLocalBranchUsesRemoteTracking(t *testing.T) { + git := &mockGit{hasLocalBranch: false} + svc, _ := makeService(t, git, &mockTmuxRunner{}) + got := svc.freshenStartPoint("/clone", "feat-x") + if got != "origin/feat-x" { + t.Errorf("start point = %q, want %q", got, "origin/feat-x") + } + if len(git.fastForwardCalls) != 0 { + t.Errorf("did not expect fast-forward, got %v", git.fastForwardCalls) + } +} + +func TestFreshenStartPoint_fetchFailsFallsBackToRaw(t *testing.T) { + git := &mockGit{fetchErr: fmt.Errorf("no such branch")} + svc, _ := makeService(t, git, &mockTmuxRunner{}) + got := svc.freshenStartPoint("/clone", "v1.2.3") + if got != "v1.2.3" { + t.Errorf("start point = %q, want %q", got, "v1.2.3") + } +} + +func TestFreshenStartPoint_explicitRemoteRef(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin", "upstream"}} + svc, _ := makeService(t, git, &mockTmuxRunner{}) + got := svc.freshenStartPoint("/clone", "upstream/feat-x") + if got != "upstream/feat-x" { + t.Errorf("start point = %q, want %q", got, "upstream/feat-x") + } + if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"upstream", "feat-x"} { + t.Errorf("fetch calls = %v", git.fetchCalls) + } +} + +func TestParseRef(t *testing.T) { + remotes := []string{"origin", "upstream"} + cases := []struct { + ref string + remote, branch string + explicit bool + }{ + {"feat-x", "origin", "feat-x", false}, + {"feature/login", "origin", "feature/login", false}, + {"origin/feat-x", "origin", "feat-x", true}, + {"upstream/feature/login", "upstream", "feature/login", true}, + {"notaremote/x", "origin", "notaremote/x", false}, + } + for _, tc := range cases { + gotR, gotB, gotE := parseRef(remotes, tc.ref) + if gotR != tc.remote || gotB != tc.branch || gotE != tc.explicit { + t.Errorf("parseRef(%q) = (%q,%q,%v), want (%q,%q,%v)", + tc.ref, gotR, gotB, gotE, tc.remote, tc.branch, tc.explicit) + } + } +} + // mockGit records calls and controls return values. type mockGit struct { addErr error @@ -119,6 +207,23 @@ type mockGit struct { listErr error addCalled bool addNewCalled bool + + fetchErr error + fetchCalls [][2]string // {remote, branch} + fetchAllErr error + fetchAllCalled bool + fastForwardErr error + fastForwardCalls [][2]string + remotesResult []string + remotesErr error + listRemoteResult []RemoteBranch + listRemoteErr error + addTrackingErr error + addTrackingCalled bool + addTrackingBranch string + addTrackingRef string + hasLocalBranch bool + hasLocalBranchErr error } func (m *mockGit) Add(cloneDir, worktreePath, branch string) error { @@ -138,6 +243,33 @@ func (m *mockGit) Remove(cloneDir, worktreePath string) error { return m.removeE func (m *mockGit) List(cloneDir string) ([]WorktreeInfo, error) { return m.listResult, m.listErr } +func (m *mockGit) Fetch(cloneDir, remote, branch string) error { + m.fetchCalls = append(m.fetchCalls, [2]string{remote, branch}) + return m.fetchErr +} +func (m *mockGit) FetchAll(cloneDir string) error { + m.fetchAllCalled = true + return m.fetchAllErr +} +func (m *mockGit) FastForward(cloneDir, remote, branch string) error { + m.fastForwardCalls = append(m.fastForwardCalls, [2]string{remote, branch}) + return m.fastForwardErr +} +func (m *mockGit) Remotes(cloneDir string) ([]string, error) { + return m.remotesResult, m.remotesErr +} +func (m *mockGit) ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) { + return m.listRemoteResult, m.listRemoteErr +} +func (m *mockGit) AddTracking(cloneDir, worktreePath, branch, remoteRef string) error { + m.addTrackingCalled = true + m.addTrackingBranch = branch + m.addTrackingRef = remoteRef + return m.addTrackingErr +} +func (m *mockGit) HasLocalBranch(cloneDir, branch string) (bool, error) { + return m.hasLocalBranch, m.hasLocalBranchErr +} // mockTmuxRunner controls tmux subprocess results. type mockTmuxRunner struct { @@ -224,24 +356,30 @@ func TestService_New_success(t *testing.T) { } } -func TestService_New_branchNotFound_fallsBackToAddNew(t *testing.T) { - git := &mockGit{addErr: fmt.Errorf("invalid reference")} +func TestService_New_branchNotFound_createsFromFreshDefault(t *testing.T) { + git := &mockGit{addErr: fmt.Errorf("invalid reference"), hasLocalBranch: false} svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { t.Fatal(err) } - _, err := svc.New("myapp", "new-feature") + result, err := svc.New("myapp", "new-feature") if err != nil { t.Fatalf("unexpected error: %v", err) } - if !git.addNewCalled { - t.Error("expected AddNewBranch to be called on Add failure") + if !git.addNewFromCalled { + t.Error("expected AddNewBranchFrom to be called on Add failure") + } + if git.addNewFromStartPoint != "origin/main" { + t.Errorf("start point = %q, want %q", git.addNewFromStartPoint, "origin/main") + } + if result.Branch != "new-feature" { + t.Errorf("branch = %q, want %q", result.Branch, "new-feature") } } func TestService_New_withFromBranch(t *testing.T) { - git := &mockGit{} + git := &mockGit{hasLocalBranch: false} svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { t.Fatal(err) @@ -254,8 +392,11 @@ func TestService_New_withFromBranch(t *testing.T) { if !git.addNewFromCalled { t.Error("expected AddNewBranchFrom to be called") } - if git.addNewFromStartPoint != "feature-auth" { - t.Errorf("start point = %q, want %q", git.addNewFromStartPoint, "feature-auth") + if git.addNewFromStartPoint != "origin/feature-auth" { + t.Errorf("start point = %q, want %q", git.addNewFromStartPoint, "origin/feature-auth") + } + if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "feature-auth"} { + t.Errorf("fetch calls = %v", git.fetchCalls) } expectedPath := cloneDirPath(tmpDir) + "__worktrees/my-feature" if result.Path != expectedPath { @@ -263,6 +404,129 @@ func TestService_New_withFromBranch(t *testing.T) { } } +func TestService_NewTracking_success(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin"}} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.NewTracking("myapp", "", "feat-x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "feat-x"} { + t.Errorf("fetch calls = %v", git.fetchCalls) + } + if !git.addTrackingCalled || git.addTrackingBranch != "feat-x" || git.addTrackingRef != "origin/feat-x" { + t.Errorf("AddTracking branch=%q ref=%q", git.addTrackingBranch, git.addTrackingRef) + } + if result.Branch != "feat-x" { + t.Errorf("branch = %q, want feat-x", result.Branch) + } + expectedPath := cloneDirPath(tmpDir) + "__worktrees/feat-x" + if result.Path != expectedPath { + t.Errorf("path = %q, want %q", result.Path, expectedPath) + } +} + +func TestService_NewTracking_overrideName(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin", "upstream"}} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + result, err := svc.NewTracking("myapp", "review", "upstream/feat-x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if git.addTrackingBranch != "review" || git.addTrackingRef != "upstream/feat-x" { + t.Errorf("AddTracking branch=%q ref=%q", git.addTrackingBranch, git.addTrackingRef) + } + if result.Branch != "review" { + t.Errorf("branch = %q, want review", result.Branch) + } +} + +func TestService_NewTracking_localBranchExists(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin"}, hasLocalBranch: true} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + _, err := svc.NewTracking("myapp", "", "feat-x") + if !errors.Is(err, ErrLocalBranchExists) { + t.Errorf("expected ErrLocalBranchExists, got %v", err) + } + if git.addTrackingCalled { + t.Error("AddTracking should not be called when the branch already exists") + } +} + +func TestService_NewTracking_fetchFails(t *testing.T) { + git := &mockGit{remotesResult: []string{"origin"}, fetchErr: fmt.Errorf("no such ref")} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + _, err := svc.NewTracking("myapp", "", "feat-x") + if err == nil { + t.Fatal("expected error when fetch fails") + } + if git.addTrackingCalled { + t.Error("AddTracking should not run after a failed fetch") + } +} + +func TestService_NewTracking_notCloned(t *testing.T) { + svc, _ := makeService(t, &mockGit{remotesResult: []string{"origin"}}, &mockTmuxRunner{}) + _, err := svc.NewTracking("myapp", "", "feat-x") + if !errors.Is(err, ErrNotCloned) { + t.Errorf("expected ErrNotCloned, got %v", err) + } +} + +func TestService_RemoteBranches_fetchesThenLists(t *testing.T) { + git := &mockGit{listRemoteResult: []RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + got, err := svc.RemoteBranches("myapp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !git.fetchAllCalled { + t.Error("expected FetchAll to be called") + } + if len(got) != 1 || got[0].Ref != "origin/feat-x" { + t.Errorf("branches = %+v", got) + } +} + +func TestService_ListRemoteBranches_noFetch(t *testing.T) { + git := &mockGit{listRemoteResult: []RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}} + svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + got, err := svc.ListRemoteBranches("myapp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if git.fetchAllCalled { + t.Error("ListRemoteBranches must not fetch") + } + if len(got) != 1 { + t.Errorf("branches = %+v", got) + } +} + func TestService_NewFrom_notCloned(t *testing.T) { svc, _ := makeService(t, &mockGit{}, &mockTmuxRunner{}) _, err := svc.NewFrom("myapp", "feature", "main") @@ -311,8 +575,8 @@ func TestService_NewFrom_unknownProject(t *testing.T) { func TestService_New_bothAddsFail(t *testing.T) { git := &mockGit{ - addErr: fmt.Errorf("invalid reference"), - addNewErr: fmt.Errorf("already exists"), + addErr: fmt.Errorf("invalid reference"), + addNewFromErr: fmt.Errorf("already exists"), } svc, tmpDir := makeService(t, git, &mockTmuxRunner{}) if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { @@ -321,7 +585,7 @@ func TestService_New_bothAddsFail(t *testing.T) { _, err := svc.New("myapp", "new-feature") if err == nil { - t.Fatal("expected error when both Add and AddNewBranch fail") + t.Fatal("expected error when both Add and AddNewBranchFrom fail") } } From 9f389079ced3c815b805acf5fea5c795d8e51d05 Mon Sep 17 00:00:00 2001 From: Francisco Rodrigues Date: Sat, 27 Jun 2026 23:09:09 -0300 Subject: [PATCH 2/2] feat: keep tmux sessions visible when a worktree's HEAD diverges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI dashboard and `ch list worktree` correlated each worktree to its tmux session by the worktree's live git branch. That key is wrong the moment HEAD moves off the branch the worktree was created for — a rebase in progress leaves a detached HEAD, and checking out another branch swaps the name entirely. In both cases the live branch no longer matches the session's frozen canonical name, so a running agent silently disappears from the dashboard and the worktree listing, exactly when the user most needs to find it. Correlation now keys off a stable identity branch rather than the live one. For worktrees under `__worktrees/` that identity is the folder base name, which equals the flattened branch the worktree was created for; for the main clone dir it is the configured default branch, falling back to the live branch when none is set. Feeding this identity into SessionName reproduces the session's original name exactly, so the fix works retroactively for sessions that already exist. The list still shows where HEAD actually points. A worktree whose HEAD diverged is annotated with a live-state hint — `detached` mid-rebase, or `on ` when a different branch is checked out — parsed from the `detached` line of `git worktree list --porcelain`. To display the real branch the session belongs to rather than a flattened folder name, a new `@codeherd_branch` tmux option records the exact pretty branch at session start and is read back during listing. Pre-upgrade sessions lack that option, so display falls back to the configured default (clone dir) and then the folder name. --- CLAUDE.md | 1 + cmd/session_integration_test.go | 2 +- ...ree-head-divergence-session-correlation.md | 905 ++++++++++++++++++ ...d-divergence-session-correlation-design.md | 185 ++++ internal/semconv/semconv.go | 19 + internal/semconv/semconv_test.go | 26 + internal/session/session.go | 1 + internal/session/session_test.go | 46 +- internal/tmux/client.go | 8 +- internal/tmux/client_test.go | 31 + internal/tui/delegate.go | 5 +- internal/tui/delegate_test.go | 19 + internal/tui/items.go | 58 +- internal/tui/items_test.go | 121 ++- internal/tui/model.go | 17 +- internal/worktree/worktree.go | 30 +- internal/worktree/worktree_test.go | 44 + 17 files changed, 1475 insertions(+), 43 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-27-worktree-head-divergence-session-correlation.md create mode 100644 docs/superpowers/specs/2026-06-27-worktree-head-divergence-session-correlation-design.md diff --git a/CLAUDE.md b/CLAUDE.md index de927ba..d97764e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,5 +92,6 @@ All commands run locally — they execute git/tmux/filesystem directly on whatev - **`syscall.Exec` for interactive commands**: `attach session`, `create worktree --attach`, and related commands replace the process rather than spawning a child - **Local execution**: all session/project/worktree commands run git/tmux via `os/exec` on the local machine — no SSH indirection - **Integration tests**: tagged with `//go:build integration` and run separately via `make test-integration` +- **Pin the git default branch in test repos**: helpers that `git init` a repo for worktree/session tests must pass `-b main` (e.g. `git init -b main `). Plain `git init` takes the branch name from the host's `init.defaultBranch`, which is unset on CI runners and defaults to `master`. Code that references `main` as a start point (e.g. `worktree.New`'s fallback) then fails with `fatal: invalid reference: main` on CI while passing on a developer machine configured with `init.defaultBranch=main`. - **Tmux isolation in tests**: tests that touch a real tmux server must isolate it. Set `CODEHERD_TMUX_SOCKET` to a path under `t.TempDir()` (the `internal/tmux` `RealRunner` reads this env var and prepends `-S ` to every tmux call), also clear `$TMUX` so tmux does not think it is nested, and probe with a throwaway `tmux -S new-session` — `t.Skip` if it fails so sandboxed CI environments do not flake. Cleanup must `tmux -S kill-server`. The `cmd_test` package exposes `useIsolatedTmux(t)` + `tmuxCmd(t, args...)` helpers; tests in `package cmd` inline the same setup. Never call bare `exec.Command("tmux", …)` from a test — it will leak into the developer's outer tmux server and collide with parallel test runs. diff --git a/cmd/session_integration_test.go b/cmd/session_integration_test.go index e45051e..0ffda67 100644 --- a/cmd/session_integration_test.go +++ b/cmd/session_integration_test.go @@ -37,7 +37,7 @@ func initBareRepo(t *testing.T, cloneDir string) { t.Helper() os.MkdirAll(cloneDir, 0o755) cmds := [][]string{ - {"git", "init", cloneDir}, + {"git", "init", "-b", "main", cloneDir}, {"git", "-C", cloneDir, "config", "user.email", "test@test.com"}, {"git", "-C", cloneDir, "config", "user.name", "Test"}, {"git", "-C", cloneDir, "commit", "--allow-empty", "-m", "init"}, diff --git a/docs/superpowers/plans/2026-06-27-worktree-head-divergence-session-correlation.md b/docs/superpowers/plans/2026-06-27-worktree-head-divergence-session-correlation.md new file mode 100644 index 0000000..4cbbe79 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-worktree-head-divergence-session-correlation.md @@ -0,0 +1,905 @@ +# Worktree HEAD-Divergence Session Correlation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep a codeherd worktree's tmux session visible in the TUI (and `ch list worktree`) when its HEAD diverges from the branch it was created for — a rebase in progress (detached HEAD) or a different branch checked out. + +**Architecture:** Correlate worktrees to sessions by a *stable identity branch* instead of the live git branch. For `__worktrees/` directories that identity is the folder base name (which equals the flattened branch); for the main clone dir it is `config.DefaultBranch`. This reproduces the session's frozen `@codeherd_canonical_name` exactly and retroactively. Display the identity branch plus a live-state hint (`detached` / `on `), using a new `@codeherd_branch` tmux option for the exact pretty name with a folder/config fallback. + +**Tech Stack:** Go, tmux (via `internal/tmux` typed wrapper), Bubble Tea v2 TUI. + +## Global Constraints + +- Aggregate test coverage must stay ≥ 80%; `make check` (coverage → integration → lint → build) must pass before completion. +- Every closed-set string already has a typed Go const; reuse existing `semconv` consts, do not introduce raw string literals for tmux options. +- TDD: write the failing test first, watch it fail, implement minimally, watch it pass, commit. +- `FlattenBranch` (slash → dash) is idempotent; rely on that rather than reversing it. +- Run a single package's tests with `go test ./internal//...`. + +--- + +### Task 1: `semconv` — identity-branch helper and `@codeherd_branch` const + +**Files:** +- Modify: `internal/semconv/semconv.go` (const block at lines 8-27; helpers near `FlattenBranch` at line 59) +- Test: `internal/semconv/semconv_test.go` + +**Interfaces:** +- Produces: `semconv.TmuxOptionBranch = "@codeherd_branch"` (string const) +- Produces: `semconv.WorktreeIdentityBranch(path, cloneDir, defaultBranch, liveBranch string) string` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/semconv/semconv_test.go`: + +```go +func TestWorktreeIdentityBranch(t *testing.T) { + tests := []struct { + name, path, cloneDir, defaultBranch, liveBranch, want string + }{ + {"worktree dir uses folder name", "/p/github.com/u/app__worktrees/feature-x", "/p/github.com/u/app", "main", "", "feature-x"}, + {"worktree dir ignores live branch", "/p/github.com/u/app__worktrees/feature-x", "/p/github.com/u/app", "main", "other", "feature-x"}, + {"clone dir uses default branch", "/p/github.com/u/app", "/p/github.com/u/app", "main", "", "main"}, + {"clone dir falls back to live branch when default unset", "/p/github.com/u/app", "/p/github.com/u/app", "", "develop", "develop"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := semconv.WorktreeIdentityBranch(tc.path, tc.cloneDir, tc.defaultBranch, tc.liveBranch) + if got != tc.want { + t.Errorf("WorktreeIdentityBranch(%q,%q,%q,%q) = %q, want %q", + tc.path, tc.cloneDir, tc.defaultBranch, tc.liveBranch, got, tc.want) + } + }) + } +} + +func TestTmuxOptionBranch_constant(t *testing.T) { + if semconv.TmuxOptionBranch != "@codeherd_branch" { + t.Errorf("TmuxOptionBranch = %q, want @codeherd_branch", semconv.TmuxOptionBranch) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/semconv/...` +Expected: FAIL — `undefined: semconv.WorktreeIdentityBranch` and `semconv.TmuxOptionBranch`. + +- [ ] **Step 3: Write minimal implementation** + +In `internal/semconv/semconv.go`, add the const inside the existing `const (...)` block (after `TmuxOptionProfile`): + +```go + TmuxOptionProfile = "@codeherd_profile" + TmuxOptionBranch = "@codeherd_branch" +``` + +Then add the helper just below `FlattenBranch` (after line 61). `path/filepath` is already imported: + +```go +// WorktreeIdentityBranch returns the stable identity branch for a worktree — +// the branch the worktree was created for, regardless of where HEAD points now. +// Feeding it into SessionName recovers the session's frozen canonical name. +// +// Worktrees under "__worktrees/" are named FlattenBranch(branch), so the +// directory base name is the (flattened) identity. The main clone dir is named +// after the repo rather than a branch, so its identity is the configured default +// branch, falling back to the live branch when no default is configured. +func WorktreeIdentityBranch(path, cloneDir, defaultBranch, liveBranch string) string { + if path == cloneDir { + if defaultBranch != "" { + return defaultBranch + } + return liveBranch + } + return filepath.Base(path) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/semconv/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/semconv/semconv.go internal/semconv/semconv_test.go +git commit -m "feat(semconv): add WorktreeIdentityBranch helper and @codeherd_branch option" +``` + +--- + +### Task 2: `worktree` — parse detached HEAD and correlate by identity branch + +**Files:** +- Modify: `internal/worktree/worktree.go` (`WorktreeInfo` lines 28-32; `ListEntry` lines 34-40; `parseWorktreePorcelain` lines 223-246; `Service.List` loop lines 585-599) +- Test: `internal/worktree/worktree_test.go` + +**Interfaces:** +- Consumes: `semconv.WorktreeIdentityBranch` (Task 1) +- Produces: `WorktreeInfo.Detached bool`, `ListEntry.Detached bool` + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/worktree/worktree_test.go`: + +```go +func TestParseWorktreePorcelain_detachedFlag(t *testing.T) { + input := "worktree /p/myapp__worktrees/detached\nHEAD ghi789\ndetached\n\n" + got := parseWorktreePorcelain(input) + if len(got) != 1 { + t.Fatalf("expected 1 entry, got %d", len(got)) + } + if !got[0].Detached { + t.Errorf("expected Detached=true for detached HEAD entry") + } + if got[0].Branch != "" { + t.Errorf("expected empty Branch for detached HEAD, got %q", got[0].Branch) + } +} + +func TestService_List_cloneDirDetachedUsesDefaultBranch(t *testing.T) { + // The clone-dir worktree is detached (e.g. mid-rebase): no live branch. + // Correlation must fall back to config DefaultBranch ("main") so the + // session is still found and Detached is surfaced. + git := &mockGit{ + listResult: []WorktreeInfo{ + {Path: "", Branch: "", Detached: true}, // Path filled in below + }, + } + svc, tmpDir := makeService(t, git, &mockTmuxRunner{exitCode: 0}) // exit 0 = session exists + git.listResult[0].Path = cloneDirPath(tmpDir) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + + entries, err := svc.List("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Session == "" { + t.Errorf("expected session populated via DefaultBranch identity, got empty") + } + if !entries[0].Detached { + t.Errorf("expected Detached=true on entry") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/worktree/...` +Expected: FAIL — `WorktreeInfo` has no field `Detached`; `ListEntry` has no field `Detached`. + +- [ ] **Step 3: Implement the struct fields and parsing** + +In `internal/worktree/worktree.go`, extend `WorktreeInfo` (lines 28-32): + +```go +// WorktreeInfo holds data from a single git worktree entry. +type WorktreeInfo struct { + Path string + Branch string // empty if detached HEAD + Detached bool // true when HEAD is detached (e.g. rebase in progress) +} +``` + +Extend `ListEntry` (lines 34-40): + +```go +// ListEntry is one row in the worktree list output. +type ListEntry struct { + Project string + Branch string + Path string + Session string // "- (running)" or "" + Detached bool // true when the worktree's HEAD is detached +} +``` + +In `parseWorktreePorcelain`, add a case for the `detached` line (inside the `switch` at lines 229-240, alongside the `branch ` case): + +```go + case strings.HasPrefix(line, "branch "): + ref := strings.TrimPrefix(line, "branch ") + current.Branch = strings.TrimPrefix(ref, "refs/heads/") + case line == "detached": + current.Detached = true +``` + +- [ ] **Step 4: Implement the `Service.List` identity correlation** + +Replace the loop body in `Service.List` (lines 585-599) with: + +```go + for _, wt := range worktrees { + identity := semconv.WorktreeIdentityBranch(wt.Path, cd, p.DefaultBranch, wt.Branch) + session := "" + if identity != "" { + candidate := semconv.SessionName("", name, identity) + if running, _ := s.tmux.HasSession(candidate); running { + session = candidate + " (running)" + } + } + entries = append(entries, ListEntry{ + Project: name, + Branch: wt.Branch, + Path: wt.Path, + Session: session, + Detached: wt.Detached, + }) + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/worktree/...` +Expected: PASS (new tests pass; `TestParseWorktreePorcelain`, `TestService_List_withRunningSession`, and the other existing List tests still pass — for `__worktrees/` paths the identity equals the branch, so behavior is unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add internal/worktree/worktree.go internal/worktree/worktree_test.go +git commit -m "feat(worktree): track detached HEAD and correlate sessions by identity branch" +``` + +--- + +### Task 3: `tmux` — expose the raw branch from `ListSessions` + +**Files:** +- Modify: `internal/tmux/client.go` (`SessionRecord` lines 9-19; `ListSessions` lines 227-260) +- Test: `internal/tmux/client_test.go` + +**Interfaces:** +- Produces: `tmux.SessionRecord.Branch string` (populated from `@codeherd_branch`, `""` when the option is unset) + +- [ ] **Step 1: Write the failing test** + +Add to `internal/tmux/client_test.go`: + +```go +func TestClient_ListSessions_readsBranchOption(t *testing.T) { + // 9-field line: branch populated as the final field. + line := "$1\tmyapp-feat\tmyapp-feat\tagent\trunning\t\t2026-01-01T00:00:00Z\twork\tfeature/login\n" + r := &mockRunner{exitCode: 0, stdout: line} + c := tmux.NewClient(r) + records, err := c.ListSessions() + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(records) != 1 { + t.Fatalf("len(records) = %d, want 1", len(records)) + } + if records[0].Branch != "feature/login" { + t.Errorf("Branch = %q, want feature/login", records[0].Branch) + } +} + +func TestClient_ListSessions_missingBranchIsEmpty(t *testing.T) { + // Old 8-field line (pre-upgrade session): Branch must default to "". + line := "$1\tmyapp-feat\tmyapp-feat\tagent\trunning\t\t2026-01-01T00:00:00Z\twork\n" + r := &mockRunner{exitCode: 0, stdout: line} + c := tmux.NewClient(r) + records, err := c.ListSessions() + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if records[0].Branch != "" { + t.Errorf("Branch = %q, want empty", records[0].Branch) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tmux/...` +Expected: FAIL — `records[0].Branch` undefined (`SessionRecord` has no `Branch` field). + +- [ ] **Step 3: Implement the field and parsing** + +In `internal/tmux/client.go`, add to `SessionRecord` (after `Profile`, line 18): + +```go + Profile string // @codeherd_profile, "" when unset + Branch string // @codeherd_branch — raw branch the session was created for, "" when unset +} +``` + +In `ListSessions`, append `@codeherd_branch` to the format string (line 228): + +```go + format := "#{session_id}\t#{session_name}\t#{@codeherd_canonical_name}\t#{@codeherd_session_type}\t#{@codeherd_status}\t#{@codeherd_annotation}\t#{@codeherd_started_at}\t#{@codeherd_profile}\t#{@codeherd_branch}" +``` + +Change the split width from 8 to 9 (lines 244-247): + +```go + fields := strings.SplitN(line, "\t", 9) + for len(fields) < 9 { + fields = append(fields, "") + } +``` + +Add `Branch` to the appended record (after `Profile`, line 256): + +```go + Profile: fields[7], + Branch: fields[8], + }) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tmux/...` +Expected: PASS (new tests pass; existing `TestClient_ListSessions_ok` and the profile tests still pass — their 8-field lines parse with `Branch == ""`). + +- [ ] **Step 5: Commit** + +```bash +git add internal/tmux/client.go internal/tmux/client_test.go +git commit -m "feat(tmux): surface @codeherd_branch in SessionRecord" +``` + +--- + +### Task 4: `session` — stamp `@codeherd_branch` at session start + +**Files:** +- Modify: `internal/session/session.go` (`SetOption` block lines 144-150) +- Test: `internal/session/session_test.go` (`TestStart_OK` at lines 96-125) + +**Interfaces:** +- Consumes: `semconv.TmuxOptionBranch` (Task 1) +- Produces: a `set-option @codeherd_branch ` tmux call during `Start` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/session/session_test.go`: + +```go +func TestStart_StampsBranchOption(t *testing.T) { + r := &mockRunnerSequence{responses: []mockResponse{ + {exitCode: 1}, // list-sessions → empty + {exitCode: 0, stdout: "$1\n"}, // new-session → ok + {exitCode: 0}, // set-option status + {exitCode: 0}, // set-option started_at + {exitCode: 0}, // set-option canonical_name + {exitCode: 0}, // set-option session_type + {exitCode: 0}, // set-option branch + }} + tc := tmux.NewClient(r) + svc := session.NewService(tc, &mockHook{}) + + if _, err := svc.Start(session.StartRequest{ + Project: "myapp", + Branch: "feature/login", + Path: t.TempDir(), + Cmd: "claude", + }); err != nil { + t.Fatalf("Start() error = %v", err) + } + + found := false + for _, call := range r.calls { + // SetOption runs: ("set-option", "-t", ,