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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions cmd/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,7 @@ func (c *ListWorktreeCmd) Run(cmd *cobra.Command, args []string) error {
if ws.Agent != nil {
sess = ws.Agent.Ref.CanonicalName() + " (running)"
}
branch := ws.DisplayBranch
if branch == "" {
branch = "(detached)"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ws.Ref.Project, branch, ws.Path, sess)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ws.Ref.Project, ws.BranchLabel(), ws.Path, sess)
}
if err := w.Flush(); err != nil {
return fmt.Errorf("flushing output: %w", err)
Expand Down
129 changes: 129 additions & 0 deletions cmd/worktree_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//go:build integration

package cmd_test

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// writeListWorktreeConfig writes a config with default_branch set so the main
// clone dir has a stable identity ("main") to fall back to when its HEAD is on
// another branch.
func writeListWorktreeConfig(t *testing.T, projectsDir string) string {
t.Helper()
cfgDir := t.TempDir()
cfgPath := filepath.Join(cfgDir, "config.toml")
content := `[defaults]
projects_dir = "` + projectsDir + `"

[projects.myapp]
repo = "git@github.com:user/myapp.git"
default_branch = "main"
`
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return cfgPath
}

func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
full := append([]string{"-C", dir}, args...)
if out, err := exec.Command("git", full...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}

// TestListWorktree_noSession_showsLiveBranch is the end-to-end guard for the
// geomonitor bug over the primary path: worktrees with NO session. It drives
// the real `ch list worktree` over a real git repo, covering the whole flow the
// TUI shares (h.List -> workspaceFrom -> resolveDisplay -> FormatBranchLabel).
//
// The main clone dir, whose slot is the configured default branch, stays
// labelled "main" with the checkout as a hint. Every other worktree shows git's
// live branch verbatim — the folder name never surfaces, and no divergence hint
// is invented without a session to prove one.
func TestListWorktree_noSession_showsLiveBranch(t *testing.T) {
// list worktree reaches tmux via h.Sessions(); isolate so it neither reads
// nor leaks into the developer's server.
useIsolatedTmux(t)

projectsDir := t.TempDir()
cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp")
initBareRepo(t, cloneDir) // main branch + one commit

// The main clone dir has a non-default branch checked out.
gitRun(t, cloneDir, "checkout", "-b", "docs/rbac-epic")

// A worktree whose directory name does not match its branch (geomonitor's
// chore-cron-rework), and a normal one.
mismatch := filepath.Join(cloneDir+"__worktrees", "chore-cron-rework")
gitRun(t, cloneDir, "worktree", "add", "-b", "chore/restore-cron-rework", mismatch, "main")
normal := filepath.Join(cloneDir+"__worktrees", "feat")
gitRun(t, cloneDir, "worktree", "add", "-b", "feat", normal, "main")

cfgPath := writeListWorktreeConfig(t, projectsDir)

out := captureStdout(t, func() {
if err := runCmd(t, "--config", cfgPath, "list", "worktree"); err != nil {
t.Fatalf("list worktree: %v", err)
}
})

// Main clone dir: slot is "main" (config), HEAD on docs/rbac-epic.
if !strings.Contains(out, "main (on docs/rbac-epic)") {
t.Errorf("main worktree should read %q, got:\n%s", "main (on docs/rbac-epic)", out)
}
// Mismatched worktree, no session: git's live branch, clean.
if !strings.Contains(out, "chore/restore-cron-rework") {
t.Errorf("mismatched worktree should show its live branch, got:\n%s", out)
}
// Normal worktree: its branch, clean.
if !strings.Contains(out, "feat") {
t.Errorf("normal worktree should show %q, got:\n%s", "feat", out)
}
// The folder name must never surface, and no hint may be invented.
if strings.Contains(out, "chore-cron-rework (") || strings.Contains(out, "(on chore/restore-cron-rework)") {
t.Errorf("non-session worktree must show the live branch without a hint, got:\n%s", out)
}
if strings.Contains(out, "docs/rbac-epic (on docs/rbac-epic)") {
t.Errorf("head hint restated the branch, got:\n%s", out)
}
}

// TestListWorktree_sessionDivergence_showsRecordedBranch covers the refinement:
// when a running session records the branch the worktree is for, and HEAD has
// since moved off it, the row shows the recorded branch with the live checkout
// as a hint. A shell session persists in tmux and stamps @codeherd_branch.
func TestListWorktree_sessionDivergence_showsRecordedBranch(t *testing.T) {
useIsolatedTmux(t)

projectsDir := t.TempDir()
cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp")
initBareRepo(t, cloneDir)

cfgPath := writeListWorktreeConfig(t, projectsDir)

// Create a worktree + shell session for "feat" (records @codeherd_branch).
if err := runCmd(t, "--config", cfgPath, "create", "session", "myapp", "feat", "--shell"); err != nil {
t.Fatalf("create shell session: %v", err)
}

// Move HEAD off "feat" inside that worktree.
wtPath := filepath.Join(cloneDir+"__worktrees", "feat")
gitRun(t, wtPath, "checkout", "-b", "other")

out := captureStdout(t, func() {
if err := runCmd(t, "--config", cfgPath, "list", "worktree"); err != nil {
t.Fatalf("list worktree: %v", err)
}
})

if !strings.Contains(out, "feat (on other)") {
t.Errorf("session-proven divergence should read %q, got:\n%s", "feat (on other)", out)
}
}
98 changes: 86 additions & 12 deletions internal/herd/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,53 @@ type Workspace struct {
Path string
IsMain bool // true for the main clone dir

// DisplayBranch is what a front end should render: the branch HEAD is
// actually on. It is NOT identity and must never be fed back in — that
// round-trip is what orphaned an agent against a deleted worktree.
// DisplayBranch is the branch label a front end should render for this row,
// filled in by resolveDisplay. Normally it is git's live branch; only when
// HEAD has diverged from the branch the worktree is *for* (the default
// branch for the main clone dir; a running session's recorded branch
// otherwise) does it become that original branch, with HeadHint carrying the
// actual checkout. It is NOT a value to feed back in; use Ref for that —
// feeding DisplayBranch or Item.Branch back into an operation orphans an
// agent against a deleted worktree. Render it through FormatBranchLabel so
// the CLI and the TUI agree.
DisplayBranch string

// HeadHint is "detached", "on <branch>", or "" when HEAD agrees with Ref.
// HeadHint is "detached", "on <branch>", or "" when HEAD is on the branch
// the worktree is for. Front ends render it alongside DisplayBranch via
// FormatBranchLabel.
HeadHint string

// Agent and Shell are nil when that session type is not running.
Agent *Handle
Shell *Handle
}

// BranchLabel renders this workspace's branch column via FormatBranchLabel.
func (w Workspace) BranchLabel() string {
return FormatBranchLabel(w.DisplayBranch, w.HeadHint)
}

// FormatBranchLabel renders a worktree's branch column: the display branch,
// plus the head hint in parentheses when HEAD has diverged from the worktree's
// identity. It is the single formatter shared by `ch list worktree` and the TUI
// so both surfaces render a diverged or detached worktree identically. Because
// a diverged workspace already carries its identity in DisplayBranch and the
// live branch in headHint, the two never restate each other — there is no
// "<branch> (on <branch>)" to suppress.
func FormatBranchLabel(displayBranch, headHint string) string {
switch {
case displayBranch == "":
if headHint == "" {
return ""
}
return "(" + headHint + ")"
case headHint == "":
return displayBranch
default:
return displayBranch + " (" + headHint + ")"
}
}

// EnsureOpts configures workspace creation. The zero value creates the
// worktree from the project's default branch and provisions nothing.
type EnsureOpts struct {
Expand Down Expand Up @@ -288,28 +322,68 @@ func (h *Herd) List(project string) ([]Workspace, error) {
ws.Shell = &hd
}
}
// Display is resolved after the join: a non-main worktree's original
// branch lives on its session, so DisplayBranch/HeadHint cannot be
// decided until the sessions are attached.
resolveDisplay(&ws, defaultBranch, wt)
out = append(out, ws)
}
}
return out, nil
}

// workspaceFrom derives identity and display from one git worktree entry.
// workspaceFrom derives a workspace's identity from one git worktree entry.
// Display (DisplayBranch/HeadHint) is left for resolveDisplay, which runs after
// sessions are joined — a non-main worktree's original branch is recorded on its
// session, not in the worktree entry.
func (h *Herd) workspaceFrom(project, cloneDir, defaultBranch string, wt git.WorktreeInfo) Workspace {
identity := semconv.WorktreeIdentityBranch(wt.Path, cloneDir, defaultBranch, wt.Branch)
ws := Workspace{
Ref: h.Ref(project, identity),
Path: wt.Path,
IsMain: wt.Path == cloneDir,
DisplayBranch: wt.Branch,
return Workspace{
Ref: h.Ref(project, identity),
Path: wt.Path,
IsMain: wt.Path == cloneDir,
}
}

// resolveDisplay fills DisplayBranch and HeadHint from the authoritative
// sources, once sessions are joined.
//
// git's wt.Branch is the ground truth of what is checked out. The "original"
// branch a worktree is *for* is the default branch for the main clone dir, or
// the branch a running session recorded (@codeherd_branch) for any other
// worktree. Divergence is HEAD leaving that original — which is why a worktree
// with no known original (a non-main worktree with no session) is never treated
// as diverged: it shows exactly what git reports. The folder-name identity in
// ws.Ref is only an addressing key and must never surface as a display value —
// rendering it is what showed "chore-cron-rework (on chore/restore-cron-rework)"
// for a worktree simply sitting on its own branch.
//
// The common case is a worktree with no session, so it must not depend on one;
// the session only refines a genuine divergence (created for X, now on Y) and
// recovers a branch name for a detached HEAD.
func resolveDisplay(ws *Workspace, defaultBranch string, wt git.WorktreeInfo) {
original := ""
switch {
case ws.IsMain:
original = defaultBranch
case ws.Agent != nil:
original = ws.Agent.Ref.Branch
case ws.Shell != nil:
original = ws.Shell.Ref.Branch
}

switch {
case wt.Detached:
// No live branch to show; recover the original when we have one,
// otherwise the label is just "(detached)".
ws.DisplayBranch = original
ws.HeadHint = "detached"
case wt.Branch != "" && semconv.FlattenBranch(wt.Branch) != semconv.FlattenBranch(identity):
case original != "" && semconv.FlattenBranch(wt.Branch) != semconv.FlattenBranch(original):
ws.DisplayBranch = original
ws.HeadHint = "on " + wt.Branch
default:
ws.DisplayBranch = wt.Branch
}
return ws
}

// Teardown stops a workspace's sessions and deletes its worktree.
Expand Down
Loading
Loading