diff --git a/internal/session/sqlite.go b/internal/session/sqlite.go index 0e828b82..427c49ab 100644 --- a/internal/session/sqlite.go +++ b/internal/session/sqlite.go @@ -33,6 +33,7 @@ type SQLiteStore struct { hasLastMessageCount bool // true if sessions table has last_message_count column hasMessageCount bool // true if sessions table has message_count column hasReasoningEffort bool // true if sessions table has reasoning_effort column + hasWorktreeDir bool // true if sessions table has worktree_dir column } // Schema for the sessions database. @@ -56,6 +57,7 @@ CREATE TABLE IF NOT EXISTS sessions ( origin TEXT DEFAULT 'tui', agent TEXT, cwd TEXT, + worktree_dir TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_message_at TIMESTAMP, @@ -218,7 +220,7 @@ func NewSQLiteStore(cfg Config) (*SQLiteStore, error) { // - Fresh databases get the full schema from `schema` const and start at this version // - Existing databases run migrations to reach this version // Increment when adding new migrations. -const schemaVersion = 25 +const schemaVersion = 26 // migration represents a schema migration. type migration struct { @@ -766,6 +768,18 @@ var migrations = []migration{ return nil }, }, + { + // Migration 26: bind a session to a git worktree directory + version: 26, + description: "add worktree_dir column", + up: func(db *sql.DB) error { + _, err := db.Exec("ALTER TABLE sessions ADD COLUMN worktree_dir TEXT") + if err != nil && !isDuplicateColumnError(err) { + return fmt.Errorf("add worktree_dir column: %w", err) + } + return nil + }, + }, } func createMessageCountTriggers(db *sql.DB) error { @@ -1021,6 +1035,14 @@ func (s *SQLiteStore) Create(ctx context.Context, sess *Session) error { reasoningEffortPlaceholder = ", ?" reasoningEffortArgs = []any{nullString(sess.ReasoningEffort)} } + worktreeDirCol := "" + worktreeDirPlaceholder := "" + var worktreeDirArgs []any + if s.hasWorktreeDir { + worktreeDirCol = ", worktree_dir" + worktreeDirPlaceholder = ", ?" + worktreeDirArgs = []any{nullString(sess.WorktreeDir)} + } insertArgs := []any{ sess.ID, sess.Name, sess.Summary, nullString(sess.GeneratedShortTitle), nullString(sess.GeneratedLongTitle), nullString(string(sess.TitleSource)), nullTime(sess.TitleGeneratedAt), sess.TitleBasisMsgSeq, nullTime(sess.TitleSkippedAt), sess.Provider, nullString(sess.ProviderKey), sess.Model, string(sess.Mode), nullString(string(sess.Origin)), nullString(sess.Agent), sess.CWD, @@ -1030,12 +1052,13 @@ func (s *SQLiteStore) Create(ctx context.Context, sess *Session) error { sess.LastTotalTokens, sess.LastMessageCount, string(sess.Status), nullString(sess.Tags), } insertArgs = append(insertArgs, reasoningEffortArgs...) + insertArgs = append(insertArgs, worktreeDirArgs...) result, err := s.db.ExecContext(ctx, ` INSERT INTO sessions (id, number, name, summary, generated_short_title, generated_long_title, title_source, title_generated_at, title_basis_msg_seq, title_skipped_at, provider, provider_key, model, mode, origin, agent, cwd, created_at, updated_at, archived, pinned, parent_id, search, tools, mcp, user_turns, llm_turns, tool_calls, input_tokens, cached_input_tokens, cache_write_tokens, output_tokens, - last_total_tokens, last_message_count, status, tags`+reasoningEffortCol+`) - VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM sessions), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?`+reasoningEffortPlaceholder+`)`, + last_total_tokens, last_message_count, status, tags`+reasoningEffortCol+worktreeDirCol+`) + VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM sessions), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?`+reasoningEffortPlaceholder+worktreeDirPlaceholder+`)`, insertArgs...) if err != nil { return fmt.Errorf("insert session: %w", err) @@ -1064,14 +1087,14 @@ func (s *SQLiteStore) Create(ctx context.Context, sess *Session) error { func (s *SQLiteStore) Get(ctx context.Context, id string) (*Session, error) { row := s.db.QueryRowContext(ctx, "SELECT "+s.sessionSelectCols()+" FROM sessions WHERE id = ?", id) - return scanSessionRow(row, s.hasGeneratedTitles, s.hasCacheWriteTokens, s.hasCompactionSeq, s.hasCompactionCount, s.hasTitleSkippedAt, s.hasLastTotalTokens, s.hasLastMessageCount) + return scanSessionRow(row, s.hasGeneratedTitles, s.hasCacheWriteTokens, s.hasCompactionSeq, s.hasCompactionCount, s.hasTitleSkippedAt, s.hasLastTotalTokens, s.hasLastMessageCount, s.hasWorktreeDir) } // GetByNumber retrieves a session by its sequential number. func (s *SQLiteStore) GetByNumber(ctx context.Context, number int64) (*Session, error) { row := s.db.QueryRowContext(ctx, "SELECT "+s.sessionSelectCols()+" FROM sessions WHERE number = ?", number) - return scanSessionRow(row, s.hasGeneratedTitles, s.hasCacheWriteTokens, s.hasCompactionSeq, s.hasCompactionCount, s.hasTitleSkippedAt, s.hasLastTotalTokens, s.hasLastMessageCount) + return scanSessionRow(row, s.hasGeneratedTitles, s.hasCacheWriteTokens, s.hasCompactionSeq, s.hasCompactionCount, s.hasTitleSkippedAt, s.hasLastTotalTokens, s.hasLastMessageCount, s.hasWorktreeDir) } // GetByPrefix retrieves a session by number (with # prefix), exact ID, or by short ID prefix match. @@ -1118,7 +1141,7 @@ func (s *SQLiteStore) GetByPrefix(ctx context.Context, prefix string) (*Session, pattern := ExpandShortID(prefix) row := s.db.QueryRowContext(ctx, "SELECT "+s.sessionSelectCols()+" FROM sessions WHERE id LIKE ? ORDER BY created_at DESC LIMIT 1", pattern) - return scanSessionRow(row, s.hasGeneratedTitles, s.hasCacheWriteTokens, s.hasCompactionSeq, s.hasCompactionCount, s.hasTitleSkippedAt, s.hasLastTotalTokens, s.hasLastMessageCount) + return scanSessionRow(row, s.hasGeneratedTitles, s.hasCacheWriteTokens, s.hasCompactionSeq, s.hasCompactionCount, s.hasTitleSkippedAt, s.hasLastTotalTokens, s.hasLastMessageCount, s.hasWorktreeDir) } // Update modifies an existing session's metadata fields. @@ -1140,10 +1163,14 @@ func (s *SQLiteStore) Update(ctx context.Context, sess *Session) error { if s.hasReasoningEffort { reasoningEffortClause = ", reasoning_effort = ?" } + worktreeDirClause := "" + if s.hasWorktreeDir { + worktreeDirClause = ", worktree_dir = ?" + } query := ` UPDATE sessions SET name = ?, summary = ?, generated_short_title = ?, generated_long_title = ?, title_source = ?, title_generated_at = ?, title_basis_msg_seq = ?` + titleSkippedAtClause + `, - provider = ?, provider_key = ?, model = ?` + reasoningEffortClause + `, mode = ?, origin = ?, agent = ?, cwd = ?, + provider = ?, provider_key = ?, model = ?` + reasoningEffortClause + `, mode = ?, origin = ?, agent = ?, cwd = ?` + worktreeDirClause + `, updated_at = ?, archived = ?, pinned = ?, parent_id = ?, search = ?, tools = ?, mcp = ?, user_turns = ?, status = ?, tags = ? WHERE id = ?` @@ -1162,6 +1189,11 @@ func (s *SQLiteStore) Update(ctx context.Context, sess *Session) error { } args = append(args, string(sess.Mode), nullString(string(sess.Origin)), nullString(sess.Agent), sess.CWD, + ) + if s.hasWorktreeDir { + args = append(args, nullString(sess.WorktreeDir)) + } + args = append(args, sess.UpdatedAt, sess.Archived, sess.Pinned, nullString(sess.ParentID), sess.Search, nullString(sess.Tools), nullString(sess.MCP), sess.UserTurns, string(sess.Status), nullString(sess.Tags), sess.ID, @@ -2164,6 +2196,7 @@ func (s *SQLiteStore) setCurrentSessionColumns() { s.hasLastMessageCount = true s.hasMessageCount = true s.hasReasoningEffort = true + s.hasWorktreeDir = true } // probeSessionColumns checks optional session columns in a single PRAGMA scan. @@ -2213,6 +2246,8 @@ func (s *SQLiteStore) probeSessionColumns() { s.hasMessageCount = true case "reasoning_effort": s.hasReasoningEffort = true + case "worktree_dir": + s.hasWorktreeDir = true } } } @@ -2266,15 +2301,18 @@ func (s *SQLiteStore) sessionSelectCols() string { if s.hasCompactionCount { base += ", compaction_count" } + if s.hasWorktreeDir { + base += ", worktree_dir" + } return base } // scanSessionRow scans a session row into a Session struct. The flags // determine which optional columns are present in the result set. -func scanSessionRow(row *sql.Row, hasGeneratedTitles, hasCacheWriteTokens, hasCompactionSeq, hasCompactionCount, hasTitleSkippedAt, hasLastTotalTokens, hasLastMessageCount bool) (*Session, error) { +func scanSessionRow(row *sql.Row, hasGeneratedTitles, hasCacheWriteTokens, hasCompactionSeq, hasCompactionCount, hasTitleSkippedAt, hasLastTotalTokens, hasLastMessageCount, hasWorktreeDir bool) (*Session, error) { var sess Session var number sql.NullInt64 - var name, summary, cwd sql.NullString + var name, summary, cwd, worktreeDir sql.NullString var generatedShortTitle, generatedLongTitle, titleSource sql.NullString var titleGeneratedAt, titleSkippedAt sql.NullTime var mode, origin, agent, parentID, tools, mcp, status, tags, providerKey, reasoningEffort sql.NullString @@ -2310,6 +2348,9 @@ func scanSessionRow(row *sql.Row, hasGeneratedTitles, hasCacheWriteTokens, hasCo if hasCompactionCount { scanArgs = append(scanArgs, &sess.CompactionCount) } + if hasWorktreeDir { + scanArgs = append(scanArgs, &worktreeDir) + } err := row.Scan(scanArgs...) if err == sql.ErrNoRows { @@ -2352,6 +2393,9 @@ func scanSessionRow(row *sql.Row, hasGeneratedTitles, hasCacheWriteTokens, hasCo if cwd.Valid { sess.CWD = cwd.String } + if worktreeDir.Valid { + sess.WorktreeDir = worktreeDir.String + } if mode.Valid { sess.Mode = SessionMode(mode.String) } diff --git a/internal/session/types.go b/internal/session/types.go index 25b1e939..33d77ffd 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -66,6 +66,7 @@ type Session struct { Origin SessionOrigin `json:"origin,omitempty"` // Session surface/origin (tui, web, telegram) Agent string `json:"agent,omitempty"` // Agent name used for this session CWD string `json:"cwd,omitempty"` // Working directory at session start + WorktreeDir string `json:"worktree_dir,omitempty"` // Bound git worktree dir (absolute); empty = root checkout CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Archived bool `json:"archived,omitempty"` diff --git a/internal/session/worktree_dir_test.go b/internal/session/worktree_dir_test.go new file mode 100644 index 00000000..c72b1e4e --- /dev/null +++ b/internal/session/worktree_dir_test.go @@ -0,0 +1,61 @@ +package session + +import ( + "context" + "testing" +) + +// TestWorktreeDirRoundTrip verifies the worktree_dir column persists through +// Create/Get/Update, including clearing it (rebinding to the root checkout). +func TestWorktreeDirRoundTrip(t *testing.T) { + store, err := NewSQLiteStore(Config{Enabled: true, Path: ":memory:"}) + if err != nil { + t.Fatalf("new store: %v", err) + } + defer store.Close() + ctx := context.Background() + + sess := &Session{ + ID: "wt-test-1", + Provider: "test", + Model: "test-model", + WorktreeDir: "/data/worktrees/abcd/neon-canyon", + } + if err := store.Create(ctx, sess); err != nil { + t.Fatalf("create: %v", err) + } + + got, err := store.Get(ctx, sess.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.WorktreeDir != sess.WorktreeDir { + t.Fatalf("WorktreeDir = %q, want %q", got.WorktreeDir, sess.WorktreeDir) + } + + // Switch the binding to another worktree. + got.WorktreeDir = "/data/worktrees/abcd/quiet-comet" + if err := store.Update(ctx, got); err != nil { + t.Fatalf("update: %v", err) + } + got2, err := store.Get(ctx, sess.ID) + if err != nil { + t.Fatalf("get2: %v", err) + } + if got2.WorktreeDir != "/data/worktrees/abcd/quiet-comet" { + t.Fatalf("after update WorktreeDir = %q", got2.WorktreeDir) + } + + // Clear the binding (rebind to root checkout). + got2.WorktreeDir = "" + if err := store.Update(ctx, got2); err != nil { + t.Fatalf("update clear: %v", err) + } + got3, err := store.Get(ctx, sess.ID) + if err != nil { + t.Fatalf("get3: %v", err) + } + if got3.WorktreeDir != "" { + t.Fatalf("after clear WorktreeDir = %q, want empty", got3.WorktreeDir) + } +} diff --git a/internal/tui/chat/chat.go b/internal/tui/chat/chat.go index bd079653..67f7ddbc 100644 --- a/internal/tui/chat/chat.go +++ b/internal/tui/chat/chat.go @@ -29,6 +29,7 @@ import ( "github.com/samsaffron/term-llm/internal/tui/inspector" sessionsui "github.com/samsaffron/term-llm/internal/tui/sessions" "github.com/samsaffron/term-llm/internal/ui" + "github.com/samsaffron/term-llm/internal/worktree" "golang.org/x/term" ) @@ -331,6 +332,24 @@ type Model struct { footerMessageTone string // "", "muted", "success", "warning", or "error" footerMessageSeq uint64 // monotonically increasing footer message timer token + // Worktree footer indicator cache. worktreeFooterSegment() shells out to git, + // so the status line (rendered every frame) reads this cached string and the + // cache is recomputed at most once per worktreeSegmentTTL. Empty when the + // session is on the root checkout (the common case → zero git calls). + worktreeSegCache string + worktreeSegFetched time.Time + + // Worktree create/remove operation state. worktreeBusy gates concurrent ops + // and keeps the spinner ticking; worktreeOpSeq tags the in-flight operation + // so stale progress/done messages are ignored; worktreeProgress is the live + // status text shown in the footer while busy. + worktreeBusy bool + worktreeProgress string + worktreeOpSeq uint64 + // worktreeDeleteTarget arms the switcher's two-press delete: it holds the ID + // of the row a first "d" selected; a second "d" on the same row confirms. + worktreeDeleteTarget string + attemptInput int attemptOutput int attemptCached int @@ -380,6 +399,24 @@ type ( handoverCancelMsg struct{} handoverRenameDoneMsg struct{ err error } mcpStatusUpdateMsg struct{ update mcp.StatusUpdate } + worktreeProgressMsg struct { + op uint64 + progress <-chan worktree.Progress + message string + done bool + } + worktreeCreateDoneMsg struct { + op uint64 + wt *worktree.Worktree + err error + } + worktreeRemoveDoneMsg struct { + op uint64 + dir string // worktree that was removed + root string // main checkout, resolved before removal, for rebinding + err error + } + worktreeShellDoneMsg struct{ err error } ) const ( @@ -1048,6 +1085,10 @@ func (m *Model) rootContext() context.Context { // Init initializes the model func (m *Model) Init() tea.Cmd { + // Re-enter the bound git worktree for a resumed session (no-op on root or + // when the bound dir is gone, in which case the session falls back to root). + m.RestoreWorktreeBinding() + // Update textarea height for any initial text m.updateTextareaHeight() @@ -1258,7 +1299,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case spinner.TickMsg: - if m.streaming && !m.pausedForExternalUI { + if (m.streaming || m.worktreeBusy) && !m.pausedForExternalUI { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) @@ -1298,6 +1339,72 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshMCPPickerIfOpen() cmds = append(cmds, m.listenForMCPStatusUpdates()) + case worktreeProgressMsg: + // Ignore progress from a superseded operation. + if !m.worktreeBusy || msg.op != m.worktreeOpSeq { + return m, nil + } + if !msg.done { + message := strings.TrimSpace(msg.message) + if message == "" { + message = "working" + } + m.worktreeProgress = "Creating worktree: " + message + cmds = append(cmds, waitForWorktreeProgress(msg.op, msg.progress)) + } + + case worktreeCreateDoneMsg: + if msg.op != m.worktreeOpSeq { + return m, nil + } + m.worktreeBusy = false + m.worktreeProgress = "" + if msg.err != nil { + return m.showFooterError(fmt.Sprintf("Worktree create failed: %v", msg.err)) + } + if msg.wt == nil { + return m.showFooterError("Worktree create failed: no worktree returned") + } + // Bind via the chdir model (single-session TUI); see bindWorktree. + if err := m.bindWorktree(msg.wt.Dir); err != nil { + return m.showFooterError(fmt.Sprintf("Created %s but failed to switch: %v", msg.wt.Name, err)) + } + return m.showFooterSuccess(fmt.Sprintf("Created and switched to worktree %s.", msg.wt.Name)) + + case worktreeRemoveDoneMsg: + if msg.op != m.worktreeOpSeq { + return m, nil + } + m.worktreeBusy = false + m.worktreeProgress = "" + if msg.err != nil { + return m.showFooterError(fmt.Sprintf("Worktree remove failed: %v", msg.err)) + } + // If the removed worktree was the bound one, rebind to the root checkout + // (the chdir model: msg.root was resolved before removal). + if m.sess != nil && pathsEqual(m.sess.WorktreeDir, msg.dir) { + if msg.root != "" { + _ = m.bindRoot(msg.root) + } else { + m.sess.WorktreeDir = "" + if m.store != nil { + _ = m.store.Update(context.Background(), m.sess) + } + m.invalidateWorktreeSegment() + } + return m.showFooterSuccess("Removed worktree; back on the root checkout.") + } + m.invalidateWorktreeSegment() + return m.showFooterSuccess("Worktree removed.") + + case worktreeShellDoneMsg: + // The shell may have changed files; refresh the footer segment. + m.invalidateWorktreeSegment() + if msg.err != nil { + return m.showFooterError(fmt.Sprintf("Worktree shell failed: %v", msg.err)) + } + return m.showFooterMuted("Returned from worktree shell.") + case interruptClassifiedMsg: return m.handleInterruptClassified(msg) diff --git a/internal/tui/chat/commands.go b/internal/tui/chat/commands.go index 4ad5e185..9f92cfe6 100644 --- a/internal/tui/chat/commands.go +++ b/internal/tui/chat/commands.go @@ -168,6 +168,21 @@ func AllCommands() []Command { Description: "Hand conversation to another agent", Usage: "/handover @agent [provider:model]", }, + { + Name: "worktree", + Aliases: []string{"wt"}, + Description: "Run this session in a git worktree (isolated checkout)", + Usage: "/worktree [new|list|pwd|diff|promote|rm|shell|]", + Subcommands: []Subcommand{ + {Name: "new", Description: "Create a worktree and switch to it"}, + {Name: "list", Description: "List worktrees in this repo"}, + {Name: "pwd", Description: "Show + copy the bound worktree path"}, + {Name: "diff", Description: "Show the worktree diff vs base"}, + {Name: "promote", Description: "Promote detached HEAD to a branch"}, + {Name: "rm", Description: "Remove the bound worktree"}, + {Name: "shell", Description: "Open a shell/tmux pane in the worktree"}, + }, + }, } } @@ -410,6 +425,8 @@ func (m *Model) ExecuteCommand(input string) (tea.Model, tea.Cmd) { return m.cmdReload() case "handover": return m.cmdHandover(args) + case "worktree": + return m.cmdWorktree(args) default: return m.showSystemMessage(fmt.Sprintf("Command /%s is not yet implemented.", cmd.Name)) } diff --git a/internal/tui/chat/dialog.go b/internal/tui/chat/dialog.go index dc00899f..f0dd8e49 100644 --- a/internal/tui/chat/dialog.go +++ b/internal/tui/chat/dialog.go @@ -24,6 +24,7 @@ const ( DialogDirApproval DialogMCPPicker DialogContent + DialogWorktreePicker ) // DialogModel handles modal dialogs @@ -42,6 +43,9 @@ type DialogModel struct { dirApprovalPath string dirApprovalOptions []string + // Worktree picker specific + worktreeFooter string + // Static content modal specific contentLines []string contentScroll int @@ -94,6 +98,7 @@ func (d *DialogModel) Close() { d.contentLines = nil d.contentScroll = 0 d.contentFooter = "" + d.worktreeFooter = "" } // ShowModelPicker opens the model picker dialog. @@ -232,6 +237,27 @@ func (d *DialogModel) ShowMCPPicker(mcpManager *mcp.Manager) { d.filtered = d.items } +// ShowWorktreePicker opens the worktree switcher dialog. items is the synthetic +// root row, one row per worktree, and a trailing "+ new worktree…" row; currentID +// marks the bound row (its ID, or "__root__" on the root checkout). +func (d *DialogModel) ShowWorktreePicker(items []DialogItem, currentID string) { + d.dialogType = DialogWorktreePicker + d.title = "Worktrees" + d.cursor = 0 + d.query = "" + d.items = nil + d.filtered = nil + d.worktreeFooter = "enter switch · n new · d delete (×2) · s shell · q close" + for _, item := range items { + item.Selected = item.ID == currentID + d.items = append(d.items, item) + if item.Selected { + d.cursor = len(d.items) - 1 + } + } + d.filtered = d.items +} + // ShowContent opens a scrollable static content modal. func (d *DialogModel) ShowContent(title, content string) { d.dialogType = DialogContent @@ -411,6 +437,10 @@ func (d *DialogModel) View() string { return d.viewContentDialog() } + if d.dialogType == DialogWorktreePicker { + return d.viewWorktreePicker() + } + // Original style for other dialogs (dir approval, session list) return d.viewStandardDialog() } @@ -620,6 +650,76 @@ func (d *DialogModel) viewStandardDialog() string { return borderStyle.Render(b.String()) } +// viewWorktreePicker renders the worktree switcher: a status dot (colored by +// lifecycle), a filled/hollow marker for the bound row, the name, and a meta +// column. The footer lists the available keys. +func (d *DialogModel) viewWorktreePicker() string { + theme := d.styles.Theme() + width := d.width - 4 + if width <= 0 || width > 86 { + width = 86 + } + if width < 48 { + width = 48 + } + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.Border). + Padding(1, 2). + Width(width) + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.Primary).MarginBottom(1) + selectedStyle := lipgloss.NewStyle().Background(theme.Primary).Foreground(lipgloss.Color("0")) + mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted) + successStyle := lipgloss.NewStyle().Foreground(theme.Success) + warningStyle := lipgloss.NewStyle().Foreground(theme.Warning) + errorStyle := lipgloss.NewStyle().Foreground(theme.Error) + + var b strings.Builder + b.WriteString(titleStyle.Render(d.title)) + b.WriteString("\n") + items := d.filtered + if len(items) == 0 { + b.WriteString(mutedStyle.Render("No worktrees found.")) + b.WriteString("\n\n") + b.WriteString(mutedStyle.Render(d.worktreeFooter)) + return borderStyle.Render(b.String()) + } + maxItems := 12 + startIdx, endIdx := ui.VisibleRange(len(items), d.cursor, maxItems) + for i, item := range items[startIdx:endIdx] { + actualIdx := startIdx + i + cursor := " " + if actualIdx == d.cursor { + cursor = "❯ " + } + statusIcon := successStyle.Render("●") + switch item.Category { + case "creating": + statusIcon = warningStyle.Render("◐") + case "failed": + statusIcon = errorStyle.Render("○") + case "new": + statusIcon = mutedStyle.Render("+") + } + filled := "○" + if item.Selected { + filled = "●" + } + line := fmt.Sprintf("%s%s %s %-18s %s", cursor, statusIcon, filled, item.Label, item.Description) + if actualIdx == d.cursor { + b.WriteString(selectedStyle.Render(line)) + } else { + b.WriteString(line) + } + if i < endIdx-startIdx-1 { + b.WriteString("\n") + } + } + b.WriteString("\n\n") + b.WriteString(mutedStyle.Render(d.worktreeFooter)) + return borderStyle.Render(b.String()) +} + // viewMCPPicker renders the MCP server picker dialog func (d *DialogModel) viewMCPPicker() string { theme := d.styles.Theme() diff --git a/internal/tui/chat/handlers.go b/internal/tui/chat/handlers.go index f069abf5..e80020d1 100644 --- a/internal/tui/chat/handlers.go +++ b/internal/tui/chat/handlers.go @@ -362,6 +362,70 @@ func (m *Model) handleKeyMsg(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } + // Worktree switcher: enter switches (routing through the chdir bind + // funcs), n creates, d deletes with a two-press confirm, s drops into a + // shell. Arrow keys disarm any pending delete. + if m.dialog.Type() == DialogWorktreePicker { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "tab"))): + selected := m.dialog.Selected() + if selected == nil { + return m, nil + } + m.worktreeDeleteTarget = "" + m.dialog.Close() + switch selected.ID { + case "__new__": + return m.createWorktree("") + case "__root__": + return m.cmdWorktreeSwitch("root") + default: + // selected.ID is the worktree directory; cmdWorktreeSwitch + // matches by path and binds via the chdir model. + return m.cmdWorktreeSwitch(selected.ID) + } + case key.Matches(msg, key.NewBinding(key.WithKeys("n"))): + m.worktreeDeleteTarget = "" + m.dialog.Close() + return m.createWorktree("") + case key.Matches(msg, key.NewBinding(key.WithKeys("d"))): + selected := m.dialog.Selected() + if selected == nil || selected.ID == "" || selected.ID == "__root__" || selected.ID == "__new__" { + return m.showFooterWarning("Select a worktree to delete.") + } + if m.worktreeDeleteTarget != selected.ID { + m.worktreeDeleteTarget = selected.ID + return m.showFooterWarning("Delete " + selected.Label + "? Press d again.") + } + m.worktreeDeleteTarget = "" + m.dialog.Close() + return m.removeWorktreeDir(selected.ID, false) + case key.Matches(msg, key.NewBinding(key.WithKeys("s"))): + selected := m.dialog.Selected() + if selected == nil || selected.ID == "__new__" { + return m, nil + } + m.worktreeDeleteTarget = "" + m.dialog.Close() + return m.shellFromPicker(selected.ID) + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"))): + m.worktreeDeleteTarget = "" + m.dialog.Close() + return m, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k", "ctrl+p"))): + m.worktreeDeleteTarget = "" + m.dialog.Update(msg) + return m, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j", "ctrl+n"))): + m.worktreeDeleteTarget = "" + m.dialog.Update(msg) + return m, nil + default: + m.dialog.Update(msg) + return m, nil + } + } + // Other dialogs (SessionList, DirApproval) use standard handling switch { case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "tab"))): diff --git a/internal/tui/chat/render.go b/internal/tui/chat/render.go index 9fc259eb..1ac78777 100644 --- a/internal/tui/chat/render.go +++ b/internal/tui/chat/render.go @@ -887,6 +887,13 @@ func (m *Model) renderStatusLine() string { errorStyle := lipgloss.NewStyle().Foreground(theme.Error) warningStyle := lipgloss.NewStyle().Foreground(theme.Warning) + // A running worktree operation takes precedence: show the spinner + live + // progress instead of the (possibly stale) footer message. + if m.worktreeBusy && strings.TrimSpace(m.worktreeProgress) != "" { + text := strings.TrimSpace(strings.TrimSpace(m.spinner.View()) + " " + strings.TrimSpace(m.worktreeProgress)) + return m.wrapFooterLine(mutedStyle.Render(text)) + } + if m.footerMessage != "" { style := mutedStyle switch m.footerMessageTone { @@ -943,6 +950,9 @@ func (m *Model) renderStatusLine() string { if m.fastMode { baseSegments = append(baseSegments, statusSegment{text: successStyle.Render("fast"), priority: 30}) } + if wtSeg := m.cachedWorktreeSegment(); wtSeg != "" { + baseSegments = append(baseSegments, statusSegment{text: successStyle.Render(wtSeg), priority: 45, essential: true}) + } if len(m.files) > 0 { baseSegments = append(baseSegments, statusSegment{text: mutedStyle.Render(fmt.Sprintf("%d file(s)", len(m.files))), priority: 55}) } diff --git a/internal/tui/chat/render_test.go b/internal/tui/chat/render_test.go index bab19f5c..283a2f2a 100644 --- a/internal/tui/chat/render_test.go +++ b/internal/tui/chat/render_test.go @@ -1714,3 +1714,21 @@ func TestChatReasoningAttemptDiscardClearsProvisionalTitle(t *testing.T) { t.Fatalf("discard should clear reasoning buffer/title, title=%q buffer=%q", m.currentReasoningTitle, m.currentReasoning.String()) } } + +// TestRenderStatusLineShowsWorktreeProgress verifies a running worktree op shows +// its live progress in the status line, taking precedence over any footer text. +func TestRenderStatusLineShowsWorktreeProgress(t *testing.T) { + m := newTestChatModel(false) + m.width = 80 + m.worktreeBusy = true + m.worktreeProgress = "Creating worktree: running setup script" + m.footerMessage = "older footer" + + line := ui.StripANSI(m.renderStatusLine()) + if !strings.Contains(line, "Creating worktree: running setup script") { + t.Fatalf("status line = %q, want worktree progress", line) + } + if strings.Contains(line, "older footer") { + t.Fatalf("status line = %q, should not show stale footer while busy", line) + } +} diff --git a/internal/tui/chat/spinner_test.go b/internal/tui/chat/spinner_test.go index 8fb24d44..b64b6f34 100644 --- a/internal/tui/chat/spinner_test.go +++ b/internal/tui/chat/spinner_test.go @@ -46,6 +46,22 @@ func TestChatSpinnerTickIgnoredWhilePausedForExternalUI(t *testing.T) { } } +func TestChatSpinnerTickContinuesWhileWorktreeBusy(t *testing.T) { + m := newTestChatModel(false) + m.worktreeBusy = true + + before := m.spinner.View() + _, cmd := m.Update(spinner.TickMsg{ID: m.spinner.ID()}) + after := m.spinner.View() + + if cmd == nil { + t.Fatal("expected spinner tick to be re-scheduled while a worktree op is busy") + } + if after == before { + t.Fatalf("expected spinner frame to advance while worktree busy, still %q", after) + } +} + func TestChatSpinnerTickContinuesWhileInspectorModeActive(t *testing.T) { m := newTestChatModel(true) m.streaming = true diff --git a/internal/tui/chat/worktree.go b/internal/tui/chat/worktree.go new file mode 100644 index 00000000..db4b8d85 --- /dev/null +++ b/internal/tui/chat/worktree.go @@ -0,0 +1,536 @@ +package chat + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/samsaffron/term-llm/internal/clipboard" + "github.com/samsaffron/term-llm/internal/worktree" +) + +// worktreeBaseDir returns a directory inside the session's repository: the bound +// worktree when set, otherwise the process working directory. Worktree git +// operations resolve the shared repo from any of its worktrees, so this is a +// valid entry point for list/create regardless of the current binding. +func (m *Model) worktreeBaseDir() string { + if m.sess != nil && m.sess.WorktreeDir != "" { + return m.sess.WorktreeDir + } + if cwd, err := os.Getwd(); err == nil { + return cwd + } + if m.sess != nil && m.sess.CWD != "" { + return m.sess.CWD + } + return "." +} + +// boundWorktreeDir returns the session's bound worktree dir, or "". +func (m *Model) boundWorktreeDir() string { + if m.sess == nil { + return "" + } + return m.sess.WorktreeDir +} + +// cmdWorktree dispatches the /worktree (alias /wt) command. +func (m *Model) cmdWorktree(args []string) (tea.Model, tea.Cmd) { + if len(args) == 0 { + return m.showWorktreeSwitcher() + } + + sub := strings.ToLower(args[0]) + rest := args[1:] + + switch sub { + case "list", "ls": + return m.showWorktreeSwitcher() + case "new", "add": + return m.createWorktree(strings.Join(rest, " ")) + case "pwd": + return m.cmdWorktreePwd() + case "diff": + return m.cmdWorktreeDiff() + case "promote": + return m.cmdWorktreePromote(rest) + case "rm", "remove", "delete": + return m.cmdWorktreeRemove(rest) + case "shell", "sh": + return m.cmdWorktreeShell(rest) + case "root": + return m.cmdWorktreeSwitch("root") + default: + // Treat a bare argument as a worktree name to switch to. + return m.cmdWorktreeSwitch(strings.Join(args, " ")) + } +} + +// showWorktreeSwitcher opens the modal switcher: a synthetic "root" row, one row +// per worktree, and a "+ new worktree…" row. Selection is routed through the +// chdir bind funcs (bindRoot / bindWorktree) by the picker key handler. +func (m *Model) showWorktreeSwitcher() (tea.Model, tea.Cmd) { + base := m.worktreeBaseDir() + if !worktree.IsGitRepo(base) { + return m.showFooterWarning("Not in a git repository; /worktree is unavailable here.") + } + rootDir := base + if r, err := worktree.MainRepoRoot(base); err == nil { + rootDir = r + } + bound := m.boundWorktreeDir() + + items := []DialogItem{{ + ID: "__root__", + Label: "root", + Description: "checkout " + elidePath(rootDir, 36), + Selected: bound == "", + Category: "ready", + }} + wts, err := worktree.List(base) + if err != nil { + return m.showFooterWarning(fmt.Sprintf("worktree list failed: %v", err)) + } + for _, wt := range wts { + meta := "worktree" + if wt.DirtyFiles > 0 { + meta += fmt.Sprintf(" ±%d", wt.DirtyFiles) + } + meta += " " + elidePath(wt.Dir, 36) + items = append(items, DialogItem{ + ID: wt.Dir, + Label: wt.Name, + Description: meta, + Selected: pathsEqual(bound, wt.Dir), + Category: string(wt.Status), + }) + } + items = append(items, DialogItem{ID: "__new__", Label: "+ new worktree…", Category: "new"}) + + currentID := "__root__" + if bound != "" { + currentID = bound + } + m.setTextareaValue("") + m.dialog.ShowWorktreePicker(items, currentID) + return m, nil +} + +// createWorktree creates a worktree off HEAD asynchronously, streaming progress +// to the footer spinner. On completion (worktreeCreateDoneMsg) the session is +// bound to it via bindWorktree. Guarded against concurrent ops and streaming. +func (m *Model) createWorktree(name string) (tea.Model, tea.Cmd) { + base := m.worktreeBaseDir() + if !worktree.IsGitRepo(base) { + return m.showFooterWarning("Not in a git repository; cannot create a worktree.") + } + if m.streaming { + return m.showFooterWarning("Cannot create a worktree while streaming. Cancel first (Esc).") + } + if m.worktreeBusy { + return m.showFooterWarning("A worktree operation is already running.") + } + + // Setup-script source for v1: the TERM_LLM_WORKTREE_SETUP env var (run in the + // new worktree after creation — e.g. "npm install", copying gitignored .env). + // Config-file precedence (repo-local → user) is a follow-up. + setupScript := os.Getenv("TERM_LLM_WORKTREE_SETUP") + + m.worktreeBusy = true + m.worktreeOpSeq++ + op := m.worktreeOpSeq + m.worktreeProgress = "Creating worktree…" + progress := make(chan worktree.Progress, 8) + create := func() tea.Msg { + opts := worktree.CreateOptions{Name: name, Base: "HEAD", SetupScript: setupScript, Progress: progress} + wt, err := worktree.Create(context.Background(), base, opts) + close(progress) + return worktreeCreateDoneMsg{op: op, wt: wt, err: err} + } + m.setTextareaValue("") + return m, tea.Batch(create, waitForWorktreeProgress(op, progress), m.spinner.Tick) +} + +// waitForWorktreeProgress blocks on the next progress event from Create and +// turns it into a worktreeProgressMsg, rescheduling itself until the channel is +// closed (signalled as done). +func waitForWorktreeProgress(op uint64, progress <-chan worktree.Progress) tea.Cmd { + if progress == nil { + return nil + } + return func() tea.Msg { + p, ok := <-progress + if !ok { + return worktreeProgressMsg{op: op, progress: progress, done: true} + } + return worktreeProgressMsg{op: op, progress: progress, message: p.Message} + } +} + +// cmdWorktreePwd prints and copies the bound worktree path. +func (m *Model) cmdWorktreePwd() (tea.Model, tea.Cmd) { + dir := m.boundWorktreeDir() + if dir == "" { + m.setTextareaValue("") + return m.showFooterMuted("On the root checkout (no worktree bound).") + } + _ = clipboard.CopyText(dir) + _ = clipboard.CopyTextOSC52(dir) + m.setTextareaValue("") + return m.showSystemMessage(fmt.Sprintf("Worktree path (copied to clipboard):\n\n`%s`", dir)) +} + +// cmdWorktreeDiff renders the worktree diff (vs base) in the scrollable pager. +func (m *Model) cmdWorktreeDiff() (tea.Model, tea.Cmd) { + dir := m.boundWorktreeDir() + if dir == "" { + return m.showFooterWarning("Not bound to a worktree; nothing to diff.") + } + diff, err := worktree.Diff(dir) + if err != nil { + return m.showFooterWarning(fmt.Sprintf("worktree diff failed: %v", err)) + } + m.setTextareaValue("") + if strings.TrimSpace(diff) == "" { + return m.showFooterMuted("No changes in this worktree.") + } + m.dialog.ShowContent("Worktree diff", diff) + return m, nil +} + +// cmdWorktreePromote converts the bound detached worktree to a named branch. +func (m *Model) cmdWorktreePromote(args []string) (tea.Model, tea.Cmd) { + dir := m.boundWorktreeDir() + if dir == "" { + return m.showFooterWarning("Not bound to a worktree; nothing to promote.") + } + branch := strings.TrimSpace(strings.Join(args, " ")) + if branch == "" { + return m.showFooterWarning("Usage: /worktree promote ") + } + if err := worktree.Promote(dir, branch); err != nil { + return m.showFooterWarning(fmt.Sprintf("promote failed: %v", err)) + } + m.setTextareaValue("") + return m.showFooterSuccess(fmt.Sprintf("Promoted worktree to branch %s.", branch)) +} + +// cmdWorktreeRemove removes the bound worktree and rebinds to root. It refuses on +// a dirty worktree unless "force" is given. +func (m *Model) cmdWorktreeRemove(args []string) (tea.Model, tea.Cmd) { + dir := m.boundWorktreeDir() + if dir == "" { + return m.showFooterWarning("Not bound to a worktree; nothing to remove.") + } + force := false + for _, a := range args { + switch strings.ToLower(a) { + case "force", "--force", "-f", "yes": + force = true + } + } + // Resolve the main checkout before removal so we can rebind afterwards. + mainRoot, rootErr := worktree.MainRepoRoot(dir) + if err := worktree.Remove(dir, force); err != nil { + if err == worktree.ErrDirty { + return m.showFooterWarning("Worktree has uncommitted changes. Run `/worktree rm force` to delete anyway.") + } + return m.showFooterWarning(fmt.Sprintf("remove failed: %v", err)) + } + // Rebind the session to the root checkout. + if rootErr == nil { + _ = m.bindRoot(mainRoot) + } else if m.sess != nil { + m.sess.WorktreeDir = "" + if m.store != nil { + _ = m.store.Update(context.Background(), m.sess) + } + } + m.setTextareaValue("") + return m.showFooterSuccess("Removed worktree; back on the root checkout.") +} + +// removeWorktreeDir removes an arbitrary worktree (the switcher's delete action) +// asynchronously, spinning while git runs. The main checkout is resolved before +// removal so the done handler can rebind to root if the removed tree was bound. +func (m *Model) removeWorktreeDir(dir string, force bool) (tea.Model, tea.Cmd) { + if m.worktreeBusy { + return m.showFooterWarning("A worktree operation is already running.") + } + mainRoot, _ := worktree.MainRepoRoot(dir) + m.worktreeBusy = true + m.worktreeOpSeq++ + op := m.worktreeOpSeq + m.worktreeProgress = "Removing worktree…" + remove := func() tea.Msg { + err := worktree.Remove(dir, force) + if errors.Is(err, worktree.ErrDirty) { + err = fmt.Errorf("dirty worktree; commit changes or use /worktree rm force") + } + return worktreeRemoveDoneMsg{op: op, dir: dir, root: mainRoot, err: err} + } + return m, tea.Batch(remove, m.spinner.Tick) +} + +// cmdWorktreeShell drops into a shell in the bound worktree. Without --tmux it +// suspends the TUI and runs $SHELL (cwd = worktree); with --tmux it opens a tmux +// split/new-window. Both paths use tea.ExecProcess so the terminal is released +// and restored cleanly. +func (m *Model) cmdWorktreeShell(args []string) (tea.Model, tea.Cmd) { + dir := m.boundWorktreeDir() + if dir == "" { + return m.showFooterWarning("Not bound to a worktree. Use /worktree new or switch first.") + } + useTmux := false + for _, a := range args { + if a == "--tmux" || a == "tmux" { + useTmux = true + } + } + return m.openWorktreeShell(dir, useTmux) +} + +// openWorktreeShell suspends the TUI and runs an interactive shell with cwd set +// to dir, or — when useTmux is set — opens a tmux split for the worktree. It is +// the shared entry point for /worktree shell and the switcher's "s" key. +func (m *Model) openWorktreeShell(dir string, useTmux bool) (tea.Model, tea.Cmd) { + if strings.TrimSpace(dir) == "" { + return m.showFooterWarning("No worktree directory selected.") + } + if useTmux && strings.TrimSpace(os.Getenv("TMUX")) == "" { + return m.showFooterWarning("/worktree shell --tmux requires an existing tmux session.") + } + m.setTextareaValue("") + cmd := worktreeShellCommand(dir, useTmux) + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return worktreeShellDoneMsg{err: err} }) +} + +// worktreeShellCommand builds the command that opens a command line in dir: a +// tmux split (falling back to a new window) when useTmux is set, otherwise an +// interactive $SHELL with its working directory set to the worktree. +func worktreeShellCommand(dir string, useTmux bool) *exec.Cmd { + if useTmux { + quoted := strconv.Quote(dir) + return exec.Command("sh", "-c", "tmux split-window -c "+quoted+" || tmux new-window -c "+quoted) + } + shell := strings.TrimSpace(os.Getenv("SHELL")) + if shell == "" { + shell = "/bin/sh" + } + cmd := exec.Command(shell) + cmd.Dir = dir + return cmd +} + +// shellFromPicker opens a shell for a switcher row: the main checkout for the +// "root" row, otherwise the worktree's own directory. +func (m *Model) shellFromPicker(id string) (tea.Model, tea.Cmd) { + dir := id + if id == "__root__" { + dir = m.worktreeBaseDir() + if root, err := worktree.MainRepoRoot(dir); err == nil { + dir = root + } + } + return m.openWorktreeShell(dir, false) +} + +// cmdWorktreeSwitch binds the session to an existing worktree by name, or to the +// root checkout when name is "root". +func (m *Model) cmdWorktreeSwitch(name string) (tea.Model, tea.Cmd) { + base := m.worktreeBaseDir() + if !worktree.IsGitRepo(base) { + return m.showFooterWarning("Not in a git repository; /worktree is unavailable here.") + } + name = strings.TrimSpace(name) + if strings.EqualFold(name, "root") { + mainRoot, err := worktree.MainRepoRoot(base) + if err != nil { + return m.showFooterWarning(fmt.Sprintf("could not resolve repo root: %v", err)) + } + if err := m.bindRoot(mainRoot); err != nil { + return m.showFooterWarning(fmt.Sprintf("switch failed: %v", err)) + } + m.setTextareaValue("") + return m.showFooterSuccess("Switched to the root checkout.") + } + + wts, err := worktree.List(base) + if err != nil { + return m.showFooterWarning(fmt.Sprintf("worktree list failed: %v", err)) + } + for _, wt := range wts { + if strings.EqualFold(wt.Name, name) || pathsEqual(wt.Dir, name) { + if err := m.bindWorktree(wt.Dir); err != nil { + return m.showFooterWarning(fmt.Sprintf("switch failed: %v", err)) + } + m.setTextareaValue("") + return m.showFooterSuccess(fmt.Sprintf("Switched to worktree %s.", wt.Name)) + } + } + return m.showFooterWarning(fmt.Sprintf("No worktree named %q. Use /worktree list.", name)) +} + +// bindWorktree binds the session to dir: chdir (single-session TUI), persist the +// binding, and approve the directory for tools. +// +// Design note: §4.2 of the worktree design suggests per-session binding without +// a process chdir, because the web/server path shares one process across many +// sessions. The TUI runs exactly one active session per process, so chdir is the +// simplest correct mechanism here and makes every tool (which falls back to the +// process cwd) operate in the worktree with no per-tool threading. WorktreeDir +// remains the persisted source of truth for a future multi-session surface. +func (m *Model) bindWorktree(dir string) error { + abs, err := filepath.Abs(dir) + if err != nil { + return err + } + if err := os.Chdir(abs); err != nil { + return err + } + if m.approvedDirs != nil { + _ = m.approvedDirs.AddDirectory(abs) + } + if m.sess != nil { + m.sess.WorktreeDir = abs + if m.store != nil { + _ = m.store.Update(context.Background(), m.sess) + } + } + m.invalidateWorktreeSegment() + return nil +} + +// bindRoot rebinds the session to the root checkout (clears the binding). +func (m *Model) bindRoot(root string) error { + abs, err := filepath.Abs(root) + if err != nil { + return err + } + if err := os.Chdir(abs); err != nil { + return err + } + if m.sess != nil { + m.sess.WorktreeDir = "" + if m.store != nil { + _ = m.store.Update(context.Background(), m.sess) + } + } + m.invalidateWorktreeSegment() + return nil +} + +// RestoreWorktreeBinding re-applies a resumed session's worktree binding by +// chdir-ing into it. If the bound directory no longer exists, the session is +// rebound to the root checkout. Safe to call when no session/binding is set. +func (m *Model) RestoreWorktreeBinding() { + if m.sess == nil || m.sess.WorktreeDir == "" { + return + } + dir := m.sess.WorktreeDir + if info, err := os.Stat(dir); err == nil && info.IsDir() && worktree.IsGitRepo(dir) { + _ = os.Chdir(dir) + return + } + // Stale binding: fall back to root. + if root, err := worktree.MainRepoRoot(m.worktreeBaseDir()); err == nil { + _ = m.bindRoot(root) + } else { + m.sess.WorktreeDir = "" + if m.store != nil { + _ = m.store.Update(context.Background(), m.sess) + } + } +} + +// worktreeSegmentTTL bounds how often the footer segment's git status is +// recomputed. The status line renders every frame; this keeps git calls rare. +const worktreeSegmentTTL = 2 * time.Second + +// cachedWorktreeSegment returns the footer segment for the bound worktree, +// recomputing (via git) at most once per worktreeSegmentTTL. Returns "" with no +// git call when the session is on the root checkout. +func (m *Model) cachedWorktreeSegment() string { + if m.boundWorktreeDir() == "" { + m.worktreeSegCache = "" + return "" + } + if m.worktreeSegCache != "" && time.Since(m.worktreeSegFetched) < worktreeSegmentTTL { + return m.worktreeSegCache + } + m.worktreeSegCache = m.worktreeFooterSegment() + m.worktreeSegFetched = time.Now() + return m.worktreeSegCache +} + +// invalidateWorktreeSegment forces the next status-line render to recompute the +// worktree footer segment. Called after a bind change. +func (m *Model) invalidateWorktreeSegment() { + m.worktreeSegCache = "" + m.worktreeSegFetched = time.Time{} +} + +// worktreeFooterSegment returns a compact status segment for the bound worktree, +// or "" when on the root checkout. Example: "⌥ neon-canyon ⎇ detached@a1b2c3 ±3". +func (m *Model) worktreeFooterSegment() string { + dir := m.boundWorktreeDir() + if dir == "" { + return "" + } + wt, err := worktree.Get(dir) + if err != nil { + return "⌥ " + filepath.Base(dir) + } + seg := "⌥ " + wt.Name + if wt.Branch != "" { + seg += " ⎇ " + wt.Branch + } else if wt.HeadSHA != "" { + seg += " ⎇ detached@" + wt.HeadSHA + } + if wt.DirtyFiles > 0 { + seg += fmt.Sprintf(" ±%d", wt.DirtyFiles) + } + return seg +} + +// pathsEqual reports whether two paths resolve to the same location. +func pathsEqual(a, b string) bool { + if a == "" || b == "" { + return a == b + } + ca, err := filepath.Abs(a) + if err != nil { + ca = a + } + if r, err := filepath.EvalSymlinks(ca); err == nil { + ca = r + } + cb, err := filepath.Abs(b) + if err != nil { + cb = b + } + if r, err := filepath.EvalSymlinks(cb); err == nil { + cb = r + } + return filepath.Clean(ca) == filepath.Clean(cb) +} + +// elidePath shortens path to at most maxLen runes, keeping the tail (the part +// that distinguishes worktrees) and prefixing an ellipsis when truncated. +func elidePath(path string, maxLen int) string { + path = strings.TrimSpace(path) + if len(path) <= maxLen { + return path + } + if maxLen <= 1 { + return "…" + } + return "…" + path[len(path)-maxLen+1:] +} diff --git a/internal/tui/chat/worktree_test.go b/internal/tui/chat/worktree_test.go new file mode 100644 index 00000000..a6d39989 --- /dev/null +++ b/internal/tui/chat/worktree_test.go @@ -0,0 +1,490 @@ +package chat + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/samsaffron/term-llm/internal/session" + "github.com/samsaffron/term-llm/internal/worktree" +) + +// --- pure / guard-path tests (no git, no cwd dependence) ------------------ + +func TestWorktreeBaseDirPrefersBinding(t *testing.T) { + m := newCmdTestModel(&mockStore{}) + m.sess = &session.Session{ID: "s1", WorktreeDir: "/tmp/wt-xyz"} + if got := m.worktreeBaseDir(); got != "/tmp/wt-xyz" { + t.Errorf("worktreeBaseDir() = %q, want bound dir", got) + } + if got := m.boundWorktreeDir(); got != "/tmp/wt-xyz" { + t.Errorf("boundWorktreeDir() = %q, want bound dir", got) + } + + m.sess = &session.Session{ID: "s1"} + if got := m.boundWorktreeDir(); got != "" { + t.Errorf("boundWorktreeDir() = %q, want empty on root", got) + } + + m.sess = nil + if got := m.boundWorktreeDir(); got != "" { + t.Errorf("boundWorktreeDir() with nil session = %q, want empty", got) + } +} + +func TestWorktreeCommandsGuardWhenUnbound(t *testing.T) { + cases := []struct { + name string + call func(m *Model) (interface{}, error) + want string + }{ + {"pwd", func(m *Model) (interface{}, error) { r, _ := m.cmdWorktreePwd(); return r, nil }, "root checkout"}, + {"diff", func(m *Model) (interface{}, error) { r, _ := m.cmdWorktreeDiff(); return r, nil }, "Not bound"}, + {"promote", func(m *Model) (interface{}, error) { r, _ := m.cmdWorktreePromote([]string{"b"}); return r, nil }, "Not bound"}, + {"rm", func(m *Model) (interface{}, error) { r, _ := m.cmdWorktreeRemove(nil); return r, nil }, "Not bound"}, + {"shell", func(m *Model) (interface{}, error) { r, _ := m.cmdWorktreeShell(nil); return r, nil }, "Not bound"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newCmdTestModel(&mockStore{}) + m.sess = &session.Session{ID: "s1"} // no WorktreeDir -> root checkout + if _, err := tc.call(m); err != nil { + t.Fatalf("call: %v", err) + } + if !strings.Contains(m.footerMessage, tc.want) { + t.Errorf("footer = %q, want contains %q", m.footerMessage, tc.want) + } + }) + } +} + +func TestCachedWorktreeSegmentEmptyOnRoot(t *testing.T) { + m := newCmdTestModel(&mockStore{}) + m.sess = &session.Session{ID: "s1"} + if seg := m.cachedWorktreeSegment(); seg != "" { + t.Errorf("segment on root = %q, want empty", seg) + } + // A stale cache must be cleared once unbound. + m.worktreeSegCache = "⌥ stale" + if seg := m.cachedWorktreeSegment(); seg != "" { + t.Errorf("segment after unbind = %q, want empty", seg) + } +} + +func TestPathsEqual(t *testing.T) { + if !pathsEqual("/tmp/a", "/tmp/a") { + t.Error("identical paths should be equal") + } + if pathsEqual("/tmp/a", "/tmp/b") { + t.Error("different paths should not be equal") + } + if pathsEqual("", "/tmp/a") { + t.Error("empty vs non-empty should not be equal") + } + if !pathsEqual("", "") { + t.Error("empty vs empty should be equal") + } +} + +// --- integration test (real git) ----------------------------------------- + +func initChatRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + root := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test"}, + {"config", "commit.gpgsign", "false"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + for _, args := range [][]string{{"add", "README.md"}, {"commit", "-qm", "init"}} { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + return root +} + +// TestWorktreeNewBindDiffRemoveFlow drives the full TUI command surface against +// a real repo: create+bind, footer segment, pwd, diff, then force-remove back to +// root. It chdir's the process (the TUI's single-session binding mechanism), so +// it restores cwd and is not parallel-safe. +func TestWorktreeNewBindDiffRemoveFlow(t *testing.T) { + repo := initChatRepo(t) + t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "xdg")) + + origWd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origWd) }) + if err := os.Chdir(repo); err != nil { + t.Fatalf("chdir repo: %v", err) + } + + store := &mockStore{sessions: map[string]*session.Session{}} + m := newCmdTestModel(store) + m.sess = &session.Session{ID: "s1"} + + // Create via the core and bind through the chdir model (bindWorktree). The + // async /worktree-new UI path is covered by TestWorktreeProgressMsgUpdatesAndClears + // and TestWorktreeCreateDoneBindsSession; this test exercises bind/diff/remove. + wt, err := worktree.Create(context.Background(), repo, worktree.CreateOptions{Name: "flowtest", Base: "HEAD"}) + if err != nil { + t.Fatalf("worktree.Create: %v", err) + } + if err := m.bindWorktree(wt.Dir); err != nil { + t.Fatalf("bindWorktree: %v", err) + } + if m.sess.WorktreeDir == "" { + t.Fatal("session not bound after create") + } + if store.updated == nil || store.updated.WorktreeDir == "" { + t.Fatal("binding not persisted via store.Update") + } + // Process cwd should now be inside the worktree. + if cwd, _ := os.Getwd(); !pathsEqual(cwd, m.sess.WorktreeDir) { + t.Errorf("cwd %q not the worktree %q", cwd, m.sess.WorktreeDir) + } + + // Footer segment should reflect the binding. + seg := m.cachedWorktreeSegment() + if !strings.Contains(seg, "flowtest") { + t.Errorf("footer segment = %q, want contains worktree name", seg) + } + if !strings.Contains(seg, "detached@") { + t.Errorf("footer segment = %q, want detached marker", seg) + } + + // pwd reports the bound path. + m.cmdWorktreePwd() + + // Modify a tracked file and confirm diff surfaces it. + if err := os.WriteFile(filepath.Join(m.sess.WorktreeDir, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatalf("modify: %v", err) + } + m.invalidateWorktreeSegment() + if seg := m.cachedWorktreeSegment(); !strings.Contains(seg, "±") { + t.Errorf("dirty segment = %q, want dirty count", seg) + } + res, _ := m.cmdWorktreeDiff() + rm := res.(*Model) + if !strings.Contains(rm.dialog.Content(), "+world") { + t.Errorf("diff dialog missing change:\n%s", rm.dialog.Content()) + } + + // Dirty remove without force must refuse. + m.cmdWorktreeRemove(nil) + if !strings.Contains(m.footerMessage, "uncommitted") { + t.Errorf("dirty rm footer = %q, want refusal", m.footerMessage) + } + if m.sess.WorktreeDir == "" { + t.Error("session should still be bound after refused remove") + } + + // Forced remove succeeds and rebinds to root. + m.cmdWorktreeRemove([]string{"force"}) + if m.sess.WorktreeDir != "" { + t.Errorf("session still bound after force remove: %q", m.sess.WorktreeDir) + } + if cwd, _ := os.Getwd(); !pathsEqual(cwd, repo) { + t.Errorf("cwd %q not back at root %q", cwd, repo) + } + if seg := m.cachedWorktreeSegment(); seg != "" { + t.Errorf("segment after remove = %q, want empty", seg) + } +} + +// TestWorktreeSwitchToRootClearsBinding verifies "/worktree root" unbinds. +func TestWorktreeSwitchToRootClearsBinding(t *testing.T) { + repo := initChatRepo(t) + t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "xdg")) + origWd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origWd) }) + if err := os.Chdir(repo); err != nil { + t.Fatalf("chdir: %v", err) + } + + store := &mockStore{sessions: map[string]*session.Session{}} + m := newCmdTestModel(store) + m.sess = &session.Session{ID: "s1"} + + wt, err := worktree.Create(context.Background(), repo, worktree.CreateOptions{Name: "rootswitch", Base: "HEAD"}) + if err != nil { + t.Fatalf("worktree.Create: %v", err) + } + if err := m.bindWorktree(wt.Dir); err != nil { + t.Fatalf("bindWorktree: %v", err) + } + if m.sess.WorktreeDir == "" { + t.Fatalf("expected binding after create") + } + m.cmdWorktreeSwitch("root") + if m.sess.WorktreeDir != "" { + t.Errorf("binding not cleared after switch root: %q", m.sess.WorktreeDir) + } + if cwd, _ := os.Getwd(); !pathsEqual(cwd, repo) { + t.Errorf("cwd %q not root %q", cwd, repo) + } + // Cleanup the created worktree so it doesn't linger in the repo metadata. + t.Cleanup(func() { + cmd := exec.Command("git", "worktree", "prune") + cmd.Dir = repo + _ = cmd.Run() + }) +} + +// TestWorktreeSwitcherEnterBindsHighlighted drives the modal switcher against a +// real repo: open it (verifying the root/worktree/new rows), highlight the +// worktree row, press enter, and confirm the session binds via the chdir model. +func TestWorktreeSwitcherEnterBindsHighlighted(t *testing.T) { + repo := initChatRepo(t) + t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "xdg")) + origWd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origWd) }) + if err := os.Chdir(repo); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + cmd := exec.Command("git", "worktree", "prune") + cmd.Dir = repo + _ = cmd.Run() + }) + + wt, err := worktree.Create(context.Background(), repo, worktree.CreateOptions{Name: "switcher", Base: "HEAD"}) + if err != nil { + t.Fatalf("worktree.Create: %v", err) + } + + store := &mockStore{sessions: map[string]*session.Session{}} + m := newCmdTestModel(store) + m.sess = &session.Session{ID: "s1"} + + if _, cmd := m.showWorktreeSwitcher(); cmd != nil { + t.Fatal("showWorktreeSwitcher should open a dialog, not return a command") + } + if m.dialog.Type() != DialogWorktreePicker { + t.Fatalf("dialog type = %v, want worktree picker", m.dialog.Type()) + } + // Rows are: root (synthetic), the worktree, then the "+ new worktree…" row. + if first := m.dialog.ItemAt(0); first == nil || first.ID != "__root__" { + t.Fatalf("first row = %+v, want __root__", first) + } + wtIdx := -1 + for i := 0; ; i++ { + it := m.dialog.ItemAt(i) + if it == nil { + if wtIdx < 0 { + t.Fatal("worktree row not present in picker") + } + if last := m.dialog.ItemAt(i - 1); last == nil || last.ID != "__new__" { + t.Fatalf("last row = %+v, want __new__", last) + } + break + } + if it.ID == wt.Dir { + wtIdx = i + } + } + m.dialog.SetCursor(wtIdx) + + res, _ := m.handleKeyMsg(tea.KeyPressMsg{Code: tea.KeyEnter}) + rm := res.(*Model) + if rm.dialog.IsOpen() { + t.Fatal("dialog should close after switch") + } + if !pathsEqual(rm.sess.WorktreeDir, wt.Dir) { + t.Errorf("bound to %q, want %q", rm.sess.WorktreeDir, wt.Dir) + } + if cwd, _ := os.Getwd(); !pathsEqual(cwd, wt.Dir) { + t.Errorf("cwd %q not the worktree %q", cwd, wt.Dir) + } +} + +// TestWorktreeSwitcherDeleteRequiresTwoPresses verifies the in-place delete +// confirm: a first "d" arms the highlighted row and warns without deleting. +func TestWorktreeSwitcherDeleteRequiresTwoPresses(t *testing.T) { + m := newCmdTestModel(&mockStore{}) + m.sess = &session.Session{ID: "s1"} + m.dialog.ShowWorktreePicker([]DialogItem{{ID: "/tmp/wt-x", Label: "neon-canyon"}}, "/tmp/wt-x") + + res, _ := m.handleKeyMsg(tea.KeyPressMsg{Code: 'd'}) + rm := res.(*Model) + if !rm.dialog.IsOpen() { + t.Fatal("first delete press should keep the dialog open") + } + if rm.worktreeDeleteTarget != "/tmp/wt-x" { + t.Fatalf("delete target = %q, want armed to /tmp/wt-x", rm.worktreeDeleteTarget) + } + if !strings.Contains(rm.footerMessage, "Press d again") { + t.Fatalf("footer = %q, want two-press prompt", rm.footerMessage) + } + if rm.worktreeBusy { + t.Fatal("first delete press must not start a removal") + } +} + +// TestWorktreeShellCommandConstruction checks the shell-drop command: an +// interactive $SHELL rooted in the worktree by default, and a tmux split with a +// new-window fallback (carrying the quoted dir) under --tmux. +func TestWorktreeShellCommandConstruction(t *testing.T) { + t.Setenv("SHELL", "/bin/zsh") + cmd := worktreeShellCommand("/tmp/wt-abc", false) + if filepath.Base(cmd.Path) != "zsh" { + t.Errorf("shell path = %q, want zsh", cmd.Path) + } + if cmd.Dir != "/tmp/wt-abc" { + t.Errorf("shell cwd = %q, want /tmp/wt-abc", cmd.Dir) + } + + t.Setenv("SHELL", "") + if def := worktreeShellCommand("/tmp/wt-abc", false); def.Path != "/bin/sh" { + t.Errorf("default shell path = %q, want /bin/sh", def.Path) + } + + tmux := worktreeShellCommand("/tmp/wt abc", true) + if filepath.Base(tmux.Path) != "sh" { + t.Errorf("tmux command path = %q, want sh", tmux.Path) + } + joined := strings.Join(tmux.Args, " ") + if !strings.Contains(joined, "tmux split-window -c") || !strings.Contains(joined, "tmux new-window -c") { + t.Errorf("tmux args = %q, want split-window + new-window fallback", joined) + } + if !strings.Contains(joined, strconv.Quote("/tmp/wt abc")) { + t.Errorf("tmux args = %q, want quoted worktree dir", joined) + } + if tmux.Dir != "" { + t.Errorf("tmux command cwd = %q, want empty (tmux -c carries it)", tmux.Dir) + } +} + +// TestOpenWorktreeShellGuards covers the no-dir and --tmux-without-tmux guards. +func TestOpenWorktreeShellGuards(t *testing.T) { + m := newCmdTestModel(&mockStore{}) + m.sess = &session.Session{ID: "s1"} + + m.openWorktreeShell("", false) + if !strings.Contains(m.footerMessage, "No worktree directory selected") { + t.Errorf("empty-dir footer = %q", m.footerMessage) + } + + t.Setenv("TMUX", "") + m.openWorktreeShell("/tmp/wt", true) + if !strings.Contains(m.footerMessage, "requires an existing tmux session") { + t.Errorf("tmux-guard footer = %q", m.footerMessage) + } +} + +// TestWorktreeShellDoneMsgClearsState verifies the shell-return handler reports +// success and refreshes the worktree footer segment. +func TestWorktreeShellDoneMsgClearsState(t *testing.T) { + m := newCmdTestModel(&mockStore{}) + m.sess = &session.Session{ID: "s1"} + m.worktreeSegCache = "stale" + + res, _ := m.Update(worktreeShellDoneMsg{}) + rm := res.(*Model) + if rm.worktreeSegCache != "" { + t.Errorf("worktree segment cache = %q, want invalidated", rm.worktreeSegCache) + } + if !strings.Contains(rm.footerMessage, "Returned from worktree shell") { + t.Errorf("footer = %q, want shell-return notice", rm.footerMessage) + } +} + +// TestWorktreeProgressMsgUpdatesAndClears drives the async create message flow: +// a progress event updates the live status text and reschedules the listener, +// then a create-done (error) clears the busy state and surfaces the failure. +func TestWorktreeProgressMsgUpdatesAndClears(t *testing.T) { + m := newCmdTestModel(&mockStore{}) + m.sess = &session.Session{ID: "s1"} + m.worktreeBusy = true + m.worktreeOpSeq = 7 + progress := make(chan worktree.Progress) + + result, cmd := m.Update(worktreeProgressMsg{op: 7, progress: progress, message: "running setup script"}) + rm := result.(*Model) + if rm.worktreeProgress != "Creating worktree: running setup script" { + t.Fatalf("worktree progress = %q", rm.worktreeProgress) + } + if cmd == nil { + t.Fatal("expected progress listener to be rescheduled") + } + + result, _ = rm.Update(worktreeCreateDoneMsg{op: 7, err: errors.New("boom")}) + rm = result.(*Model) + if rm.worktreeBusy { + t.Fatal("worktreeBusy should clear on create completion") + } + if rm.worktreeProgress != "" { + t.Fatalf("worktree progress after completion = %q, want empty", rm.worktreeProgress) + } + if !strings.Contains(rm.footerMessage, "Worktree create failed") { + t.Fatalf("footer = %q, want create failure", rm.footerMessage) + } +} + +// TestWorktreeCreateDoneBindsSession verifies the create-done handler binds the +// session to the new worktree via the chdir model on success. +func TestWorktreeCreateDoneBindsSession(t *testing.T) { + repo := initChatRepo(t) + t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "xdg")) + origWd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origWd) }) + if err := os.Chdir(repo); err != nil { + t.Fatalf("chdir: %v", err) + } + store := &mockStore{sessions: map[string]*session.Session{}} + m := newCmdTestModel(store) + m.sess = &session.Session{ID: "s1"} + m.worktreeBusy = true + m.worktreeOpSeq = 3 + + wt, err := worktree.Create(context.Background(), repo, worktree.CreateOptions{Name: "donebind", Base: "HEAD"}) + if err != nil { + t.Fatalf("worktree.Create: %v", err) + } + t.Cleanup(func() { + cmd := exec.Command("git", "worktree", "prune") + cmd.Dir = repo + _ = cmd.Run() + }) + + res, _ := m.Update(worktreeCreateDoneMsg{op: 3, wt: wt}) + rm := res.(*Model) + if rm.worktreeBusy { + t.Fatal("worktreeBusy should clear after create-done") + } + if !pathsEqual(rm.sess.WorktreeDir, wt.Dir) { + t.Errorf("session bound to %q, want %q", rm.sess.WorktreeDir, wt.Dir) + } + if cwd, _ := os.Getwd(); !pathsEqual(cwd, wt.Dir) { + t.Errorf("cwd %q not the worktree %q", cwd, wt.Dir) + } + if !strings.Contains(rm.footerMessage, "Created and switched") { + t.Errorf("footer = %q, want success", rm.footerMessage) + } +} diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go new file mode 100644 index 00000000..8aba6f1a --- /dev/null +++ b/internal/worktree/worktree.go @@ -0,0 +1,652 @@ +// Package worktree manages git worktrees for term-llm sessions. +// +// A worktree is a second checkout of the same repository (sharing one .git) +// created with `git worktree add`, living out-of-tree under a managed root: +// +// $XDG_DATA_HOME/term-llm/worktrees/// +// +// Worktrees are created in a detached HEAD by default so that refs/heads/* stay +// clean and git's "a branch can only be checked out in one worktree" rule is +// sidestepped until the user explicitly promotes to a named branch. +// +// This package owns all git logic for the feature; callers (the TUI) drive it +// through the exported Create/List/Get/Promote/Remove/Diff API and never shell +// out to git themselves. +package worktree + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/samsaffron/term-llm/internal/appdata" +) + +// Status describes where a worktree is in its lifecycle. +type Status string + +const ( + StatusCreating Status = "creating" + StatusReady Status = "ready" + StatusFailed Status = "failed" +) + +// Worktree describes a single git worktree. +type Worktree struct { + Name string // friendly slug, unique within the repo + Dir string // absolute path of the worktree + Branch string // named branch, or "" when detached + Base string // commit/branch it was created from (best-effort) + Detached bool // true when HEAD is detached + Status Status // Creating | Ready | Failed + DirtyFiles int // count from `git status --porcelain` + HeadSHA string // current HEAD, short form +} + +// CreateOptions configures Create. +type CreateOptions struct { + Name string // optional; a slug is generated when empty + Base string // base commit/branch; defaults to "HEAD" + SetupScript string // optional script run in the new worktree after creation + ProgressFn func(message string) // optional progress callback for a spinner + Progress chan<- Progress // optional channel for progress events (TUI spinner) +} + +// Progress is emitted by Create as it advances through the slow parts (worktree +// add, setup script) so a caller can drive a live spinner. +type Progress struct { + Message string +} + +// metadata is persisted alongside the managed root (outside the worktree dir so +// it never dirties the checkout) to recover Name/Base after creation. +type metadata struct { + Name string `json:"name"` + Dir string `json:"dir"` + Base string `json:"base"` + CreatedAt time.Time `json:"created_at"` +} + +// ErrDirty is returned by Remove when the worktree has uncommitted changes and +// force was not requested. +var ErrDirty = errors.New("worktree has uncommitted changes") + +// ErrExists is returned by Create when an explicitly named worktree already +// exists. +var ErrExists = errors.New("worktree already exists") + +func (o *CreateOptions) progress(msg string) { + if o == nil { + return + } + if o.ProgressFn != nil { + o.ProgressFn(msg) + } + emit(o.Progress, msg) +} + +// emit sends a progress event without blocking; events are dropped if no +// receiver is ready so Create never stalls on a slow/absent consumer. +func emit(ch chan<- Progress, msg string) { + if ch == nil { + return + } + select { + case ch <- Progress{Message: msg}: + default: + } +} + +// MainRepoRoot returns the main checkout root for any worktree of the repo +// (the directory whose .git is the shared common dir). Useful for rebinding a +// session back to the root checkout after removing its worktree. +func MainRepoRoot(dir string) (string, error) { + return canonicalRepoRoot(dir) +} + +// IsGitRepo reports whether dir is inside a git working tree. +func IsGitRepo(dir string) bool { + out, err := runGit(dir, "rev-parse", "--is-inside-work-tree") + return err == nil && strings.TrimSpace(out) == "true" +} + +// ManagedRoot returns the directory under which this repo's worktrees live: +// $XDG_DATA_HOME/term-llm/worktrees//. The repo identity is derived +// from the shared .git directory, so all worktrees of the same repo agree. +func ManagedRoot(repoRoot string) (string, error) { + dataDir, err := appdata.GetDataDir() + if err != nil { + return "", err + } + canonical, err := canonicalRepoRoot(repoRoot) + if err != nil { + return "", err + } + return filepath.Join(dataDir, "worktrees", repoHash(canonical)), nil +} + +// Create runs `git worktree add` (detached) under the managed root, then the +// optional setup script. It is long-running and reports progress via +// opts.ProgressFn. On failure it cleans up so no half-created directory is left. +func Create(ctx context.Context, repoRoot string, opts CreateOptions) (*Worktree, error) { + if !IsGitRepo(repoRoot) { + return nil, fmt.Errorf("worktree: %q is not a git repository", repoRoot) + } + + root, err := ManagedRoot(repoRoot) + if err != nil { + return nil, err + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("worktree: create managed root: %w", err) + } + + base := strings.TrimSpace(opts.Base) + if base == "" { + base = "HEAD" + } + // Resolve to a concrete commit so the worktree is pinned even if the branch + // later moves, and so Base is meaningful for diffs. + baseSHA, err := runGit(repoRoot, "rev-parse", base) + if err != nil { + return nil, fmt.Errorf("worktree: resolve base %q: %w", base, err) + } + baseSHA = strings.TrimSpace(baseSHA) + + name, dir, err := resolveName(root, opts.Name) + if err != nil { + return nil, err + } + + opts.progress(fmt.Sprintf("Creating worktree %s…", name)) + if out, err := runGitCtx(ctx, repoRoot, "worktree", "add", "--detach", dir, baseSHA); err != nil { + return nil, fmt.Errorf("worktree: git worktree add: %w: %s", err, strings.TrimSpace(out)) + } + + // From here on, clean up the worktree if anything fails. + cleanup := func() { + _, _ = runGit(repoRoot, "worktree", "remove", "--force", dir) + _, _ = runGit(repoRoot, "worktree", "prune") + _ = os.RemoveAll(dir) + } + + writeMetadata(root, metadata{Name: name, Dir: dir, Base: baseSHA, CreatedAt: time.Now()}) + + if script := strings.TrimSpace(opts.SetupScript); script != "" { + opts.progress("Running setup script…") + if out, err := runScript(ctx, dir, script); err != nil { + cleanup() + removeMetadata(root, name) + return nil, fmt.Errorf("worktree: setup script failed: %w\n%s", err, strings.TrimSpace(out)) + } + } + + wt, err := Get(dir) + if err != nil { + cleanup() + removeMetadata(root, name) + return nil, err + } + wt.Name = name + wt.Base = shortSHA(baseSHA) + wt.Status = StatusReady + opts.progress("Ready") + return wt, nil +} + +// List returns the repo's worktrees, excluding the main checkout. Names and +// bases are recovered from metadata when available, otherwise derived. +func List(repoRoot string) ([]Worktree, error) { + if !IsGitRepo(repoRoot) { + return nil, fmt.Errorf("worktree: %q is not a git repository", repoRoot) + } + mainRoot, err := canonicalRepoRoot(repoRoot) + if err != nil { + return nil, err + } + out, err := runGit(repoRoot, "worktree", "list", "--porcelain") + if err != nil { + return nil, fmt.Errorf("worktree: list: %w", err) + } + root, _ := ManagedRoot(repoRoot) + meta := readAllMetadata(root) + + var result []Worktree + for _, rec := range parseWorktreeList(out) { + if pathsEqual(rec.Dir, mainRoot) { + continue // skip the main checkout; the TUI shows it as a synthetic row + } + wt := Worktree{ + Name: filepath.Base(rec.Dir), + Dir: rec.Dir, + Branch: rec.Branch, + Detached: rec.Detached, + HeadSHA: shortSHA(rec.HeadSHA), + Status: StatusReady, + } + if m, ok := meta[rec.Dir]; ok { + wt.Name = m.Name + wt.Base = shortSHA(m.Base) + } + wt.DirtyFiles = dirtyCount(rec.Dir) + result = append(result, wt) + } + return result, nil +} + +// Get inspects a single worktree directory. +func Get(dir string) (*Worktree, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + if !IsGitRepo(abs) { + return nil, fmt.Errorf("worktree: %q is not a git worktree", abs) + } + top, err := runGit(abs, "rev-parse", "--show-toplevel") + if err != nil { + return nil, fmt.Errorf("worktree: resolve toplevel: %w", err) + } + abs = strings.TrimSpace(top) + + wt := &Worktree{ + Name: filepath.Base(abs), + Dir: abs, + Status: StatusReady, + DirtyFiles: dirtyCount(abs), + } + if head, err := runGit(abs, "rev-parse", "--short", "HEAD"); err == nil { + wt.HeadSHA = strings.TrimSpace(head) + } + // symbolic-ref succeeds (with a branch name) only when HEAD is attached. + if branch, err := runGit(abs, "symbolic-ref", "--quiet", "--short", "HEAD"); err == nil { + wt.Branch = strings.TrimSpace(branch) + wt.Detached = false + } else { + wt.Detached = true + } + + // Recover Base/Name from metadata when present. + if mainRoot, err := canonicalRepoRoot(abs); err == nil { + if dataDir, err := appdata.GetDataDir(); err == nil { + root := filepath.Join(dataDir, "worktrees", repoHash(mainRoot)) + if m, ok := readMetadataByDir(root, abs); ok { + wt.Name = m.Name + wt.Base = shortSHA(m.Base) + } + } + } + return wt, nil +} + +// Promote converts a detached-HEAD worktree to a named branch at the current +// HEAD. +func Promote(dir, branch string) error { + branch = strings.TrimSpace(branch) + if branch == "" { + return errors.New("worktree: branch name required") + } + if out, err := runGit(dir, "switch", "-c", branch); err != nil { + return fmt.Errorf("worktree: promote to %q: %w: %s", branch, err, strings.TrimSpace(out)) + } + return nil +} + +// Remove deletes a worktree via `git worktree remove`. It refuses on a dirty +// worktree unless force is set. When force is set and the worktree is on a +// named branch, that branch is deleted too. +func Remove(dir string, force bool) error { + abs, err := filepath.Abs(dir) + if err != nil { + return err + } + wt, err := Get(abs) + if err != nil { + return err + } + if wt.DirtyFiles > 0 && !force { + return ErrDirty + } + mainRoot, err := canonicalRepoRoot(abs) + if err != nil { + return err + } + + args := []string{"worktree", "remove"} + if force { + args = append(args, "--force") + } + args = append(args, abs) + if out, err := runGit(mainRoot, args...); err != nil { + return fmt.Errorf("worktree: remove: %w: %s", err, strings.TrimSpace(out)) + } + _, _ = runGit(mainRoot, "worktree", "prune") + _ = os.RemoveAll(abs) + + if force && wt.Branch != "" { + _, _ = runGit(mainRoot, "branch", "-D", wt.Branch) + } + + if root, err := ManagedRoot(mainRoot); err == nil { + removeMetadataByDir(root, abs) + } + return nil +} + +// Diff returns the diff of the worktree against its base commit (everything +// done in the worktree), falling back to a diff against HEAD when the base is +// unknown. +func Diff(dir string) (string, error) { + wt, err := Get(dir) + if err != nil { + return "", err + } + target := "HEAD" + if wt.Base != "" { + // wt.Base is short; git accepts short SHAs. + target = wt.Base + } + out, err := runGit(wt.Dir, "--no-pager", "diff", target) + if err != nil { + return "", fmt.Errorf("worktree: diff: %w", err) + } + return out, nil +} + +// --- git plumbing helpers ------------------------------------------------- + +func runGit(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return string(out), err +} + +func runGitCtx(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return string(out), err +} + +func runScript(ctx context.Context, dir, script string) (string, error) { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + cmd := exec.CommandContext(ctx, shell, "-c", script) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "TERM_LLM_WORKTREE_DIR="+dir) + out, err := cmd.CombinedOutput() + return string(out), err +} + +// canonicalRepoRoot returns the main worktree's root for any worktree of the +// repo, so all worktrees share one identity. It resolves the shared .git +// (common) directory and returns its parent. +func canonicalRepoRoot(dir string) (string, error) { + out, err := runGit(dir, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + // Older git may not support --path-format; fall back. + out, err = runGit(dir, "rev-parse", "--git-common-dir") + if err != nil { + return "", fmt.Errorf("worktree: resolve git dir: %w", err) + } + } + common := strings.TrimSpace(out) + if !filepath.IsAbs(common) { + common = filepath.Join(dir, common) + } + common, err = filepath.Abs(common) + if err != nil { + return "", err + } + // common is typically /.git + root := filepath.Dir(common) + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } + return root, nil +} + +func dirtyCount(dir string) int { + out, err := runGit(dir, "status", "--porcelain", "--untracked-files=all") + if err != nil { + return 0 + } + out = strings.TrimRight(out, "\n") + if out == "" { + return 0 + } + return len(strings.Split(out, "\n")) +} + +type worktreeRec struct { + Dir string + HeadSHA string + Branch string + Detached bool +} + +// parseWorktreeList parses `git worktree list --porcelain` output. +func parseWorktreeList(out string) []worktreeRec { + var recs []worktreeRec + var cur *worktreeRec + flush := func() { + if cur != nil { + recs = append(recs, *cur) + cur = nil + } + } + for _, line := range strings.Split(out, "\n") { + line = strings.TrimRight(line, "\r") + switch { + case strings.HasPrefix(line, "worktree "): + flush() + cur = &worktreeRec{Dir: strings.TrimPrefix(line, "worktree ")} + case cur == nil: + continue + case strings.HasPrefix(line, "HEAD "): + cur.HeadSHA = strings.TrimPrefix(line, "HEAD ") + case strings.HasPrefix(line, "branch "): + ref := strings.TrimPrefix(line, "branch ") + cur.Branch = strings.TrimPrefix(ref, "refs/heads/") + case line == "detached": + cur.Detached = true + case line == "": + flush() + } + } + flush() + return recs +} + +func shortSHA(s string) string { + s = strings.TrimSpace(s) + if len(s) > 12 { + return s[:12] + } + return s +} + +func repoHash(root string) string { + abs, err := filepath.Abs(root) + if err != nil { + abs = root + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + h := sha256.Sum256([]byte(abs)) + return hex.EncodeToString(h[:8]) +} + +func pathsEqual(a, b string) bool { + ca, err := filepath.Abs(a) + if err != nil { + ca = a + } + if r, err := filepath.EvalSymlinks(ca); err == nil { + ca = r + } + cb, err := filepath.Abs(b) + if err != nil { + cb = b + } + if r, err := filepath.EvalSymlinks(cb); err == nil { + cb = r + } + return filepath.Clean(ca) == filepath.Clean(cb) +} + +// resolveName picks a worktree name and its target directory under root. An +// explicit name that already exists is an error; a generated slug is retried +// until it is free. +func resolveName(root, requested string) (name, dir string, err error) { + requested = sanitizeName(requested) + if requested != "" { + dir = filepath.Join(root, requested) + if _, statErr := os.Stat(dir); statErr == nil { + return "", "", fmt.Errorf("worktree: %w: %q", ErrExists, requested) + } + return requested, dir, nil + } + for i := 0; i < 20; i++ { + candidate := Slug() + dir = filepath.Join(root, candidate) + if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + return candidate, dir, nil + } + } + return "", "", errors.New("worktree: could not allocate a unique name") +} + +// sanitizeName makes a user-supplied name safe to use as a directory segment. +func sanitizeName(name string) string { + name = strings.TrimSpace(strings.ToLower(name)) + if name == "" { + return "" + } + var b strings.Builder + prevDash := false + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + case r == '-' || r == '_' || r == ' ' || r == '/': + if !prevDash { + b.WriteRune('-') + prevDash = true + } + } + } + return strings.Trim(b.String(), "-") +} + +// --- metadata persistence (best-effort) ----------------------------------- + +func metaDir(root string) string { return filepath.Join(root, ".meta") } + +func writeMetadata(root string, m metadata) { + dir := metaDir(root) + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return + } + _ = os.WriteFile(filepath.Join(dir, m.Name+".json"), data, 0o644) +} + +func removeMetadata(root, name string) { + _ = os.Remove(filepath.Join(metaDir(root), name+".json")) +} + +func readAllMetadata(root string) map[string]metadata { + result := map[string]metadata{} + entries, err := os.ReadDir(metaDir(root)) + if err != nil { + return result + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(metaDir(root), e.Name())) + if err != nil { + continue + } + var m metadata + if json.Unmarshal(data, &m) == nil && m.Dir != "" { + result[m.Dir] = m + } + } + return result +} + +func readMetadataByDir(root, dir string) (metadata, bool) { + for d, m := range readAllMetadata(root) { + if pathsEqual(d, dir) { + return m, true + } + } + return metadata{}, false +} + +func removeMetadataByDir(root, dir string) { + for _, m := range readAllMetadata(root) { + if pathsEqual(m.Dir, dir) { + removeMetadata(root, m.Name) + } + } +} + +// --- slug generation ------------------------------------------------------ + +var slugAdjectives = []string{ + "amber", "azure", "bold", "brave", "bright", "calm", "clear", "cobalt", + "coral", "crimson", "crisp", "dawn", "deep", "dusk", "eager", "ember", + "fair", "fleet", "fond", "gentle", "glad", "gold", "green", "hardy", + "hazel", "ivory", "jade", "keen", "lance", "lively", "lunar", "mellow", + "merry", "mild", "neon", "noble", "olive", "polar", "proud", "quiet", + "rapid", "royal", "ruby", "sage", "sleek", "snowy", "solar", "spry", + "still", "swift", "teal", "tidy", "vivid", "warm", "wise", "zesty", +} + +var slugNouns = []string{ + "anchor", "arbor", "aspen", "badge", "birch", "bloom", "bolt", "brook", + "canyon", "cedar", "cliff", "comet", "coral", "cove", "crane", "creek", + "crest", "delta", "drift", "ember", "fern", "field", "fjord", "flare", + "glade", "glen", "grove", "harbor", "haven", "heron", "hollow", "inlet", + "isle", "knoll", "lake", "ledge", "maple", "marsh", "meadow", "mesa", + "oasis", "orbit", "otter", "peak", "pine", "pond", "reef", "ridge", + "river", "shoal", "shore", "slate", "spire", "summit", "thorn", "vale", +} + +// Slug returns an adjective-noun pair like "neon-canyon". +func Slug() string { + return slugAdjectives[randIndex(len(slugAdjectives))] + "-" + slugNouns[randIndex(len(slugNouns))] +} + +func randIndex(n int) int { + if n <= 0 { + return 0 + } + v, err := rand.Int(rand.Reader, big.NewInt(int64(n))) + if err != nil { + return 0 + } + return int(v.Int64()) +} diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go new file mode 100644 index 00000000..badb416e --- /dev/null +++ b/internal/worktree/worktree_test.go @@ -0,0 +1,300 @@ +package worktree + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/samsaffron/term-llm/internal/appdata" +) + +// initRepo creates a fresh git repo with one commit and returns its root. +func initRepo(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + runOrFail(t, root, "git", "init", "-q") + runOrFail(t, root, "git", "config", "user.email", "test@example.com") + runOrFail(t, root, "git", "config", "user.name", "Test") + runOrFail(t, root, "git", "config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write README: %v", err) + } + runOrFail(t, root, "git", "add", "README.md") + runOrFail(t, root, "git", "commit", "-qm", "init") + return root +} + +func runOrFail(t *testing.T, dir, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out) + } + return string(out) +} + +// guardXDG points the data dir at a temp location and verifies the package +// honors it, so tests never touch the real data directory. +func guardXDG(t *testing.T) { + t.Helper() + xdg := filepath.Join(t.TempDir(), "xdg") + t.Setenv("XDG_DATA_HOME", xdg) + dataDir, err := appdata.GetDataDir() + if err != nil { + t.Fatalf("data dir: %v", err) + } + if !strings.HasPrefix(dataDir, xdg) { + t.Skipf("data dir %q does not honor XDG_DATA_HOME %q; skipping to avoid polluting real data dir", dataDir, xdg) + } +} + +func TestCreateListGetRemove(t *testing.T) { + guardXDG(t) + repo := initRepo(t) + ctx := context.Background() + + wt, err := Create(ctx, repo, CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if !wt.Detached { + t.Errorf("expected detached HEAD, got branch %q", wt.Branch) + } + if wt.Status != StatusReady { + t.Errorf("status = %q, want ready", wt.Status) + } + if wt.DirtyFiles != 0 { + t.Errorf("DirtyFiles = %d, want 0", wt.DirtyFiles) + } + if _, err := os.Stat(wt.Dir); err != nil { + t.Fatalf("worktree dir missing: %v", err) + } + if !strings.Contains(wt.Dir, "worktrees") { + t.Errorf("dir %q not under managed root", wt.Dir) + } + + list, err := List(repo) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 { + t.Fatalf("List len = %d, want 1", len(list)) + } + if !pathsEqual(list[0].Dir, wt.Dir) { + t.Errorf("List dir = %q, want %q", list[0].Dir, wt.Dir) + } + if list[0].Name != wt.Name { + t.Errorf("List name = %q, want %q", list[0].Name, wt.Name) + } + + got, err := Get(wt.Dir) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != wt.Name { + t.Errorf("Get name = %q, want %q", got.Name, wt.Name) + } + if got.Base != wt.Base || got.Base == "" { + t.Errorf("Get base = %q, want %q", got.Base, wt.Base) + } + if !got.Detached { + t.Errorf("Get should report detached") + } + + if err := Remove(wt.Dir, false); err != nil { + t.Fatalf("Remove(clean): %v", err) + } + if _, err := os.Stat(wt.Dir); !os.IsNotExist(err) { + t.Errorf("worktree dir still present after Remove") + } + list, _ = List(repo) + if len(list) != 0 { + t.Errorf("List len = %d after remove, want 0", len(list)) + } +} + +func TestSetupScriptRunsAndDirtyGuard(t *testing.T) { + guardXDG(t) + repo := initRepo(t) + + wt, err := Create(context.Background(), repo, CreateOptions{ + SetupScript: "echo marker > setup_marker.txt", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := os.Stat(filepath.Join(wt.Dir, "setup_marker.txt")); err != nil { + t.Fatalf("setup script did not run: %v", err) + } + + // The untracked marker makes the worktree dirty; a non-forced Remove must refuse. + got, _ := Get(wt.Dir) + if got.DirtyFiles == 0 { + t.Errorf("expected dirty worktree after setup script") + } + if err := Remove(wt.Dir, false); err == nil { + t.Errorf("Remove(dirty, force=false) should fail") + } + if err := Remove(wt.Dir, true); err != nil { + t.Fatalf("Remove(dirty, force=true): %v", err) + } +} + +func TestSetupScriptFailureCleansUp(t *testing.T) { + guardXDG(t) + repo := initRepo(t) + + _, err := Create(context.Background(), repo, CreateOptions{ + Name: "doomed", + SetupScript: "exit 3", + }) + if err == nil { + t.Fatalf("Create should fail when setup script fails") + } + root, _ := ManagedRoot(repo) + if _, statErr := os.Stat(filepath.Join(root, "doomed")); !os.IsNotExist(statErr) { + t.Errorf("half-created worktree dir left behind") + } + if list, _ := List(repo); len(list) != 0 { + t.Errorf("List len = %d after failed create, want 0", len(list)) + } +} + +func TestPromoteAndForceRemoveDeletesBranch(t *testing.T) { + guardXDG(t) + repo := initRepo(t) + + wt, err := Create(context.Background(), repo, CreateOptions{Name: "promoteme"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := Promote(wt.Dir, "feature/x"); err != nil { + t.Fatalf("Promote: %v", err) + } + got, err := Get(wt.Dir) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Detached { + t.Errorf("worktree should be attached after promote") + } + if got.Branch != "feature/x" { + t.Errorf("Branch = %q, want feature/x", got.Branch) + } + + if err := Remove(wt.Dir, true); err != nil { + t.Fatalf("Remove(force): %v", err) + } + out := runOrFail(t, repo, "git", "branch", "--list", "feature/x") + if strings.TrimSpace(out) != "" { + t.Errorf("branch feature/x not deleted: %q", out) + } +} + +func TestDiff(t *testing.T) { + guardXDG(t) + repo := initRepo(t) + + wt, err := Create(context.Background(), repo, CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { _ = Remove(wt.Dir, true) }) + + if err := os.WriteFile(filepath.Join(wt.Dir, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatalf("modify file: %v", err) + } + diff, err := Diff(wt.Dir) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if !strings.Contains(diff, "+world") { + t.Errorf("diff missing change:\n%s", diff) + } +} + +func TestDuplicateNameErrors(t *testing.T) { + guardXDG(t) + repo := initRepo(t) + + wt, err := Create(context.Background(), repo, CreateOptions{Name: "fixed"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { _ = Remove(wt.Dir, true) }) + + _, err = Create(context.Background(), repo, CreateOptions{Name: "fixed"}) + if err == nil { + t.Fatalf("duplicate name should error") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("error = %v, want already-exists", err) + } +} + +func TestNonRepoIsInert(t *testing.T) { + dir := t.TempDir() + if IsGitRepo(dir) { + t.Errorf("temp dir should not be a git repo") + } + if _, err := List(dir); err == nil { + t.Errorf("List on non-repo should error") + } +} + +func TestParseWorktreeList(t *testing.T) { + sample := strings.Join([]string{ + "worktree /home/u/repo", + "HEAD 1111111111111111111111111111111111111111", + "branch refs/heads/main", + "", + "worktree /data/worktrees/abcd/neon-canyon", + "HEAD 2222222222222222222222222222222222222222", + "detached", + "", + }, "\n") + recs := parseWorktreeList(sample) + if len(recs) != 2 { + t.Fatalf("parsed %d records, want 2", len(recs)) + } + if recs[0].Branch != "main" || recs[0].Detached { + t.Errorf("rec0 = %+v, want branch main attached", recs[0]) + } + if recs[1].Dir != "/data/worktrees/abcd/neon-canyon" || !recs[1].Detached { + t.Errorf("rec1 = %+v, want detached managed path", recs[1]) + } + if recs[1].Branch != "" { + t.Errorf("rec1 branch = %q, want empty (detached)", recs[1].Branch) + } +} + +func TestSanitizeName(t *testing.T) { + cases := map[string]string{ + " Hello World ": "hello-world", + "feature/Foo": "feature-foo", + "a__b": "a-b", + "!!!": "", + "Neon-Canyon": "neon-canyon", + } + for in, want := range cases { + if got := sanitizeName(in); got != want { + t.Errorf("sanitizeName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSlugShape(t *testing.T) { + s := Slug() + parts := strings.Split(s, "-") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + t.Errorf("Slug() = %q, want adjective-noun", s) + } +}