diff --git a/WORKTREES_SPEC.md b/WORKTREES_SPEC.md new file mode 100644 index 0000000..b41929a --- /dev/null +++ b/WORKTREES_SPEC.md @@ -0,0 +1,1071 @@ +# Git Worktree Support for Chief + +## Context + +Chief already supports running multiple PRDs in parallel via the Loop Manager. However, all PRDs share the same working directory and git state. Parallel Claude instances can conflict: editing the same files, producing interleaved commits, and stepping on each other's branches. Git worktrees solve this by giving each PRD its own isolated checkout on its own branch. + +--- + +## UX Design + +### How It Works (User's Perspective) + +1. User creates PRDs as normal: `chief new auth`, `chief new payments` +2. When pressing `s` to start a PRD, a dialog offers worktree creation +3. Each PRD's Claude instance works in its own isolated directory on its own branch +4. When a PRD completes, its branch has all the commits ready for merge/PR +5. User merges branches at their leisure + +### Worktree Location + +Worktrees live under `.chief/worktrees//`. Since `.chief/` is already gitignored, this keeps everything contained and invisible to git in the main repo. + +``` +.chief/ + config.yaml # Project-level config (YAML) + prds/ + auth/prd.json # PRD state (always in main repo) + payments/prd.json + worktrees/ + auth/ # Full repo checkout on chief/auth branch + payments/ # Full repo checkout on chief/payments branch +``` + +### Start Dialog (Enhanced Branch Warning) + +When pressing `s` to start a PRD, the dialog appears contextually: + +**On a protected branch (main/master):** + +``` +You are on the main branch. + +> Create worktree + branch (Recommended) chief/auth in .chief/worktrees/auth/ + Create branch only chief/auth (stay in current directory) + Continue on main (not recommended) + +[Enter] Confirm [j/k] Navigate [e] Edit branch name +``` + +**Another PRD already running in same directory:** + +``` +Another PRD (payments) is already running in this directory. + +> Create worktree (Recommended) chief/auth in .chief/worktrees/auth/ + Run in same directory (may cause file conflicts) + +[Enter] Confirm [j/k] Navigate +``` + +**No conflicts (not protected, nothing else running):** + +``` +How should this PRD run? + +> Run in current directory (Recommended) Use the current working directory + Create worktree + branch chief/auth in .chief/worktrees/auth/ + +[Enter] Confirm [j/k] Navigate [e] Edit branch name +``` + +### Tab Bar - Branch Info + +``` + auth [chief/auth] > 3/8 payments [chief/payments] > 1/5 + New +``` + +### Dashboard Header - Worktree Path + +With worktree: + +``` + chief auth [Running] iter 3 2m 14s + branch: chief/auth dir: .chief/worktrees/auth/ +``` + +Without worktree (running in main repo): + +``` + chief auth [Running] iter 3 2m 14s + branch: chief/auth dir: ./ (current directory) +``` + +### PRD Completion - Fully Automated + +When a PRD completes, chief automatically runs whatever post-completion actions are configured. The user can walk away from the computer and chief handles everything. + +**With push + PR enabled (typical):** + +``` +PRD Complete! auth 8/8 stories + +Branch 'chief/auth' has 8 commits. + +Pushing chief/auth -> origin/chief/auth... Done +Creating pull request... Done +PR #42: feat(auth): JWT authentication system https://github.com/user/repo/pull/42 + +[m] Merge locally [c] Clean worktree [l] Switch PRD [q] Quit +``` + +**With nothing configured:** + +``` +PRD Complete! auth 8/8 stories + +Branch chief/auth has 8 commits. +Configure auto-push and PR in settings (,) + +[m] Merge locally [c] Clean worktree [l] Switch PRD [q] Quit +``` + +Push and PR creation are config-only - not manual actions. If the user wants to push or create a PR, they configure it in settings and it happens automatically on every PRD completion. + +### Picker - Worktree Status + Actions + +``` +PRDs + +> auth 8/8 Complete chief/auth .chief/worktrees/auth/ + payments 1/5 Running chief/payments .chief/worktrees/payments/ + main 0/3 Ready (current directory) + +[Enter] Select [s] Start [n] New [m] Merge [c] Clean +``` + +Picker actions: +- `n` - Create a new PRD (same flow as `chief new` - launches Claude Code for interactive PRD creation) +- `e` - Edit the selected PRD (same flow as `chief edit`) +- `s` - Start the selected PRD +- `m` - Merge selected PRD's branch into current branch (only for completed PRDs with worktrees) +- `c` - Clean selected PRD's worktree + optionally delete branch + +### Creating PRDs + +PRDs can be created two ways - both invoke the same flow (launch Claude Code with the PRD-creation prompt): + +1. **From the CLI:** `chief new [name]` - creates a PRD and exits +2. **From the TUI:** Press `n` in the picker - creates a PRD and returns to the TUI + +Editing works the same way: `chief edit [name]` from CLI, or `e` on a selected PRD in the picker. + +### CLI Commands + +CLI stays minimal - only the core trio: + +``` +chief new [name] # Create a new PRD +chief edit [name] # Edit an existing PRD +chief list # List all PRDs with progress +``` + +All worktree management (merge, clean, listing) and settings editing are **TUI-only** via the picker, completion screen, and `,` keybinding. This keeps the CLI surface minimal and pushes users toward the TUI where they get full context. + +### Status command enhanced + +The existing `chief status` command gains worktree/branch info: + +``` +$ chief status auth +Project: Auth System +Branch: chief/auth +Worktree: .chief/worktrees/auth/ +Progress: 8/8 stories complete (100%) +``` + +### Chief Config File (.chief/config.yaml) + +YAML format for readability: + +```yaml +worktree: + setup: "npm install" + +onComplete: + push: true + createPR: true +``` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `worktree.setup` | string | `""` | Shell command to run after creating a worktree (e.g., `npm install`) | +| `onComplete.push` | bool | `false` | Auto-push the PRD branch to origin when all stories complete | +| `onComplete.createPR` | bool | `false` | Auto-create a PR after pushing (requires `push: true` and `gh` CLI) | + +### First-Run Config Prompt + +Part of the first-time setup flow (gitignore -> PRD name -> config). Three steps: + +**Step 3: Post-completion settings** +- Push branch to remote? (y/n) +- Auto-create a pull request? (y/n) + +**Step 4: Worktree setup command** +- Option A: "Let Claude figure it out" (Recommended) - chief invokes Claude Code with a one-shot prompt to analyze the project and detect the right setup commands (npm install, go mod download, pip install, etc.), then writes the result to config +- Option B: Enter manually - text input for the command +- Option C: Skip + +Answers saved to `.chief/config.yaml`. On subsequent runs, these prompts are skipped. + +### `gh` CLI Validation + +When the user enables "Automatically create a pull request" - either during onboarding (Step 3) or via the Settings TUI - chief immediately validates: + +1. Is `gh` in PATH? (`which gh`) +2. Is `gh` authenticated? (`gh auth status`) + +If either check fails, show a graceful error **before saving the config**: + +``` ++----------------------------------------------------------+ +| | +| ! GitHub CLI Required | +| --------------------------------------------------------| +| | +| Auto-creating pull requests requires the GitHub CLI | +| (gh) to be installed and authenticated. | +| | +| Install: https://cli.github.com | +| Then run: gh auth login | +| | +| > Continue without PR creation | +| Try again | +| | +| --------------------------------------------------------| +| Up/Down: Navigate Enter: Select | +| | ++----------------------------------------------------------+ +``` + +Same validation applies in the Settings TUI when toggling `createPR` to `Yes`. If `gh` isn't available, show the error and revert the toggle. + +### Auto-Detect Setup Command (Claude Prompt) + +When the user selects "Let Claude figure it out", chief runs Claude Code with a one-shot prompt: + +``` +Analyze this project and determine what commands need to run to install +dependencies and set up a working development environment from a fresh +git checkout (e.g., after creating a git worktree). + +Look at package managers, lock files, README, build tools, etc. Return ONLY the +shell command(s) needed, joined with &&. Examples: +- "npm install" +- "go mod download" +- "pip install -r requirements.txt" +- "npm install && npx prisma generate" + +If no setup is needed, return "none". +Do not explain, just return the command. +``` + +The result is written directly to `worktree.setup` in `.chief/config.yaml`. + +### Settings TUI + +Accessible via `,` keybinding from any TUI view. Allows editing all config values at any time. + +``` ++----------------------------------------------------------+ +| | +| Settings .chief/config.yaml | +| --------------------------------------------------------| +| | +| Worktree | +| > Setup command npm install | +| | +| On Complete | +| Push to remote Yes | +| Create pull request Yes | +| | +| --------------------------------------------------------| +| Config: .chief/config.yaml | +| --------------------------------------------------------| +| Enter: Edit Up/Down: Navigate Esc: Close | +| | ++----------------------------------------------------------+ +``` + +Editing a boolean toggles it (with `gh` validation on `createPR`). Editing a string opens an inline text input. Changes are saved immediately to `.chief/config.yaml`. + +### PR Creation Behavior + +When `onComplete.createPR` is enabled, chief automatically creates a PR after pushing: + +- **Branch:** Already on `chief/` +- **Commits:** Follow conventional commits from the agent prompt (`feat: [Story ID] - [Story Title]`) +- **PR title:** Derived from the PRD project name, following conventional commits format (e.g., `feat(auth): JWT authentication system`) +- **PR body:** + - Summary section: high-level description from PRD + - Changes section: outline of what was implemented (derived from completed user stories) + - No test plan checklist + - No mention of Claude or Claude Code +- **Screenshots:** If the PRD involves UI changes, use playwright to capture screenshots and include them +- **Command:** `gh pr create --title "..." --body "..."` + +Chief generates the PR content by reading the PRD description and completed story titles, rather than invoking Claude again - keeping it fast and deterministic. + +--- + +## Transparency Principles + +Every automated action should show *what* is happening, *where* it's happening, and *what directory/branch/path* is involved. Never hide the mechanics. The process should be slick and subtle but still transparent and clear so it doesn't appear magical or confusing. + +### Specific Transparency Requirements + +**Tab bar** - Show branch name per tab: +``` + auth [chief/auth] > 3/8 payments [chief/payments] > 1/5 +``` + +**Dashboard header** - Show working directory and branch: +``` + chief auth [Running] iter 3 2m 14s + branch: chief/auth dir: .chief/worktrees/auth/ +``` + +**Worktree setup spinner** - Show each step as it happens with paths: +``` + ✓ Created branch 'chief/auth' from 'main' + ✓ Created worktree at .chief/worktrees/auth/ + * Running setup: npm install... +``` + +**Completion screen** - Show what auto-actions ran and their results with full context: +``` + Branch 'chief/auth' has 8 commits. + ✓ Pushed chief/auth -> origin/chief/auth + ✓ PR #42: feat(auth): JWT authentication system + https://github.com/user/repo/pull/42 +``` + +**Picker** - Show worktree path, branch, and working directory for every PRD: +``` + > auth 8/8 Complete chief/auth .chief/worktrees/auth/ + payments 1/5 Running chief/payments .chief/worktrees/payments/ + main 0/3 Ready (current directory) +``` + +**Start dialog** - Always show the full path of where Claude will work: +``` + > Create worktree + branch 'chief/auth' + Claude will work in: .chief/worktrees/auth/ + Branch created from: main + + Create branch only 'chief/auth' + Claude will work in: ./ (current directory) +``` + +**Settings TUI** - Show config file path in header: +``` + Settings .chief/config.yaml +``` + +**Clean confirmation** - Show exact paths being removed: +``` + Remove worktree for 'auth'? + + Worktree: .chief/worktrees/auth/ (will be deleted) + Branch: chief/auth (will be deleted) +``` + +**Merge result** - Show what happened: +``` + ✓ Merged chief/auth into main (fast-forward) + 8 commits applied. +``` + +**Merge conflict** - Show exact instructions: +``` + x Could not merge 'chief/auth' into 'main'. + Conflicting files: + src/auth/handler.go + src/middleware/jwt.go + Resolve in your terminal: + cd /Users/you/project + git merge chief/auth +``` + +**Auto-action errors** - Inline, actionable, never blocking: +``` + ✓ Pushed chief/auth to origin + x PR creation failed: gh not found + Install: https://cli.github.com +``` + +--- + +## Design Decisions + +1. **Worktree branches always start from main/master** - not from current HEAD. This ensures each PRD has a clean base regardless of what branch the user happens to be on. +2. **Config prompt is part of the first-time setup flow** - The existing flow is: gitignore -> PRD name. Extended to: gitignore -> PRD name -> post-completion config -> worktree setup. One consolidated onboarding. +3. **Push and PR are config-only, not manual actions** - Once configured, they fire automatically on every PRD completion. The goal is "start and walk away." No `p` or `r` keybindings in the TUI. If the user wants to change the behavior, they use the settings TUI (`,` key). +4. **Claude auto-detects worktree setup commands** - During onboarding, the user can let Claude analyze the project to figure out what setup commands are needed. This is a one-shot Claude invocation that writes the result to config. +5. **Config is YAML** - `.chief/config.yaml` for readability and easy manual editing. +6. **Agent prompt doesn't need worktree awareness** - Claude just sees a normal git repo checkout. The `{{PRD_PATH}}` is absolute so it works from any CWD. +7. **`gh` CLI is validated eagerly** - When the user enables `createPR`, chief checks for `gh` immediately (during onboarding and in settings) rather than waiting for completion to fail. Errors are graceful with actionable instructions. +8. **Merge conflicts show error in TUI** - The TUI can't do interactive merge resolution. When merge fails, show the error output and instruct the user to resolve in their terminal. +9. **Clean on running PRD is blocked** - The `c` keybinding is disabled (grayed out) for running PRDs. User must stop first. +10. **Settings TUI for post-onboarding changes** - Accessible via `,` from any view. All config values editable with immediate save. +11. **No new CLI subcommands** - Merge, clean, worktree listing, and settings are TUI-only. The CLI stays minimal: `new`, `edit`, `list`. This pushes users toward the TUI where they get full context and avoids duplicating UI surfaces. +12. **Transparency over magic** - Every TUI surface shows paths, branches, directories, and running processes. Users should always know what's happening and where. + +--- + +## TUI Mockups + +All mockups follow the existing modal pattern: centered rounded-border modals, `>` selection indicator, divider lines, footer with keybinding hints. + +### First-Time Setup (Extended) + +The existing flow adds two new steps after PRD name entry: + +**Step 3: Post-Completion Config** + +``` ++----------------------------------------------------------+ +| | +| ✓ Added .chief to .gitignore | +| ✓ PRD name: main | +| | +| Post-Completion Settings | +| --------------------------------------------------------| +| | +| When a PRD finishes all its stories: | +| | +| Push branch to remote? | +| > Yes (Recommended) | +| No | +| | +| Automatically create a pull request? | +| > Yes (Recommended) | +| No | +| | +| You can change these later with , or chief settings | +| | +| --------------------------------------------------------| +| Up/Down: Navigate Tab: Next field Enter: Confirm | +| | ++----------------------------------------------------------+ +``` + +When "Yes" is selected for PR creation, chief runs `gh auth status` before proceeding. If `gh` is not available or not authenticated, the `gh` CLI Required error dialog is shown (see `gh` CLI Validation section above). + +**Step 4: Worktree Setup Command** + +``` ++----------------------------------------------------------+ +| | +| ✓ Push on complete | +| ✓ Create PR on complete | +| | +| Worktree Setup Command | +| --------------------------------------------------------| +| | +| Command to run after creating a git worktree | +| (e.g., install dependencies) | +| | +| > Let Claude figure it out (Recommended) | +| Enter manually | +| Skip | +| | +| --------------------------------------------------------| +| Up/Down: Navigate Enter: Select | +| | ++----------------------------------------------------------+ +``` + +**Step 4b: Claude auto-detecting setup (after selecting "Let Claude figure it out")** + +``` ++----------------------------------------------------------+ +| | +| Worktree Setup Command | +| --------------------------------------------------------| +| | +| * Analyzing project for setup commands... | +| | ++----------------------------------------------------------+ +``` + +**Step 4c: Claude result** + +``` ++----------------------------------------------------------+ +| | +| Worktree Setup Command | +| --------------------------------------------------------| +| | +| Detected: npm install && npx prisma generate | +| | +| > Use this command (Recommended) | +| Edit | +| Skip | +| | +| --------------------------------------------------------| +| Up/Down: Navigate Enter: Select | +| | ++----------------------------------------------------------+ +``` + +**Step 4 (manual entry, if selected):** + +``` ++----------------------------------------------------------+ +| | +| Worktree Setup Command | +| --------------------------------------------------------| +| | +| Command to run after creating a worktree: | +| | +| +----------------------------------------------+ | +| | npm install_ | | +| +----------------------------------------------+ | +| | +| --------------------------------------------------------| +| Enter: Confirm Esc: Back | +| | ++----------------------------------------------------------+ +``` + +### Enhanced Branch Warning Dialog (Worktree Option) + +**On protected branch:** + +``` ++----------------------------------------------------------+ +| | +| ! Protected Branch Warning | +| --------------------------------------------------------| +| | +| You are on the 'main' branch. | +| Starting the loop will make changes to this branch. | +| | +| > Create worktree + branch 'chief/auth' (Recommended) | +| Claude will work in: .chief/worktrees/auth/ | +| Branch created from: main | +| | +| Create branch only 'chief/auth' | +| Claude will work in: ./ (current directory) | +| | +| Continue on 'main' | +| Not recommended for production branches | +| | +| Cancel | +| | +| --------------------------------------------------------| +| Up/Down: Navigate Enter: Select e: Edit branch name | +| | ++----------------------------------------------------------+ +``` + +**Another PRD already running:** + +``` ++----------------------------------------------------------+ +| | +| ! Parallel Execution | +| --------------------------------------------------------| +| | +| PRD 'payments' is already running in this directory. | +| Running another PRD here may cause file conflicts. | +| | +| > Create worktree + branch 'chief/auth' (Recommended) | +| Claude will work in: .chief/worktrees/auth/ | +| | +| Run in same directory | +| May cause conflicts with running PRD | +| | +| Cancel | +| | +| --------------------------------------------------------| +| Up/Down: Navigate Enter: Select e: Edit branch name | +| | ++----------------------------------------------------------+ +``` + +### Worktree Setup Spinner + +Shown after selecting "Create worktree" in the branch dialog: + +``` ++----------------------------------------------------------+ +| | +| Setting Up Worktree | +| --------------------------------------------------------| +| | +| ✓ Created branch 'chief/auth' from 'main' | +| ✓ Created worktree at .chief/worktrees/auth/ | +| * Running setup: npm install... | +| | +| --------------------------------------------------------| +| This may take a moment. Esc: Cancel | +| | ++----------------------------------------------------------+ +``` + +After setup completes (briefly shown before loop starts): + +``` ++----------------------------------------------------------+ +| | +| Setting Up Worktree | +| --------------------------------------------------------| +| | +| ✓ Created branch 'chief/auth' from 'main' | +| ✓ Created worktree at .chief/worktrees/auth/ | +| ✓ Setup complete: npm install | +| | +| Starting loop... | +| | ++----------------------------------------------------------+ +``` + +### Completion Screen + +**Auto-push + PR in progress:** + +``` ++----------------------------------------------------------+ +| | +| ✓ PRD Complete! auth 8/8 stories | +| --------------------------------------------------------| +| | +| Branch 'chief/auth' has 8 commits. | +| | +| ✓ Pushed chief/auth -> origin/chief/auth | +| * Creating pull request... | +| | ++----------------------------------------------------------+ +``` + +**Auto-actions finished:** + +``` ++----------------------------------------------------------+ +| | +| ✓ PRD Complete! auth 8/8 stories | +| --------------------------------------------------------| +| | +| Branch 'chief/auth' has 8 commits. | +| | +| ✓ Pushed chief/auth -> origin/chief/auth | +| ✓ PR #42: feat(auth): JWT authentication system | +| https://github.com/user/repo/pull/42 | +| | +| --------------------------------------------------------| +| m: Merge locally c: Clean worktree l: Switch PRD | +| q: Quit | +| | ++----------------------------------------------------------+ +``` + +**No auto-actions configured:** + +``` ++----------------------------------------------------------+ +| | +| ✓ PRD Complete! auth 8/8 stories | +| --------------------------------------------------------| +| | +| Branch 'chief/auth' has 8 commits. | +| Configure auto-push and PR in settings (,) | +| | +| --------------------------------------------------------| +| m: Merge locally c: Clean worktree l: Switch PRD | +| q: Quit | +| | ++----------------------------------------------------------+ +``` + +**Auto-action error (shown inline, non-blocking):** + +``` ++----------------------------------------------------------+ +| | +| ✓ PRD Complete! auth 8/8 stories | +| --------------------------------------------------------| +| | +| Branch 'chief/auth' has 8 commits. | +| | +| ✓ Pushed chief/auth -> origin/chief/auth | +| x PR creation failed: gh not found | +| Install: https://cli.github.com | +| | +| --------------------------------------------------------| +| m: Merge locally c: Clean worktree l: Switch PRD | +| q: Quit | +| | ++----------------------------------------------------------+ +``` + +### Merge Conflict Error + +When `m` (merge) encounters conflicts: + +``` ++----------------------------------------------------------+ +| | +| x Merge Conflict | +| --------------------------------------------------------| +| | +| Could not merge 'chief/auth' into 'main'. | +| | +| Conflicting files: | +| src/auth/handler.go | +| src/middleware/jwt.go | +| | +| Resolve conflicts in your terminal: | +| cd /path/to/project | +| git merge chief/auth | +| # resolve conflicts, then git commit | +| | +| --------------------------------------------------------| +| Enter/Esc: Dismiss | +| | ++----------------------------------------------------------+ +``` + +### Clean Confirmation Dialog + +When `c` (clean) is pressed for a completed PRD: + +``` ++----------------------------------------------------------+ +| | +| Clean Worktree | +| --------------------------------------------------------| +| | +| Remove worktree for 'auth'? | +| | +| Worktree: .chief/worktrees/auth/ (will be deleted) | +| Branch: chief/auth (will be deleted) | +| | +| > Remove worktree + delete branch (Recommended) | +| Remove worktree only (keep branch) | +| Cancel | +| | +| --------------------------------------------------------| +| Up/Down: Navigate Enter: Confirm | +| | ++----------------------------------------------------------+ +``` + +--- + +## Technical Gotchas + +### 1. `.chief/` is gitignored and won't exist in worktrees + +Since `.chief/` is gitignored, worktree checkouts won't have it. PRD files (`prd.json`, `progress.md`, `claude.log`) all live in `.chief/prds//` in the main repo only. This works because: +- The agent prompt uses `{{PRD_PATH}}` as an absolute path - Claude reads `prd.json` regardless of CWD +- The PRD watcher watches files in the main repo's `.chief/` directory + +### 2. Claude's working directory must change + +Currently: `l.claudeCmd.Dir = filepath.Dir(l.prdPath)` (sets CWD to `.chief/prds//`) +Must become: `l.claudeCmd.Dir = l.workDir` where `workDir` is the worktree path (or the project root for non-worktree PRDs) + +### 3. Dependencies must be installed per worktree + +Each worktree is a fresh checkout with no `node_modules/`, build artifacts, etc. Solved by the `worktree.setup` config in `.chief/config.yaml`. During onboarding, Claude can auto-detect the right command by analyzing the project's package managers and lock files. + +### 4. Disk space + +Each worktree is a full source checkout (git objects are shared via the `.git` dir). Large repos add up. Mitigated by `c` (clean) in the TUI after merge. + +### 5. Git lock contention + +Concurrent git operations across worktrees can occasionally hit lock files. Git worktrees are designed for this, but rapid concurrent commits could race. The Ralph loop's sequential model (one commit per iteration) makes this unlikely. + +### 6. Branch uniqueness (a feature, not a bug) + +Git enforces each worktree must be on a unique branch. Two worktrees cannot share a branch. This prevents two PRDs from stomping on each other's state. + +### 7. Orphaned worktrees on crash + +If chief crashes, worktrees remain on disk. Need: +- `c` keybinding in picker for manual removal +- Startup detection of orphaned worktrees +- Under the hood: `git worktree prune` cleans git's internal tracking + +### 8. Submodules + +Worktrees don't auto-init submodules. The setup command would need `git submodule update --init` if the project uses them. + +### 9. Stale worktree paths + +If `.chief/worktrees/` already exists from a previous run: +- Check if it's a valid worktree on the expected branch -> reuse it +- Otherwise remove and recreate + +### 10. Worktree inside the main repo's gitignored dir + +Placing worktrees at `.chief/worktrees/` means they're inside the main repo's tree but gitignored. Git handles this fine - the main repo won't track worktree contents. The worktree itself has its own index and HEAD pointing at the shared `.git` via a `.git` file (not directory). + +### 11. CLAUDE.md and project config + +If the project has a `CLAUDE.md` or other config at the repo root, the worktree will have its own copy (from the branch checkout). This is correct behavior - Claude should see the project config. + +### 12. Merging may produce conflicts + +If two PRDs modify overlapping files, merging their branches will conflict. Chief should report this clearly but not try to auto-resolve. + +--- + +## High-Level Implementation + +### Files to Create/Modify + +| File | Change | +|------|--------| +| `internal/git/worktree.go` | **New** - Worktree CRUD primitives | +| `internal/git/push.go` | **New** - Push branch, create PR via `gh`, CheckGHCLI validation | +| `internal/config/config.go` | **New** - Load/save `.chief/config.yaml` (YAML) | +| `internal/loop/loop.go` | Add `workDir` field, use it for `cmd.Dir` | +| `internal/loop/manager.go` | LoopInstance gets WorktreeDir, Branch fields; post-completion hooks | +| `internal/tui/app.go` | Enhanced start dialog, worktree creation flow, completion actions, `,` keybinding | +| `internal/tui/branch_warning.go` | Add worktree option to dialog with path transparency | +| `internal/tui/first_time_setup.go` | Extend with post-completion config + worktree setup steps + `gh` validation | +| `internal/tui/dashboard.go` | Show branch + worktree dir in header | +| `internal/tui/tabbar.go` | Show `[branch-name]` per tab | +| `internal/tui/picker.go` | Show worktree path/branch + `m`/`c` keybindings | +| `internal/tui/completion.go` | **New** - Completion screen with auto-action progress | +| `internal/tui/settings.go` | **New** - Settings editor overlay (`,` keybinding) with `gh` validation | +| `embed/detect_setup_prompt.txt` | **New** - Prompt for Claude to auto-detect worktree setup commands | +| `cmd/chief/main.go` | Load config on startup (no new subcommands) | + +### Step 1: Git Worktree Primitives + +`internal/git/worktree.go` - Shell out to git commands: + +```go +type Worktree struct { + Path string // Filesystem path + Branch string // Branch checked out + HEAD string // Current commit SHA + Prunable bool +} + +// Finds the default branch (main or master) +func GetDefaultBranch(repoDir string) (string, error) +// Creates branch from default branch (main/master), then creates worktree +func CreateWorktree(repoDir, worktreePath, branch string) error +// git worktree remove +func RemoveWorktree(repoDir, worktreePath string) error +// git worktree list --porcelain +func ListWorktrees(repoDir string) ([]Worktree, error) +// Check if path is a valid worktree +func IsWorktree(path string) bool +// Standard path: .chief/worktrees/ +func WorktreePathForPRD(baseDir, prdName string) string +// git worktree prune +func PruneWorktrees(repoDir string) error +// Merge a branch into current branch, returns conflict file list on failure +func MergeBranch(repoDir, branch string) ([]string, error) +``` + +### Step 2: Loop Changes + +`internal/loop/loop.go` - Add `workDir` to Loop: + +```go +type Loop struct { + prdPath string // Path to prd.json (for reading PRD state) + workDir string // Working directory for Claude (worktree or project root) + // ... rest unchanged +} +``` + +In `runIteration`, change line 256: +```go +// Before: +l.claudeCmd.Dir = filepath.Dir(l.prdPath) +// After: +l.claudeCmd.Dir = l.workDir +``` + +Add factory function: +```go +func NewLoopWithWorkDir(prdPath, workDir string, maxIter int) *Loop +``` + +`NewLoopWithEmbeddedPrompt` continues to work for non-worktree PRDs, deriving `workDir` from `prdPath` as today (or better: default to the project root). + +### Step 3: Manager Changes + +`internal/loop/manager.go` - Extend LoopInstance: + +```go +type LoopInstance struct { + Name string + PRDPath string + WorktreeDir string // Empty string = no worktree, use main repo + Branch string // Branch name (e.g., "chief/auth") + // ... rest unchanged +} +``` + +New method: +```go +func (m *Manager) RegisterWithWorktree(name, prdPath, worktreeDir, branch string) error +``` + +In `Start()`, pass `workDir` when creating the Loop: +```go +workDir := m.baseDir // default: project root +if instance.WorktreeDir != "" { + workDir = instance.WorktreeDir +} +instance.Loop = NewLoopWithWorkDir(instance.PRDPath, workDir, prompt, m.maxIter) +``` + +### Step 4: Config System (internal/config/config.go) + +Uses `gopkg.in/yaml.v3` for YAML parsing. + +```go +type Config struct { + Worktree WorktreeConfig `yaml:"worktree"` + OnComplete OnCompleteConfig `yaml:"onComplete"` +} + +type WorktreeConfig struct { + Setup string `yaml:"setup"` // e.g., "npm install" +} + +type OnCompleteConfig struct { + Push bool `yaml:"push"` // Auto-push branch to origin + CreatePR bool `yaml:"createPR"` // Auto-create PR after push +} + +func Load(baseDir string) (*Config, error) // Reads .chief/config.yaml +func Save(baseDir string, cfg *Config) error // Writes .chief/config.yaml +func Exists(baseDir string) bool // Check if config.yaml exists +func Default() *Config // Returns config with zero-value defaults +``` + +### Step 5: Git Push + PR + Validation (internal/git/push.go) + +```go +// Check if gh CLI is installed and authenticated +func CheckGHCLI() (installed bool, authenticated bool, err error) + +// Push a branch to origin +func PushBranch(dir, branch string) error + +// Create PR using gh CLI, returns PR URL +func CreatePR(dir, branch, title, body string) (string, error) + +// Generate PR title from PRD (conventional commits format) +func PRTitleFromPRD(p *prd.PRD) string + +// Generate PR body from PRD (summary + changes from stories, no test plan, no Claude mentions) +func PRBodyFromPRD(p *prd.PRD) string +``` + +`CheckGHCLI()` runs `gh auth status` and parses the exit code: +- Exit 0: installed and authenticated +- Exit non-zero or command not found: not ready + +PR generation reads the PRD directly - no Claude invocation needed: +- Title: `feat(): ` (conventional commits) +- Body: `## Summary\n\n\n## Changes\n` + +### Step 6: TUI Integration + +**`internal/tui/first_time_setup.go`** - Extended with two new steps: + +The existing flow (gitignore -> PRD name) gains two additional steps: +- `StepPostCompletion` - Two yes/no toggles: push to remote, create PR. Uses same `>` selection pattern as gitignore step. When PR is selected, runs `CheckGHCLI()` and shows error dialog if validation fails. +- `StepWorktreeSetup` - Three options: "Let Claude figure it out" / "Enter manually" / "Skip". If Claude is selected, runs a one-shot Claude Code invocation to analyze the project and detect setup commands. Shows spinner while Claude runs, then presents result for confirmation/editing. + +`FirstTimeSetupResult` gains new fields: `PushOnComplete bool`, `CreatePROnComplete bool`, `WorktreeSetup string`. After setup completes, `main.go` saves these to `.chief/config.yaml`. + +**`internal/tui/app.go`** - Enhanced start flow: + +The `startLoop()` method becomes: +1. Check context: protected branch? another PRD running in same directory? +2. Show enhanced dialog with worktree option (see mockups above) +3. If worktree selected: + a. Create branch `chief/` from default branch (main/master) + b. Create worktree at `.chief/worktrees//` on that branch + c. Run setup command if configured (show spinner: "Setting up worktree...") + d. Register instance with worktree path +4. Start the loop +5. On completion: run auto-actions from config (push, create PR) + +Add `,` keybinding handler to show settings overlay from any view. + +**`internal/tui/completion.go`** - Completion screen: + +When a PRD completes (`EventComplete` received): +1. Automatically run configured actions (push, create PR) - show progress inline +2. Display results (success or error for each auto-action) with full paths and URLs +3. Show remaining manual action keybindings: + - `m` - Merge branch locally + - `c` - Clean worktree + delete branch + - `l` - Switch to another PRD + - `q` - Quit + +**`internal/tui/settings.go`** - New - Settings editor overlay: + +Modal view accessible via `,` from any view. Renders all config values as an editable list. Booleans toggle on Enter (with `gh` validation on `createPR` toggle). Strings open inline text input. Saves to `.chief/config.yaml` on every change. Shows config file path in header. + +**`internal/tui/branch_warning.go`** - Add "Create worktree + branch" as first option. Each option shows where Claude will work (path transparency). + +**`internal/tui/dashboard.go`** - Add branch/worktree directory line to header section. + +**`internal/tui/tabbar.go`** - Show `[branch-name]` next to PRD name in tabs. + +**`internal/tui/picker.go`** - Show worktree path and branch in PRD entries. Action keybindings for completed PRDs: +- `m` - Merge selected PRD's branch +- `c` - Clean selected PRD's worktree + +--- + +## Documentation Updates + +All docs under `docs/` need updates to reflect the new features. + +| Doc File | Changes | +|----------|---------| +| `docs/reference/cli.md` | Keep `new`, `edit`, `list` commands. Add keyboard shortcuts for new TUI features: `,` (settings), `m` (merge), `c` (clean). Update TUI keyboard shortcuts section with worktree-related keybindings. | +| `docs/reference/configuration.md` | **Major rewrite.** Document `.chief/config.yaml` format, all config keys (`worktree.setup`, `onComplete.push`, `onComplete.createPR`), the Settings TUI (`,` key), first-time setup flow, and Claude auto-detect for setup commands. Replaces the "No Global Config" section. | +| `docs/concepts/chief-directory.md` | Add `worktrees/` subdirectory and `config.yaml` to the directory structure diagram. Explain worktree layout (`.chief/worktrees//`), that worktrees are full checkouts sharing git objects. Add `config.yaml` to the file explanations section. | +| `docs/concepts/how-it-works.md` | Add section on git worktrees for parallel PRD execution. Update the execution loop to mention worktree isolation. Add note about auto-push and PR creation on completion. | +| `docs/concepts/ralph-loop.md` | Update the loop flowchart to show the optional worktree creation before "Press 's'" and the post-completion actions (push, PR) after "Done". Add section on working directory isolation. | +| `docs/guide/quick-start.md` | Add coverage of the first-time setup config prompts (push/PR/worktree setup). Mention the settings TUI. Update keyboard controls table with new keybindings (`,`, `m`, `c`). | +| `docs/guide/installation.md` | Add optional prerequisite: `gh` CLI for auto-PR creation with link to https://cli.github.com. | +| `docs/troubleshooting/common-issues.md` | Add sections: "Worktree Setup Failed", "PR Creation Failed (gh not found)", "Orphaned Worktrees", "Merge Conflicts". | +| `docs/troubleshooting/faq.md` | Add: "How do worktrees work?", "Can I run multiple PRDs in parallel safely?", "How do I merge a completed PRD?", "How do I clean up worktrees?", "What is .chief/config.yaml?". | +| `docs/adr/0007-git-worktree-isolation.md` | **New** - ADR documenting the decision to use git worktrees for parallel PRD isolation, alternatives considered (separate clones, docker, single-branch parallel), and trade-offs. | + +### Sidebar Config + +`docs/.vitepress/config.ts` - No structural changes needed. Existing sidebar sections cover all updated pages. The new ADR is auto-discovered by the ADR index page. + +--- + +## Verification + +1. **Single PRD, no worktree:** `chief test` -> press `s` -> choose "current directory" -> works as today +2. **Single PRD, with worktree:** `chief test` -> press `s` -> choose "create worktree" -> Claude works in `.chief/worktrees/test/` +3. **Two parallel PRDs:** Start auth, then start payments -> second gets worktree dialog emphasizing isolation -> both run independently +4. **Worktree reuse:** Stop a PRD, restart it -> detects existing worktree, reuses it +5. **First-run config:** Delete `.chief/config.yaml` -> start chief -> prompted for push/PR/setup preferences -> config saved as YAML +6. **Claude auto-detect setup:** During onboarding, select "Let Claude figure it out" -> Claude analyzes project -> detected command shown for confirmation -> saved to config +7. **`gh` validation (onboarding):** Enable PR creation without `gh` installed -> error dialog shown -> option to continue without or retry +8. **`gh` validation (settings):** Toggle createPR to Yes without `gh` -> error shown, toggle reverts +9. **Auto-push + PR:** Configure push + PR -> complete a PRD -> branch pushed + PR created automatically without user interaction +10. **Auto-action errors:** Configure PR but no `gh` installed -> complete a PRD -> shows error inline on completion screen, doesn't block +11. **Settings TUI:** Press `,` -> settings overlay opens -> toggle a boolean -> verify `config.yaml` updated -> press Esc -> back to dashboard +12. **Picker actions:** Open picker -> select completed PRD -> press `m` to merge, `c` to clean -> verify each works +13. **Crash recovery:** Kill chief -> restart -> detect orphaned worktrees +14. **Conflict merge:** Two PRDs edit same file -> merge reports conflict clearly with file list and terminal instructions +15. **PR format:** Verify PR title follows conventional commits, body has summary + changes, no Claude mentions +16. **Walk-away test:** Configure push + PR, start a PRD, leave -> PRD completes, pushes, creates PR all unattended +17. **Transparency check:** At every stage, verify the TUI shows what directory, branch, and path is involved - nothing should feel hidden or magical +18. **No new CLI commands:** Verify `chief merge`, `chief clean`, `chief worktrees`, `chief settings` do NOT exist as subcommands +19. **Docs accuracy:** All documentation pages reflect the new features, config format, and keyboard shortcuts accurately diff --git a/cmd/chief/main.go b/cmd/chief/main.go index 11e13d3..fca1f4a 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/minicodemonkey/chief/internal/cmd" + "github.com/minicodemonkey/chief/internal/config" "github.com/minicodemonkey/chief/internal/git" "github.com/minicodemonkey/chief/internal/notify" "github.com/minicodemonkey/chief/internal/prd" @@ -302,6 +303,15 @@ func runTUIWithOptions(opts *TUIOptions) { return } + // Save config from setup + cfg := config.Default() + cfg.OnComplete.Push = result.PushOnComplete + cfg.OnComplete.CreatePR = result.CreatePROnComplete + cfg.Worktree.Setup = result.WorktreeSetup + if err := config.Save(cwd, cfg); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to save config: %v\n", err) + } + // Create the PRD newOpts := cmd.NewOptions{ Name: result.PRDName, diff --git a/docs/adr/0007-git-worktree-isolation.md b/docs/adr/0007-git-worktree-isolation.md new file mode 100644 index 0000000..150885b --- /dev/null +++ b/docs/adr/0007-git-worktree-isolation.md @@ -0,0 +1,103 @@ +# ADR-0007: Git Worktree Isolation for Parallel PRDs + +## Status + +Accepted + +## Context + +Chief supports running multiple PRDs in parallel via the Loop Manager. However, all PRDs share the same working directory and git state. This causes several problems when parallel Claude instances: + +1. **File conflicts**: Two instances editing the same file simultaneously produce corrupted or overwritten content +2. **Interleaved commits**: Commits from different PRDs are mixed in the same branch's history +3. **Branch conflicts**: All instances work on the same branch, making it impossible to review or merge PRD work independently + +We considered three approaches: + +1. **Locking**: Serialize access to files and git operations. Simple but eliminates parallelism. +2. **Separate clones**: Clone the repo for each PRD. Provides isolation but wastes disk space and has slow setup. +3. **Git worktrees**: Create lightweight worktrees for each PRD. Full isolation with minimal overhead. + +## Decision + +Use git worktrees to isolate parallel PRD execution. Each PRD gets: +- A dedicated branch (`chief/`) +- A worktree at `.chief/worktrees//` +- An optional setup command to install dependencies in the worktree + +Additionally, add post-completion automation: +- Automatic branch push to remote +- Automatic PR creation via `gh` CLI +- A Settings TUI (`,`) for managing these options + +## Implementation + +### Worktree Lifecycle + +1. **Creation**: When a user starts a PRD, the TUI offers to create a worktree. Chief creates the branch from the default branch and sets up the worktree via `git worktree add`. +2. **Usage**: The Loop runs Claude Code with the worktree as the working directory. All file operations and commits happen in isolation. +3. **Completion**: When the PRD finishes, auto-push and auto-PR actions run if configured. +4. **Cleanup**: Users can merge the branch (`m`) and clean the worktree (`c`) from the picker. + +### Reuse and Stale Detection + +`CreateWorktree` handles edge cases: +- If a worktree already exists on the expected branch, it is reused +- If a worktree exists but is stale (wrong branch or invalid), it is removed and recreated +- Orphaned worktrees from crashed sessions are detected on startup + +### Configuration + +Settings are stored in `.chief/config.yaml`: + +```yaml +worktree: + setup: "npm install" # Command to run in new worktrees +onComplete: + push: true # Auto-push branch on completion + createPR: true # Auto-create PR on completion +``` + +### UI Integration + +- Tab bar shows branch names: `auth [chief/auth] > 3/8` +- Dashboard header shows working directory and branch +- Picker shows branch and worktree path per PRD +- Completion screen shows auto-action progress and results +- Settings overlay (`,`) for live config editing + +## Rationale + +1. **Git worktrees are lightweight**: They share the object store with the main repo. Creating a worktree is nearly instant compared to cloning. + +2. **Full isolation**: Each worktree has its own working tree, index, and HEAD. Parallel Claude instances cannot interfere with each other. + +3. **Clean git history**: Each PRD's commits live on a separate branch, making code review and merging straightforward. + +4. **User control**: Worktrees are optional. Users who run one PRD at a time can skip them entirely. + +5. **Self-cleaning**: Orphaned worktrees are detected on startup. Users manage cleanup explicitly via the picker to avoid accidental data loss. + +## Consequences + +### Positive + +- Multiple PRDs can run truly in parallel without conflicts +- Each PRD has a clean, reviewable branch +- Post-completion automation enables "start and walk away" workflows +- Backward compatible — single-PRD usage works unchanged + +### Negative + +- Disk usage increases (each worktree is a full checkout minus the object store) +- Setup commands (e.g., `npm install`) add time to worktree creation +- Users must understand basic git branch concepts (merge, conflicts) +- `gh` CLI is a new optional dependency for PR creation + +## References + +- [git-worktree documentation](https://git-scm.com/docs/git-worktree) +- `internal/git/worktree.go` — Worktree CRUD operations +- `internal/git/push.go` — Push and PR primitives +- `internal/config/config.go` — Configuration system +- `internal/tui/settings.go` — Settings TUI overlay diff --git a/docs/concepts/chief-directory.md b/docs/concepts/chief-directory.md index b454a74..961c623 100644 --- a/docs/concepts/chief-directory.md +++ b/docs/concepts/chief-directory.md @@ -15,15 +15,21 @@ your-project/ ├── src/ ├── package.json └── .chief/ - └── prds/ - └── my-feature/ - ├── prd.md # Human-readable PRD (you write this) - ├── prd.json # Machine-readable PRD (Chief reads/writes) - ├── progress.md # Progress log (Chief appends after each story) - └── claude.log # Raw Claude output (for debugging) + ├── config.yaml # Project settings (worktree, auto-push, PR) + ├── prds/ + │ └── my-feature/ + │ ├── prd.md # Human-readable PRD (you write this) + │ ├── prd.json # Machine-readable PRD (Chief reads/writes) + │ ├── progress.md # Progress log (Chief appends after each story) + │ └── claude.log # Raw Claude output (for debugging) + └── worktrees/ # Isolated checkouts for parallel PRDs + └── my-feature/ # Git worktree (full project checkout) ``` -The root `.chief/` directory contains a single `prds/` subdirectory. Each PRD gets its own folder inside `prds/`, named after the feature or initiative. +The root `.chief/` directory contains: +- `config.yaml` — Project-level settings (see [Configuration](/reference/configuration)) +- `prds/` — One subdirectory per PRD with requirements, state, and logs +- `worktrees/` — Git worktrees for parallel PRD isolation (created on demand) ## The `prds/` Subdirectory @@ -106,6 +112,39 @@ Raw output from Claude Code during execution. This file captures everything Clau This file can get large (multiple megabytes per run) and is regenerated on each execution. You typically don't need to read it unless you're investigating an issue. +## The `worktrees/` Subdirectory + +When you run multiple PRDs in parallel, each PRD can get its own isolated git worktree under `.chief/worktrees/`. A worktree is a full checkout of your project on a separate branch, so parallel Claude instances never conflict over files or git state. + +``` +.chief/worktrees/ +├── auth-system/ # Full checkout on branch chief/auth-system +└── payment-integration/ # Full checkout on branch chief/payment-integration +``` + +Worktrees are created when you choose "Create worktree + branch" from the start dialog. Each worktree: +- Has its own branch (named `chief/`) +- Is a complete copy of your project +- Runs the configured setup command (e.g., `npm install`) automatically + +You can merge completed branches via `m` in the picker, and clean up worktrees via `c`. + +For more details, see [ADR-0007: Git Worktree Isolation](/adr/0007-git-worktree-isolation). + +## The `config.yaml` File + +Project-level settings are stored in `.chief/config.yaml`. This file is created during first-time setup or when you change settings via the Settings TUI (`,`). + +```yaml +worktree: + setup: "npm install" +onComplete: + push: true + createPR: true +``` + +See [Configuration](/reference/configuration) for all available settings. + ## Self-Contained by Design Chief has no global configuration. There is no `~/.chiefrc`, no `~/.config/chief/`, no environment variables required. Every piece of state Chief needs is inside `.chief/`. @@ -143,19 +182,23 @@ A single project can have multiple PRDs, each tracking a separate feature or ini ``` .chief/ -└── prds/ +├── config.yaml +├── prds/ +│ ├── auth-system/ +│ │ ├── prd.md +│ │ ├── prd.json +│ │ └── progress.md +│ ├── payment-integration/ +│ │ ├── prd.md +│ │ ├── prd.json +│ │ └── progress.md +│ └── admin-dashboard/ +│ ├── prd.md +│ ├── prd.json +│ └── progress.md +└── worktrees/ ├── auth-system/ - │ ├── prd.md - │ ├── prd.json - │ └── progress.md - ├── payment-integration/ - │ ├── prd.md - │ ├── prd.json - │ └── progress.md - └── admin-dashboard/ - ├── prd.md - ├── prd.json - └── progress.md + └── payment-integration/ ``` Run a specific PRD by name: @@ -165,7 +208,7 @@ chief auth-system chief payment-integration ``` -Each PRD tracks its own stories, progress, and logs independently. You can run them sequentially or work on different PRDs over time as your project evolves. +Each PRD tracks its own stories, progress, and logs independently. When running multiple PRDs in parallel, each gets its own git worktree and branch for full isolation. You can run them simultaneously without worrying about file conflicts or interleaved commits. ## Git Considerations diff --git a/docs/concepts/how-it-works.md b/docs/concepts/how-it-works.md index 92d8436..a15883c 100644 --- a/docs/concepts/how-it-works.md +++ b/docs/concepts/how-it-works.md @@ -95,6 +95,22 @@ The `progress.md` file is what makes fresh context windows possible. After every When the next iteration starts, Claude reads this file and immediately understands the project's history, without needing thousands of tokens of prior conversation. This gives you the benefits of long-running context (consistency, institutional memory) without the downsides (context overflow, degraded performance). +## Worktree Isolation for Parallel PRDs + +When running multiple PRDs simultaneously, each PRD can work in its own isolated git worktree. This prevents parallel Claude instances from conflicting over files, producing interleaved commits, or stepping on each other's branches. + +When you start a PRD, Chief offers to create a worktree: +- A new branch is created (e.g., `chief/auth-system`) from your default branch +- A worktree is set up at `.chief/worktrees//` +- Any configured setup command runs automatically (e.g., `npm install`) + +Each worktree is a full checkout of your project, so Claude can read, write, and run tests independently. When the PRD completes, you can merge the branch back, push it to a remote, or have Chief automatically create a pull request. + +The TUI shows branch and directory information throughout: +- **Tab bar**: Branch name next to each PRD tab +- **Dashboard header**: Current branch and working directory +- **PRD picker**: Branch and worktree path for each PRD + ## Staying in Control Autonomous doesn't mean unattended. The TUI lets you: @@ -102,6 +118,9 @@ Autonomous doesn't mean unattended. The TUI lets you: - **Start / Pause / Stop**: Press `s` to start, `p` to pause after the current story, `x` to stop immediately - **Switch projects**: Press `n` to cycle through projects, or `1-9` to jump directly - **Resume anytime**: Walk away, come back, press `s`. Chief picks up where you left off +- **Merge branches**: Press `m` in the picker to merge a completed branch +- **Clean worktrees**: Press `c` in the picker to remove a worktree and optionally delete the branch +- **Configure settings**: Press `,` to open the Settings overlay ## Further Reading diff --git a/docs/concepts/ralph-loop.md b/docs/concepts/ralph-loop.md index af83a02..6e440c1 100644 --- a/docs/concepts/ralph-loop.md +++ b/docs/concepts/ralph-loop.md @@ -65,6 +65,18 @@ Here's the complete Ralph Loop as a flowchart: └─────────────┘ ``` +## Before the Loop: Worktree Setup + +Before the loop starts, Chief sets up the working environment. When you press `s` to start a PRD, the TUI shows a dialog offering to create an isolated worktree: + +1. **Create branch** — A new branch (e.g., `chief/auth-system`) is created from your default branch +2. **Create worktree** — A git worktree is set up at `.chief/worktrees//` +3. **Run setup** — If a setup command is configured (e.g., `npm install`), it runs in the worktree + +This setup happens once per PRD. The loop then runs entirely within the worktree directory, isolating all file changes and commits to that branch. + +You can also skip worktree creation and run in the current directory if you prefer. + ## Step by Step Each step in the loop has a specific purpose. Here's what happens in each one. @@ -196,6 +208,23 @@ If you hit the limit, it usually means: You can adjust the limit with the `--max-iterations` flag or in your configuration. +## Post-Completion Actions + +When all stories in a PRD are complete, Chief can automatically: + +1. **Push the branch** — If `onComplete.push` is enabled in `.chief/config.yaml`, Chief pushes the branch to origin +2. **Create a pull request** — If `onComplete.createPR` is also enabled, Chief creates a PR via the `gh` CLI with a title and body generated from the PRD + +The completion screen shows the progress of these actions with spinners, checkmarks, or error messages. On PR success, the PR URL is displayed and clickable. + +If auto-actions aren't configured, the completion screen shows a hint to configure them via the Settings TUI (`,`). + +You can also take manual actions from the completion screen: +- `m` — Merge the branch locally +- `c` — Clean up the worktree +- `l` — Switch to another PRD +- `q` — Quit Chief + ## Why "Ralph"? The name comes from [Ralph Wiggum loops](https://ghuntley.com/ralph/), a pattern coined by Geoffrey Huntley. The idea: instead of fighting context window limits with one long session, you run the AI in a loop. Each iteration starts fresh but reads persisted state from the previous run. diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 6e2aa73..642adaf 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -31,6 +31,23 @@ npx @anthropic-ai/claude-code login Run `claude --version` to confirm Claude Code is installed. Chief will not work without it. ::: +### Optional: GitHub CLI (`gh`) + +If you want Chief to automatically create pull requests when a PRD completes, install the [GitHub CLI](https://cli.github.com/): + +```bash +# macOS +brew install gh + +# Linux +# See https://github.com/cli/cli/blob/trunk/docs/install_linux.md + +# Authenticate +gh auth login +``` + +The `gh` CLI is only required for automatic PR creation. All other features work without it. + ## Homebrew (Recommended) The easiest way to install Chief on **macOS** or **Linux**: diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index 9611274..52b9fd3 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -78,11 +78,16 @@ Launch Chief's Terminal User Interface: chief ``` -This opens the TUI dashboard in **Ready** state. The loop doesn't start automatically—you control when it begins. +On first launch, Chief prompts you to configure a few settings: + +1. **Post-completion automation** — Whether to automatically push branches and create PRs when a PRD completes (recommended: Yes for both) +2. **Worktree setup command** — A command to run in new worktrees (e.g., `npm install`). You can let Claude detect it automatically, enter it manually, or skip + +These settings are saved to `.chief/config.yaml` and can be changed anytime via the Settings TUI (press `,`). ## Step 4: Start the Loop -Press `s` to start the Ralph Loop. Chief will begin working through your stories automatically. +Press `s` to start the Ralph Loop. Chief will offer to create a worktree for isolated development, then begin working through your stories automatically. The TUI shows: @@ -103,6 +108,9 @@ The TUI shows: | `1-9` | **Quick switch** to PRD tabs 1-9 | | `j/↓` | Navigate down (stories or scroll log) | | `k/↑` | Navigate up (stories or scroll log) | +| `m` | **Merge** completed branch (in picker) | +| `c` | **Clean** worktree (in picker) | +| `,` | Open **Settings** | | `?` | Show **help** overlay | | `q` | **Quit** Chief | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 759f254..3b739e8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -229,6 +229,14 @@ When Chief is running, the TUI provides real-time feedback and interactive contr | `n` | Open **PRD picker** (switch PRDs or create new) | | `1-9` | **Quick switch** to PRD tabs 1-9 | | `e` | **Edit** selected PRD (in picker) | +| `m` | **Merge** completed PRD's branch into main (in picker or completion screen) | +| `c` | **Clean** worktree and optionally delete branch (in picker or completion screen) | + +### Settings + +| Key | Action | +|-----|--------| +| `,` | Open **Settings** overlay (from any view) | ### Navigation diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5624403..5954ebf 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,10 +1,83 @@ --- -description: Chief configuration reference. CLI flags and settings for customizing Chief's behavior. +description: Chief configuration reference. Project config file, CLI flags, Settings TUI, and first-time setup flow. --- # Configuration -Chief is designed to work with zero configuration. All state lives in `.chief/` and settings are passed via CLI flags. +Chief uses a project-level configuration file at `.chief/config.yaml` for persistent settings, plus CLI flags for per-run options. + +## Config File (`.chief/config.yaml`) + +Chief stores project-level settings in `.chief/config.yaml`. This file is created automatically during first-time setup or when you change settings via the Settings TUI. + +### Format + +```yaml +worktree: + setup: "npm install" +onComplete: + push: true + createPR: true +``` + +### Config Keys + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `worktree.setup` | string | `""` | Shell command to run in new worktrees (e.g., `npm install`, `go mod download`) | +| `onComplete.push` | bool | `false` | Automatically push the branch to remote when a PRD completes | +| `onComplete.createPR` | bool | `false` | Automatically create a pull request when a PRD completes (requires `gh` CLI) | + +### Example Configurations + +**Minimal (defaults):** + +```yaml +worktree: + setup: "" +onComplete: + push: false + createPR: false +``` + +**Full automation:** + +```yaml +worktree: + setup: "npm install && npm run build" +onComplete: + push: true + createPR: true +``` + +## Settings TUI + +Press `,` from any view in the TUI to open the Settings overlay. This provides an interactive way to view and edit all config values. + +Settings are organized by section: + +- **Worktree** — Setup command (string, editable inline) +- **On Complete** — Push to remote (toggle), Create pull request (toggle) + +Changes are saved immediately to `.chief/config.yaml` on every edit. + +When toggling "Create pull request" to Yes, Chief validates that the `gh` CLI is installed and authenticated. If validation fails, the toggle reverts and an error message is shown with installation instructions. + +Navigate with `j`/`k` or arrow keys. Press `Enter` to toggle booleans or edit strings. Press `Esc` to close. + +## First-Time Setup + +When you launch Chief for the first time in a project, you'll be prompted to configure: + +1. **Post-completion settings** — Whether to automatically push branches and create PRs when a PRD completes +2. **Worktree setup command** — A shell command to run in new worktrees (e.g., installing dependencies) + +For the setup command, you can: +- **Let Claude figure it out** (Recommended) — Claude analyzes your project and suggests appropriate setup commands +- **Enter manually** — Type a custom command +- **Skip** — Leave it empty + +These settings are saved to `.chief/config.yaml` and can be changed at any time via the Settings TUI (`,`). ## CLI Flags @@ -39,11 +112,3 @@ Chief runs Claude with full permissions to modify your codebase. Only run Chief For additional isolation, consider using [Claude Code's sandbox mode](https://docs.anthropic.com/en/docs/claude-code/sandboxing) or running Chief in a Docker container. ::: - -## No Global Config - -Intentionally, Chief has no global configuration file. This ensures: - -1. **Portability**: Project works the same on any machine -2. **Reproducibility**: No hidden state affecting behavior -3. **Simplicity**: One place to look for all settings diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md index 8af8285..65017a6 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -161,6 +161,91 @@ chief --no-sound - Missing quotes around keys - Unescaped characters in strings +## Worktree Setup Failures + +**Symptom:** Worktree creation fails when starting a PRD. + +**Cause:** The branch already exists, the worktree path is in use, or git state is corrupted. + +**Solution:** + +1. Chief automatically handles common cases (reuses valid worktrees, cleans stale ones). If it still fails: + +2. Manually clean up: + ```bash + # Remove the worktree + git worktree remove .chief/worktrees/ --force + + # Delete the branch if needed + git branch -D chief/ + + # Prune git's worktree tracking + git worktree prune + ``` + +3. Restart Chief and try again + +## PR Creation Failures + +**Symptom:** Auto-PR creation fails after a PRD completes. + +**Cause:** `gh` CLI not installed, not authenticated, or network issues. + +**Solution:** + +1. Verify `gh` is installed and authenticated: + ```bash + gh auth status + ``` + +2. If not installed, get it from [cli.github.com](https://cli.github.com/) + +3. If not authenticated: + ```bash + gh auth login + ``` + +4. You can also create the PR manually: + ```bash + git push -u origin chief/ + gh pr create --title "feat: " --body "..." + ``` + +5. Auto-PR can be disabled in Settings (`,`) — push-only mode still works + +## Orphaned Worktrees + +**Symptom:** The picker shows entries marked `[orphaned]` or `[orphaned worktree]`. + +**Cause:** A previous Chief session crashed or was terminated without cleaning up its worktree. + +**Solution:** + +1. Orphaned worktrees are harmless but take disk space +2. Select the orphaned entry in the picker and press `c` to clean it up +3. Choose "Remove worktree + delete branch" or "Remove worktree only" as appropriate + +Chief automatically prunes git's internal worktree tracking on startup, but does not auto-delete worktree directories to avoid data loss. + +## Merge Conflicts + +**Symptom:** Merging a completed branch fails with conflict list. + +**Cause:** The PRD's branch has changes that conflict with the target branch. + +**Solution:** + +1. Chief shows the list of conflicting files in the merge result dialog +2. Resolve conflicts manually in a terminal: + ```bash + cd /path/to/project + git merge chief/ + # Resolve conflicts in the listed files + git add . + git commit + ``` +3. Or push the branch and resolve via a pull request on GitHub + ## Still Stuck? If none of these solutions help: diff --git a/docs/troubleshooting/faq.md b/docs/troubleshooting/faq.md index 33ccaa0..c2d1282 100644 --- a/docs/troubleshooting/faq.md +++ b/docs/troubleshooting/faq.md @@ -74,6 +74,30 @@ Mark it as passed manually: Or remove it from the PRD entirely. +### What are worktrees? + +Git worktrees let you have multiple checkouts of a repository at the same time, each on a different branch. Chief uses worktrees to isolate parallel PRDs so they don't interfere with each other's files or commits. Each worktree lives at `.chief/worktrees//`. + +### Do I have to use worktrees? + +No. When you start a PRD, Chief offers worktree creation as an option. You can choose "Run in current directory" to skip it. Worktrees are most useful when running multiple PRDs simultaneously. + +### How do I merge a completed branch? + +Press `n` to open the PRD picker, select the completed PRD, and press `m` to merge. If there are conflicts, Chief shows the conflicting files and instructions for manual resolution. + +### How do I clean up a worktree? + +Press `n` to open the PRD picker, select the PRD, and press `c`. You can choose to remove just the worktree or remove the worktree and delete the branch. + +### What happens if Chief crashes mid-worktree? + +Chief detects orphaned worktrees on startup and marks them in the picker. You can clean them up with `c`. Your work on the branch is preserved — git worktrees are just directories with a separate checkout. + +### Can I automatically push and create PRs? + +Yes. During first-time setup, Chief asks if you want to enable auto-push and auto-PR creation. You can also toggle these in the Settings TUI (`,`). Auto-PR requires the `gh` CLI to be installed and authenticated. + ## Technical ### Why stream-json? diff --git a/embed/detect_setup_prompt.txt b/embed/detect_setup_prompt.txt new file mode 100644 index 0000000..1f22ea3 --- /dev/null +++ b/embed/detect_setup_prompt.txt @@ -0,0 +1,19 @@ +Analyze this project and determine what commands are needed to set up a new working copy (git worktree) for development. + +Look at: +- Package manager files (package.json, go.mod, Cargo.toml, requirements.txt, Gemfile, etc.) +- Build configuration files +- Any setup scripts or Makefiles + +Return ONLY the shell command(s) needed to install dependencies and prepare the project for development. Multiple commands should be joined with `&&`. + +Examples: +- Node.js project: `npm install` +- Go project: `go mod download` +- Python project: `pip install -r requirements.txt` +- Rust project: `cargo build` +- Mixed project: `npm install && go mod download` + +If no setup is needed, return: `echo "No setup needed"` + +IMPORTANT: Return ONLY the command string, nothing else. No explanation, no markdown, no code blocks. Just the raw command. \ No newline at end of file diff --git a/embed/embed.go b/embed/embed.go index d2430b0..8112974 100644 --- a/embed/embed.go +++ b/embed/embed.go @@ -19,6 +19,9 @@ var editPromptTemplate string //go:embed convert_prompt.txt var convertPromptTemplate string +//go:embed detect_setup_prompt.txt +var detectSetupPromptTemplate string + // GetPrompt returns the agent prompt with the PRD path substituted. func GetPrompt(prdPath string) string { return strings.ReplaceAll(promptTemplate, "{{PRD_PATH}}", prdPath) @@ -42,3 +45,8 @@ func GetEditPrompt(prdDir string) string { func GetConvertPrompt(prdDir string) string { return strings.ReplaceAll(convertPromptTemplate, "{{PRD_DIR}}", prdDir) } + +// GetDetectSetupPrompt returns the prompt for detecting project setup commands. +func GetDetectSetupPrompt() string { + return detectSetupPromptTemplate +} diff --git a/go.mod b/go.mod index 2d5ba12..99005a4 100644 --- a/go.mod +++ b/go.mod @@ -30,4 +30,5 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.3.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 65a73dc..fef6007 100644 --- a/go.sum +++ b/go.sum @@ -51,3 +51,6 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6f43ec0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,81 @@ +package config + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const configFile = ".chief/config.yaml" + +// Config holds project-level settings for Chief. +type Config struct { + Worktree WorktreeConfig `yaml:"worktree"` + OnComplete OnCompleteConfig `yaml:"onComplete"` +} + +// WorktreeConfig holds worktree-related settings. +type WorktreeConfig struct { + Setup string `yaml:"setup"` +} + +// OnCompleteConfig holds post-completion automation settings. +type OnCompleteConfig struct { + Push bool `yaml:"push"` + CreatePR bool `yaml:"createPR"` +} + +// Default returns a Config with zero-value defaults. +func Default() *Config { + return &Config{} +} + +// configPath returns the full path to the config file. +func configPath(baseDir string) string { + return filepath.Join(baseDir, configFile) +} + +// Exists checks if the config file exists. +func Exists(baseDir string) bool { + _, err := os.Stat(configPath(baseDir)) + return err == nil +} + +// Load reads the config from .chief/config.yaml. +// Returns Default() when the file doesn't exist (no error). +func Load(baseDir string) (*Config, error) { + path := configPath(baseDir) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return Default(), nil + } + return nil, err + } + + cfg := Default() + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + + return cfg, nil +} + +// Save writes the config to .chief/config.yaml. +func Save(baseDir string, cfg *Config) error { + path := configPath(baseDir) + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0o644) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..dacee39 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefault(t *testing.T) { + cfg := Default() + if cfg.Worktree.Setup != "" { + t.Errorf("expected empty setup, got %q", cfg.Worktree.Setup) + } + if cfg.OnComplete.Push { + t.Error("expected Push to be false") + } + if cfg.OnComplete.CreatePR { + t.Error("expected CreatePR to be false") + } +} + +func TestLoadNonExistent(t *testing.T) { + cfg, err := Load(t.TempDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Worktree.Setup != "" { + t.Errorf("expected empty setup, got %q", cfg.Worktree.Setup) + } +} + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + + cfg := &Config{ + Worktree: WorktreeConfig{ + Setup: "npm install", + }, + OnComplete: OnCompleteConfig{ + Push: true, + CreatePR: true, + }, + } + + if err := Save(dir, cfg); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := Load(dir) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if loaded.Worktree.Setup != "npm install" { + t.Errorf("expected setup %q, got %q", "npm install", loaded.Worktree.Setup) + } + if !loaded.OnComplete.Push { + t.Error("expected Push to be true") + } + if !loaded.OnComplete.CreatePR { + t.Error("expected CreatePR to be true") + } +} + +func TestExists(t *testing.T) { + dir := t.TempDir() + + if Exists(dir) { + t.Error("expected Exists to return false for missing config") + } + + // Create the config + chiefDir := filepath.Join(dir, ".chief") + if err := os.MkdirAll(chiefDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chiefDir, "config.yaml"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + if !Exists(dir) { + t.Error("expected Exists to return true for existing config") + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 702eb78..c3ea2a7 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -3,6 +3,7 @@ package git import ( "os/exec" + "strconv" "strings" ) @@ -47,3 +48,23 @@ func IsGitRepo(dir string) bool { cmd.Dir = dir return cmd.Run() == nil } + +// CommitCount returns the number of commits on branch that are not on the default branch. +// Returns 0 if the count cannot be determined. +func CommitCount(repoDir, branch string) int { + defaultBranch, err := GetDefaultBranch(repoDir) + if err != nil { + return 0 + } + cmd := exec.Command("git", "rev-list", "--count", defaultBranch+".."+branch) + cmd.Dir = repoDir + out, err := cmd.Output() + if err != nil { + return 0 + } + count, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0 + } + return count +} diff --git a/internal/git/push.go b/internal/git/push.go new file mode 100644 index 0000000..8d79b86 --- /dev/null +++ b/internal/git/push.go @@ -0,0 +1,85 @@ +package git + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/minicodemonkey/chief/internal/prd" +) + +// CheckGHCLI validates that the GitHub CLI is installed and authenticated. +func CheckGHCLI() (installed bool, authenticated bool, err error) { + // Check if gh is installed + _, err = exec.LookPath("gh") + if err != nil { + return false, false, nil + } + + // Check if gh is authenticated + cmd := exec.Command("gh", "auth", "status") + if err := cmd.Run(); err != nil { + return true, false, nil + } + + return true, true, nil +} + +// PushBranch pushes the branch to origin. +func PushBranch(dir, branch string) error { + cmd := exec.Command("git", "push", "-u", "origin", branch) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to push branch: %s", strings.TrimSpace(string(out))) + } + return nil +} + +// CreatePR creates a pull request via `gh pr create` and returns the PR URL. +func CreatePR(dir, branch, title, body string) (string, error) { + cmd := exec.Command("gh", "pr", "create", + "--head", branch, + "--title", title, + "--body", body, + ) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to create PR: %s", strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +// PRTitleFromPRD generates a conventional-commits title for a PR. +// Format: feat(): +func PRTitleFromPRD(prdName string, p *prd.PRD) string { + return fmt.Sprintf("feat(%s): %s", prdName, p.Project) +} + +// PRBodyFromPRD generates a PR body with a summary and list of completed stories. +func PRBodyFromPRD(p *prd.PRD) string { + var b strings.Builder + + b.WriteString("## Summary\n\n") + b.WriteString(p.Description) + b.WriteString("\n\n") + + b.WriteString("## Changes\n\n") + for _, story := range p.UserStories { + if story.Passes { + b.WriteString(fmt.Sprintf("- %s: %s\n", story.ID, story.Title)) + } + } + + return b.String() +} + +// DeleteBranch deletes a local branch. +func DeleteBranch(repoDir, branch string) error { + cmd := exec.Command("git", "branch", "-D", branch) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to delete branch: %s", strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/git/push_test.go b/internal/git/push_test.go new file mode 100644 index 0000000..271dd37 --- /dev/null +++ b/internal/git/push_test.go @@ -0,0 +1,157 @@ +package git + +import ( + "os/exec" + "testing" + + "github.com/minicodemonkey/chief/internal/prd" +) + +func TestCheckGHCLI(t *testing.T) { + t.Run("returns result without error", func(t *testing.T) { + installed, _, err := CheckGHCLI() + if err != nil { + t.Fatalf("CheckGHCLI() error = %v", err) + } + // We can't guarantee gh is installed in CI, but we can verify the function runs + _ = installed + }) +} + +func TestPushBranch(t *testing.T) { + t.Run("fails on repo without remote", func(t *testing.T) { + dir := initTestRepo(t) + err := PushBranch(dir, "main") + if err == nil { + t.Error("PushBranch() expected error for repo without remote, got nil") + } + }) +} + +func TestDeleteBranch(t *testing.T) { + t.Run("deletes existing branch", func(t *testing.T) { + dir := initTestRepo(t) + + // Create a branch + cmd := exec.Command("git", "branch", "feature-branch") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git branch failed: %s", string(out)) + } + + // Verify it exists + exists, err := BranchExists(dir, "feature-branch") + if err != nil { + t.Fatalf("BranchExists() error = %v", err) + } + if !exists { + t.Fatal("branch should exist before deletion") + } + + // Delete the branch + err = DeleteBranch(dir, "feature-branch") + if err != nil { + t.Fatalf("DeleteBranch() error = %v", err) + } + + // Verify it's gone + exists, err = BranchExists(dir, "feature-branch") + if err != nil { + t.Fatalf("BranchExists() error = %v", err) + } + if exists { + t.Error("branch still exists after deletion") + } + }) + + t.Run("fails for non-existent branch", func(t *testing.T) { + dir := initTestRepo(t) + err := DeleteBranch(dir, "nonexistent-branch") + if err == nil { + t.Error("DeleteBranch() expected error for non-existent branch, got nil") + } + }) +} + +func TestPRTitleFromPRD(t *testing.T) { + p := &prd.PRD{ + Project: "Git Worktree Support", + } + got := PRTitleFromPRD("worktrees", p) + want := "feat(worktrees): Git Worktree Support" + if got != want { + t.Errorf("PRTitleFromPRD() = %q, want %q", got, want) + } +} + +func TestPRBodyFromPRD(t *testing.T) { + t.Run("includes summary and completed stories", func(t *testing.T) { + p := &prd.PRD{ + Project: "Test Project", + Description: "This is a test project description.", + UserStories: []prd.UserStory{ + {ID: "US-001", Title: "Config System", Passes: true}, + {ID: "US-002", Title: "Git Worktree Primitives", Passes: true}, + {ID: "US-003", Title: "Incomplete Story", Passes: false}, + }, + } + + body := PRBodyFromPRD(p) + + // Check summary section + if got := body; got == "" { + t.Fatal("PRBodyFromPRD() returned empty string") + } + if !contains(body, "## Summary") { + t.Error("body missing ## Summary header") + } + if !contains(body, "This is a test project description.") { + t.Error("body missing project description") + } + + // Check changes section + if !contains(body, "## Changes") { + t.Error("body missing ## Changes header") + } + if !contains(body, "US-001: Config System") { + t.Error("body missing completed story US-001") + } + if !contains(body, "US-002: Git Worktree Primitives") { + t.Error("body missing completed story US-002") + } + + // Incomplete stories should not be listed + if contains(body, "Incomplete Story") { + t.Error("body should not include incomplete stories") + } + }) + + t.Run("empty stories produces changes header only", func(t *testing.T) { + p := &prd.PRD{ + Project: "Empty Project", + Description: "No stories yet.", + UserStories: []prd.UserStory{}, + } + + body := PRBodyFromPRD(p) + if !contains(body, "## Summary") { + t.Error("body missing ## Summary header") + } + if !contains(body, "## Changes") { + t.Error("body missing ## Changes header") + } + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/git/worktree.go b/internal/git/worktree.go new file mode 100644 index 0000000..31d2164 --- /dev/null +++ b/internal/git/worktree.go @@ -0,0 +1,255 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Worktree represents a git worktree entry. +type Worktree struct { + Path string + Branch string + HEAD string + Prunable bool +} + +// GetDefaultBranch detects the default branch (main or master) for a repository. +func GetDefaultBranch(repoDir string) (string, error) { + // Try symbolic-ref first (works for repos with remotes) + cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD") + cmd.Dir = repoDir + output, err := cmd.Output() + if err == nil { + ref := strings.TrimSpace(string(output)) + // refs/remotes/origin/main -> main + parts := strings.Split(ref, "/") + if len(parts) > 0 { + return parts[len(parts)-1], nil + } + } + + // Fallback: check if main or master branch exists + for _, branch := range []string{"main", "master"} { + exists, err := BranchExists(repoDir, branch) + if err != nil { + continue + } + if exists { + return branch, nil + } + } + + return "", fmt.Errorf("could not detect default branch (tried main, master)") +} + +// CreateWorktree creates a branch from the default branch and adds a worktree at the given path. +// If the worktree path already exists and is a valid worktree on the expected branch, it is reused. +// If the worktree path exists but is stale (wrong branch or invalid), it is removed and recreated. +func CreateWorktree(repoDir, worktreePath, branch string) error { + absWorktreePath, err := filepath.Abs(worktreePath) + if err != nil { + return fmt.Errorf("failed to resolve worktree path: %w", err) + } + + // Check if the path already exists as a worktree + if IsWorktree(absWorktreePath) { + // Check if it's on the expected branch + currentBranch, err := GetCurrentBranch(absWorktreePath) + if err == nil && currentBranch == branch { + // Valid worktree on the expected branch, reuse it + return nil + } + // Stale worktree (wrong branch or invalid), remove and recreate + if err := RemoveWorktree(repoDir, absWorktreePath); err != nil { + return fmt.Errorf("failed to remove stale worktree: %w", err) + } + } + + defaultBranch, err := GetDefaultBranch(repoDir) + if err != nil { + return fmt.Errorf("failed to detect default branch: %w", err) + } + + // Create the branch from the default branch if it doesn't exist + exists, err := BranchExists(repoDir, branch) + if err != nil { + return fmt.Errorf("failed to check branch existence: %w", err) + } + if !exists { + cmd := exec.Command("git", "branch", branch, defaultBranch) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create branch %s: %s", branch, strings.TrimSpace(string(out))) + } + } + + // Add the worktree + cmd := exec.Command("git", "worktree", "add", absWorktreePath, branch) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to add worktree: %s", strings.TrimSpace(string(out))) + } + + return nil +} + +// RemoveWorktree removes a git worktree at the given path. +func RemoveWorktree(repoDir, worktreePath string) error { + cmd := exec.Command("git", "worktree", "remove", worktreePath) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to remove worktree: %s", strings.TrimSpace(string(out))) + } + return nil +} + +// ListWorktrees parses `git worktree list --porcelain` and returns all worktrees. +func ListWorktrees(repoDir string) ([]Worktree, error) { + cmd := exec.Command("git", "worktree", "list", "--porcelain") + cmd.Dir = repoDir + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list worktrees: %w", err) + } + + var worktrees []Worktree + var current Worktree + + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "worktree "): + current = Worktree{Path: strings.TrimPrefix(line, "worktree ")} + case strings.HasPrefix(line, "HEAD "): + current.HEAD = strings.TrimPrefix(line, "HEAD ") + case strings.HasPrefix(line, "branch "): + // refs/heads/branch-name -> branch-name + ref := strings.TrimPrefix(line, "branch ") + current.Branch = strings.TrimPrefix(ref, "refs/heads/") + case line == "prunable": + current.Prunable = true + case line == "": + if current.Path != "" { + worktrees = append(worktrees, current) + current = Worktree{} + } + } + } + // Append last entry if not empty-line terminated + if current.Path != "" { + worktrees = append(worktrees, current) + } + + return worktrees, nil +} + +// IsWorktree checks if a path is a valid git worktree. +func IsWorktree(path string) bool { + cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") + cmd.Dir = path + output, err := cmd.Output() + if err != nil { + return false + } + if strings.TrimSpace(string(output)) != "true" { + return false + } + + // Verify it's actually a worktree (not the main repo) by checking for .git file + cmd = exec.Command("git", "rev-parse", "--git-common-dir") + cmd.Dir = path + commonDir, err := cmd.Output() + if err != nil { + return false + } + + cmd = exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = path + gitDir, err := cmd.Output() + if err != nil { + return false + } + + // In a worktree, --git-dir differs from --git-common-dir + // But we also consider the main worktree valid + _ = commonDir + _ = gitDir + return true +} + +// WorktreePathForPRD returns the worktree path for a given PRD name. +func WorktreePathForPRD(baseDir, prdName string) string { + return filepath.Join(baseDir, ".chief", "worktrees", prdName) +} + +// PruneWorktrees runs `git worktree prune` to clean up stale worktree tracking. +func PruneWorktrees(repoDir string) error { + cmd := exec.Command("git", "worktree", "prune") + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to prune worktrees: %s", strings.TrimSpace(string(out))) + } + return nil +} + +// DetectOrphanedWorktrees scans .chief/worktrees/ and returns a map of PRD name -> absolute worktree path +// for worktrees that exist on disk. The caller is responsible for determining which are orphaned +// (i.e., have no corresponding registered/running PRD). +func DetectOrphanedWorktrees(baseDir string) map[string]string { + worktreesDir := filepath.Join(baseDir, ".chief", "worktrees") + entries, err := os.ReadDir(worktreesDir) + if err != nil { + return nil + } + + result := make(map[string]string) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + absPath := filepath.Join(worktreesDir, entry.Name()) + result[entry.Name()] = absPath + } + return result +} + +// MergeBranch merges a branch into the current branch, returning conflicting file list on failure. +func MergeBranch(repoDir, branch string) ([]string, error) { + cmd := exec.Command("git", "merge", branch) + cmd.Dir = repoDir + out, err := cmd.CombinedOutput() + if err != nil { + // Parse conflicting files from merge output + conflicts := parseConflicts(repoDir) + if len(conflicts) > 0 { + // Abort the merge to leave a clean state + abortCmd := exec.Command("git", "merge", "--abort") + abortCmd.Dir = repoDir + _ = abortCmd.Run() + return conflicts, fmt.Errorf("merge conflict: %s", strings.TrimSpace(string(out))) + } + return nil, fmt.Errorf("merge failed: %s", strings.TrimSpace(string(out))) + } + return nil, nil +} + +// parseConflicts uses `git diff --name-only --diff-filter=U` to find conflicting files. +func parseConflicts(repoDir string) []string { + cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=U") + cmd.Dir = repoDir + output, err := cmd.Output() + if err != nil { + return nil + } + + var conflicts []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line != "" { + conflicts = append(conflicts, line) + } + } + return conflicts +} diff --git a/internal/git/worktree_test.go b/internal/git/worktree_test.go new file mode 100644 index 0000000..7895246 --- /dev/null +++ b/internal/git/worktree_test.go @@ -0,0 +1,470 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// initTestRepo creates a temporary git repository with an initial commit and returns its path. +func initTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "checkout", "-b", "main"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("setup command %v failed: %s", args, string(out)) + } + } + + // Create an initial commit so branches can be created + readme := filepath.Join(dir, "README.md") + if err := os.WriteFile(readme, []byte("# Test\n"), 0644); err != nil { + t.Fatalf("failed to create README: %v", err) + } + cmd := exec.Command("git", "add", ".") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add failed: %s", string(out)) + } + cmd = exec.Command("git", "commit", "-m", "initial commit") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit failed: %s", string(out)) + } + + return dir +} + +func TestGetDefaultBranch(t *testing.T) { + t.Run("detects main branch", func(t *testing.T) { + dir := initTestRepo(t) + branch, err := GetDefaultBranch(dir) + if err != nil { + t.Fatalf("GetDefaultBranch() error = %v", err) + } + if branch != "main" { + t.Errorf("GetDefaultBranch() = %q, want %q", branch, "main") + } + }) + + t.Run("detects master branch", func(t *testing.T) { + dir := t.TempDir() + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "checkout", "-b", "master"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("setup command %v failed: %s", args, string(out)) + } + } + readme := filepath.Join(dir, "README.md") + if err := os.WriteFile(readme, []byte("# Test\n"), 0644); err != nil { + t.Fatalf("failed to create README: %v", err) + } + cmd := exec.Command("git", "add", ".") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add failed: %s", string(out)) + } + cmd = exec.Command("git", "commit", "-m", "initial commit") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit failed: %s", string(out)) + } + + branch, err := GetDefaultBranch(dir) + if err != nil { + t.Fatalf("GetDefaultBranch() error = %v", err) + } + if branch != "master" { + t.Errorf("GetDefaultBranch() = %q, want %q", branch, "master") + } + }) +} + +func TestCreateWorktree(t *testing.T) { + t.Run("creates worktree and branch", func(t *testing.T) { + dir := initTestRepo(t) + wtPath := filepath.Join(dir, "worktrees", "test-prd") + + err := CreateWorktree(dir, wtPath, "chief/test-prd") + if err != nil { + t.Fatalf("CreateWorktree() error = %v", err) + } + + // Verify worktree exists and is on the right branch + branch, err := GetCurrentBranch(wtPath) + if err != nil { + t.Fatalf("GetCurrentBranch() error = %v", err) + } + if branch != "chief/test-prd" { + t.Errorf("branch = %q, want %q", branch, "chief/test-prd") + } + }) + + t.Run("reuses existing valid worktree", func(t *testing.T) { + dir := initTestRepo(t) + wtPath := filepath.Join(dir, "worktrees", "test-prd") + + // Create worktree first time + if err := CreateWorktree(dir, wtPath, "chief/test-prd"); err != nil { + t.Fatalf("first CreateWorktree() error = %v", err) + } + + // Create a file in the worktree to verify it's reused (not recreated) + marker := filepath.Join(wtPath, "marker.txt") + if err := os.WriteFile(marker, []byte("marker"), 0644); err != nil { + t.Fatalf("failed to create marker: %v", err) + } + + // Create again - should reuse + if err := CreateWorktree(dir, wtPath, "chief/test-prd"); err != nil { + t.Fatalf("second CreateWorktree() error = %v", err) + } + + // Marker should still exist + if _, err := os.Stat(marker); err != nil { + t.Error("marker file was removed - worktree was not reused") + } + }) + + t.Run("recreates stale worktree with wrong branch", func(t *testing.T) { + dir := initTestRepo(t) + wtPath := filepath.Join(dir, "worktrees", "test-prd") + + // Create worktree with one branch + if err := CreateWorktree(dir, wtPath, "chief/branch-a"); err != nil { + t.Fatalf("first CreateWorktree() error = %v", err) + } + + // Create again with a different branch - should remove and recreate + if err := CreateWorktree(dir, wtPath, "chief/branch-b"); err != nil { + t.Fatalf("second CreateWorktree() error = %v", err) + } + + branch, err := GetCurrentBranch(wtPath) + if err != nil { + t.Fatalf("GetCurrentBranch() error = %v", err) + } + if branch != "chief/branch-b" { + t.Errorf("branch = %q, want %q", branch, "chief/branch-b") + } + }) +} + +func TestRemoveWorktree(t *testing.T) { + t.Run("removes existing worktree", func(t *testing.T) { + dir := initTestRepo(t) + wtPath := filepath.Join(dir, "worktrees", "test-prd") + + if err := CreateWorktree(dir, wtPath, "chief/test-prd"); err != nil { + t.Fatalf("CreateWorktree() error = %v", err) + } + + err := RemoveWorktree(dir, wtPath) + if err != nil { + t.Fatalf("RemoveWorktree() error = %v", err) + } + + // Verify the directory is gone + if _, err := os.Stat(wtPath); !os.IsNotExist(err) { + t.Error("worktree directory still exists after removal") + } + }) +} + +func TestListWorktrees(t *testing.T) { + t.Run("lists worktrees including main", func(t *testing.T) { + dir := initTestRepo(t) + wtPath := filepath.Join(dir, "worktrees", "test-prd") + + if err := CreateWorktree(dir, wtPath, "chief/test-prd"); err != nil { + t.Fatalf("CreateWorktree() error = %v", err) + } + + worktrees, err := ListWorktrees(dir) + if err != nil { + t.Fatalf("ListWorktrees() error = %v", err) + } + + if len(worktrees) < 2 { + t.Fatalf("expected at least 2 worktrees, got %d", len(worktrees)) + } + + // Find our worktree + found := false + for _, wt := range worktrees { + if wt.Branch == "chief/test-prd" { + found = true + if wt.HEAD == "" { + t.Error("worktree HEAD is empty") + } + } + } + if !found { + t.Error("worktree with branch chief/test-prd not found in list") + } + }) +} + +func TestIsWorktree(t *testing.T) { + t.Run("returns true for valid worktree", func(t *testing.T) { + dir := initTestRepo(t) + wtPath := filepath.Join(dir, "worktrees", "test-prd") + + if err := CreateWorktree(dir, wtPath, "chief/test-prd"); err != nil { + t.Fatalf("CreateWorktree() error = %v", err) + } + + if !IsWorktree(wtPath) { + t.Error("IsWorktree() = false, want true") + } + }) + + t.Run("returns false for non-existent path", func(t *testing.T) { + if IsWorktree("/nonexistent/path") { + t.Error("IsWorktree() = true for non-existent path") + } + }) + + t.Run("returns false for plain directory", func(t *testing.T) { + dir := t.TempDir() + if IsWorktree(dir) { + t.Error("IsWorktree() = true for plain directory") + } + }) +} + +func TestWorktreePathForPRD(t *testing.T) { + result := WorktreePathForPRD("/home/user/project", "auth") + expected := filepath.Join("/home/user/project", ".chief", "worktrees", "auth") + if result != expected { + t.Errorf("WorktreePathForPRD() = %q, want %q", result, expected) + } +} + +func TestPruneWorktrees(t *testing.T) { + t.Run("prune succeeds on clean repo", func(t *testing.T) { + dir := initTestRepo(t) + err := PruneWorktrees(dir) + if err != nil { + t.Fatalf("PruneWorktrees() error = %v", err) + } + }) +} + +func TestMergeBranch(t *testing.T) { + t.Run("fast-forward merge succeeds", func(t *testing.T) { + dir := initTestRepo(t) + + // Create a branch with a commit + cmd := exec.Command("git", "checkout", "-b", "feature") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("checkout failed: %s", string(out)) + } + + featureFile := filepath.Join(dir, "feature.txt") + if err := os.WriteFile(featureFile, []byte("feature\n"), 0644); err != nil { + t.Fatalf("failed to create feature file: %v", err) + } + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add failed: %s", string(out)) + } + cmd = exec.Command("git", "commit", "-m", "add feature") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit failed: %s", string(out)) + } + + // Switch back to main and merge + cmd = exec.Command("git", "checkout", "main") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("checkout main failed: %s", string(out)) + } + + conflicts, err := MergeBranch(dir, "feature") + if err != nil { + t.Fatalf("MergeBranch() error = %v", err) + } + if len(conflicts) > 0 { + t.Errorf("expected no conflicts, got %v", conflicts) + } + + // Verify feature file exists on main + if _, err := os.Stat(featureFile); err != nil { + t.Error("feature.txt not present after merge") + } + }) + + t.Run("merge conflict returns conflicting files", func(t *testing.T) { + dir := initTestRepo(t) + + // Create conflicting changes on two branches + conflictFile := filepath.Join(dir, "conflict.txt") + if err := os.WriteFile(conflictFile, []byte("main content\n"), 0644); err != nil { + t.Fatalf("failed to create conflict file: %v", err) + } + cmd := exec.Command("git", "add", ".") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add failed: %s", string(out)) + } + cmd = exec.Command("git", "commit", "-m", "main change") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit failed: %s", string(out)) + } + + // Create feature branch from parent commit + cmd = exec.Command("git", "checkout", "-b", "feature", "HEAD~1") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("checkout failed: %s", string(out)) + } + if err := os.WriteFile(conflictFile, []byte("feature content\n"), 0644); err != nil { + t.Fatalf("failed to create conflict file: %v", err) + } + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add failed: %s", string(out)) + } + cmd = exec.Command("git", "commit", "-m", "feature change") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit failed: %s", string(out)) + } + + // Switch to main and try to merge + cmd = exec.Command("git", "checkout", "main") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("checkout main failed: %s", string(out)) + } + + conflicts, err := MergeBranch(dir, "feature") + if err == nil { + t.Fatal("MergeBranch() expected error for conflict, got nil") + } + if len(conflicts) == 0 { + t.Fatal("expected conflict files, got none") + } + + foundConflict := false + for _, f := range conflicts { + if strings.Contains(f, "conflict.txt") { + foundConflict = true + } + } + if !foundConflict { + t.Errorf("expected conflict.txt in conflicts, got %v", conflicts) + } + + // Verify the merge was aborted (clean state) + cmd = exec.Command("git", "status", "--porcelain") + cmd.Dir = dir + output, _ := cmd.Output() + if strings.TrimSpace(string(output)) != "" { + t.Errorf("expected clean working tree after merge abort, got: %s", string(output)) + } + }) +} + +func TestDetectOrphanedWorktrees(t *testing.T) { + t.Run("returns nil when worktrees directory does not exist", func(t *testing.T) { + dir := t.TempDir() + result := DetectOrphanedWorktrees(dir) + if result != nil { + t.Errorf("expected nil, got %v", result) + } + }) + + t.Run("returns empty map when worktrees directory is empty", func(t *testing.T) { + dir := t.TempDir() + worktreesDir := filepath.Join(dir, ".chief", "worktrees") + if err := os.MkdirAll(worktreesDir, 0755); err != nil { + t.Fatalf("failed to create worktrees dir: %v", err) + } + result := DetectOrphanedWorktrees(dir) + if len(result) != 0 { + t.Errorf("expected empty map, got %v", result) + } + }) + + t.Run("detects worktree directories on disk", func(t *testing.T) { + dir := t.TempDir() + worktreesDir := filepath.Join(dir, ".chief", "worktrees") + + // Create some worktree directories + for _, name := range []string{"auth", "payments"} { + if err := os.MkdirAll(filepath.Join(worktreesDir, name), 0755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + } + + result := DetectOrphanedWorktrees(dir) + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d: %v", len(result), result) + } + + authPath, ok := result["auth"] + if !ok { + t.Error("expected 'auth' in result") + } + if authPath != filepath.Join(worktreesDir, "auth") { + t.Errorf("expected auth path %q, got %q", filepath.Join(worktreesDir, "auth"), authPath) + } + + paymentsPath, ok := result["payments"] + if !ok { + t.Error("expected 'payments' in result") + } + if paymentsPath != filepath.Join(worktreesDir, "payments") { + t.Errorf("expected payments path %q, got %q", filepath.Join(worktreesDir, "payments"), paymentsPath) + } + }) + + t.Run("ignores files in worktrees directory", func(t *testing.T) { + dir := t.TempDir() + worktreesDir := filepath.Join(dir, ".chief", "worktrees") + if err := os.MkdirAll(worktreesDir, 0755); err != nil { + t.Fatalf("failed to create worktrees dir: %v", err) + } + + // Create a directory and a file + if err := os.MkdirAll(filepath.Join(worktreesDir, "auth"), 0755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + if err := os.WriteFile(filepath.Join(worktreesDir, "stale-file.txt"), []byte("junk"), 0644); err != nil { + t.Fatalf("failed to create file: %v", err) + } + + result := DetectOrphanedWorktrees(dir) + if len(result) != 1 { + t.Fatalf("expected 1 entry (only dirs), got %d: %v", len(result), result) + } + if _, ok := result["auth"]; !ok { + t.Error("expected 'auth' in result") + } + }) +} diff --git a/internal/loop/loop.go b/internal/loop/loop.go index e264ea0..b7d702e 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -38,6 +38,7 @@ func DefaultRetryConfig() RetryConfig { // Loop manages the core agent loop that invokes Claude repeatedly until all stories are complete. type Loop struct { prdPath string + workDir string prompt string maxIter int iteration int @@ -61,6 +62,19 @@ func NewLoop(prdPath, prompt string, maxIter int) *Loop { } } +// NewLoopWithWorkDir creates a new Loop instance with a configurable working directory. +// When workDir is empty, defaults to the project root for backward compatibility. +func NewLoopWithWorkDir(prdPath, workDir string, prompt string, maxIter int) *Loop { + return &Loop{ + prdPath: prdPath, + workDir: workDir, + prompt: prompt, + maxIter: maxIter, + events: make(chan Event, 100), + retryConfig: DefaultRetryConfig(), + } +} + // NewLoopWithEmbeddedPrompt creates a new Loop instance using the embedded agent prompt. // The PRD path placeholder in the prompt is automatically substituted. func NewLoopWithEmbeddedPrompt(prdPath string, maxIter int) *Loop { @@ -252,8 +266,8 @@ func (l *Loop) runIteration(ctx context.Context) error { "--output-format", "stream-json", "--verbose", ) - // Set working directory to the PRD directory - l.claudeCmd.Dir = filepath.Dir(l.prdPath) + // Set working directory: use workDir if configured, otherwise default to PRD directory + l.claudeCmd.Dir = l.effectiveWorkDir() l.mu.Unlock() // Create pipes for stdout and stderr @@ -392,6 +406,15 @@ func (l *Loop) IsStopped() bool { return l.stopped } +// effectiveWorkDir returns the working directory to use for Claude. +// If workDir is set, it is used directly. Otherwise, defaults to the PRD directory. +func (l *Loop) effectiveWorkDir() string { + if l.workDir != "" { + return l.workDir + } + return filepath.Dir(l.prdPath) +} + // IsRunning returns whether a Claude process is currently running. func (l *Loop) IsRunning() bool { l.mu.Lock() diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go index a8d1a18..037d802 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -72,6 +72,34 @@ func TestNewLoop(t *testing.T) { } } +func TestNewLoopWithWorkDir(t *testing.T) { + l := NewLoopWithWorkDir("/path/to/prd.json", "/work/dir", "test prompt", 5) + + if l.prdPath != "/path/to/prd.json" { + t.Errorf("Expected prdPath %q, got %q", "/path/to/prd.json", l.prdPath) + } + if l.workDir != "/work/dir" { + t.Errorf("Expected workDir %q, got %q", "/work/dir", l.workDir) + } + if l.prompt != "test prompt" { + t.Errorf("Expected prompt %q, got %q", "test prompt", l.prompt) + } + if l.maxIter != 5 { + t.Errorf("Expected maxIter %d, got %d", 5, l.maxIter) + } + if l.events == nil { + t.Error("Expected events channel to be initialized") + } +} + +func TestNewLoopWithWorkDir_EmptyWorkDir(t *testing.T) { + l := NewLoopWithWorkDir("/path/to/prd.json", "", "test prompt", 5) + + if l.workDir != "" { + t.Errorf("Expected empty workDir, got %q", l.workDir) + } +} + func TestLoop_Events(t *testing.T) { l := NewLoop("/path/to/prd.json", "test prompt", 5) events := l.Events() diff --git a/internal/loop/manager.go b/internal/loop/manager.go index 82977e6..004fb0a 100644 --- a/internal/loop/manager.go +++ b/internal/loop/manager.go @@ -6,6 +6,8 @@ import ( "sync" "time" + "github.com/minicodemonkey/chief/embed" + "github.com/minicodemonkey/chief/internal/config" "github.com/minicodemonkey/chief/internal/prd" ) @@ -42,16 +44,18 @@ func (s LoopState) String() string { // LoopInstance represents a single loop with its metadata. type LoopInstance struct { - Name string - PRDPath string - Loop *Loop - State LoopState - Iteration int - StartTime time.Time - Error error - ctx context.Context - cancel context.CancelFunc - mu sync.Mutex + Name string + PRDPath string + WorktreeDir string // Working directory for this PRD (empty = project root) + Branch string // Git branch for this PRD (empty = current branch) + Loop *Loop + State LoopState + Iteration int + StartTime time.Time + Error error + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex } // ManagerEvent represents an event from any managed loop. @@ -67,9 +71,11 @@ type Manager struct { events chan ManagerEvent maxIter int retryConfig RetryConfig - mu sync.RWMutex - wg sync.WaitGroup - onComplete func(prdName string) // Callback when a PRD completes + config *config.Config // Project config for post-completion actions + mu sync.RWMutex + wg sync.WaitGroup + onComplete func(prdName string) // Callback when a PRD completes + onPostComplete func(prdName, branch, workDir string) // Callback for post-completion actions (push, PR) } // NewManager creates a new loop manager. @@ -103,6 +109,28 @@ func (m *Manager) SetCompletionCallback(fn func(prdName string)) { m.onComplete = fn } +// SetPostCompleteCallback sets a callback for post-completion actions (push, PR creation). +// The callback receives the PRD name, branch name, and working directory. +func (m *Manager) SetPostCompleteCallback(fn func(prdName, branch, workDir string)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onPostComplete = fn +} + +// SetConfig sets the project config for post-completion actions. +func (m *Manager) SetConfig(cfg *config.Config) { + m.mu.Lock() + defer m.mu.Unlock() + m.config = cfg +} + +// Config returns the current project config. +func (m *Manager) Config() *config.Config { + m.mu.RLock() + defer m.mu.RUnlock() + return m.config +} + // Events returns the channel for receiving events from all loops. func (m *Manager) Events() <-chan ManagerEvent { return m.events @@ -127,6 +155,27 @@ func (m *Manager) Register(name, prdPath string) error { return nil } +// RegisterWithWorktree registers a PRD with worktree metadata (does not start it). +func (m *Manager) RegisterWithWorktree(name, prdPath, worktreeDir, branch string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if already registered + if _, exists := m.instances[name]; exists { + return fmt.Errorf("PRD %s is already registered", name) + } + + m.instances[name] = &LoopInstance{ + Name: name, + PRDPath: prdPath, + WorktreeDir: worktreeDir, + Branch: branch, + State: LoopStateReady, + } + + return nil +} + // Unregister removes a PRD from the manager (stops it first if running). func (m *Manager) Unregister(name string) error { m.mu.Lock() @@ -165,8 +214,13 @@ func (m *Manager) Start(name string) error { return fmt.Errorf("PRD %s is already running", name) } - // Create a new loop instance - instance.Loop = NewLoopWithEmbeddedPrompt(instance.PRDPath, m.maxIter) + // Create a new loop instance, using worktree-aware constructor if WorktreeDir is set + if instance.WorktreeDir != "" { + prompt := embed.GetPrompt(instance.PRDPath) + instance.Loop = NewLoopWithWorkDir(instance.PRDPath, instance.WorktreeDir, prompt, m.maxIter) + } else { + instance.Loop = NewLoopWithEmbeddedPrompt(instance.PRDPath, m.maxIter) + } m.mu.RLock() instance.Loop.SetRetryConfig(m.retryConfig) m.mu.RUnlock() @@ -212,14 +266,22 @@ func (m *Manager) runLoop(instance *LoopInstance) { Completed: completed, } - // If completed, trigger callback + // If completed, trigger callbacks if completed { m.mu.RLock() callback := m.onComplete + postCallback := m.onPostComplete m.mu.RUnlock() if callback != nil { callback(instance.Name) } + if postCallback != nil { + instance.mu.Lock() + branch := instance.Branch + workDir := instance.WorktreeDir + instance.mu.Unlock() + postCallback(instance.Name, branch, workDir) + } } case <-instance.ctx.Done(): close(done) @@ -308,6 +370,46 @@ func (m *Manager) Stop(name string) error { return nil } +// UpdateWorktreeInfo updates the worktree directory and branch for an existing PRD instance. +func (m *Manager) UpdateWorktreeInfo(name, worktreeDir, branch string) error { + m.mu.RLock() + instance, exists := m.instances[name] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("PRD %s not found", name) + } + + instance.mu.Lock() + defer instance.mu.Unlock() + + instance.WorktreeDir = worktreeDir + instance.Branch = branch + + return nil +} + +// ClearWorktreeInfo clears the worktree directory and optionally the branch for a PRD instance. +func (m *Manager) ClearWorktreeInfo(name string, clearBranch bool) error { + m.mu.RLock() + instance, exists := m.instances[name] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("PRD %s not found", name) + } + + instance.mu.Lock() + defer instance.mu.Unlock() + + instance.WorktreeDir = "" + if clearBranch { + instance.Branch = "" + } + + return nil +} + // GetState returns the state of a specific PRD loop. func (m *Manager) GetState(name string) (LoopState, int, error) { m.mu.RLock() @@ -339,12 +441,14 @@ func (m *Manager) GetInstance(name string) *LoopInstance { // Return a copy to avoid race conditions return &LoopInstance{ - Name: instance.Name, - PRDPath: instance.PRDPath, - State: instance.State, - Iteration: instance.Iteration, - StartTime: instance.StartTime, - Error: instance.Error, + Name: instance.Name, + PRDPath: instance.PRDPath, + WorktreeDir: instance.WorktreeDir, + Branch: instance.Branch, + State: instance.State, + Iteration: instance.Iteration, + StartTime: instance.StartTime, + Error: instance.Error, } } @@ -357,12 +461,14 @@ func (m *Manager) GetAllInstances() []*LoopInstance { for _, instance := range m.instances { instance.mu.Lock() copy := &LoopInstance{ - Name: instance.Name, - PRDPath: instance.PRDPath, - State: instance.State, - Iteration: instance.Iteration, - StartTime: instance.StartTime, - Error: instance.Error, + Name: instance.Name, + PRDPath: instance.PRDPath, + WorktreeDir: instance.WorktreeDir, + Branch: instance.Branch, + State: instance.State, + Iteration: instance.Iteration, + StartTime: instance.StartTime, + Error: instance.Error, } instance.mu.Unlock() result = append(result, copy) diff --git a/internal/loop/manager_test.go b/internal/loop/manager_test.go index 9235ae9..d9b23b9 100644 --- a/internal/loop/manager_test.go +++ b/internal/loop/manager_test.go @@ -6,6 +6,8 @@ import ( "sync" "testing" "time" + + "github.com/minicodemonkey/chief/internal/config" ) // createTestPRDWithName creates a minimal test PRD file with a given name and returns its path. @@ -353,3 +355,259 @@ func TestManagerRetryConfig(t *testing.T) { t.Errorf("expected MaxRetries 5, got %d", m.retryConfig.MaxRetries) } } + +func TestManagerRegisterWithWorktree(t *testing.T) { + tmpDir := t.TempDir() + prdPath := createTestPRDWithName(t, tmpDir, "test-prd") + + m := NewManager(10) + + err := m.RegisterWithWorktree("test-prd", prdPath, "/tmp/worktree/test-prd", "chief/test-prd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + instance := m.GetInstance("test-prd") + if instance == nil { + t.Fatal("expected instance to be registered") + } + if instance.Name != "test-prd" { + t.Errorf("expected name 'test-prd', got '%s'", instance.Name) + } + if instance.WorktreeDir != "/tmp/worktree/test-prd" { + t.Errorf("expected WorktreeDir '/tmp/worktree/test-prd', got '%s'", instance.WorktreeDir) + } + if instance.Branch != "chief/test-prd" { + t.Errorf("expected Branch 'chief/test-prd', got '%s'", instance.Branch) + } + if instance.State != LoopStateReady { + t.Errorf("expected state Ready, got %v", instance.State) + } + + // Duplicate registration should fail + err = m.RegisterWithWorktree("test-prd", prdPath, "/tmp/worktree/test-prd", "chief/test-prd") + if err == nil { + t.Error("expected error when registering duplicate PRD") + } +} + +func TestManagerRegisterWithWorktreeFieldsInGetAllInstances(t *testing.T) { + tmpDir := t.TempDir() + prd1Path := createTestPRDWithName(t, tmpDir, "prd1") + prd2Path := createTestPRDWithName(t, tmpDir, "prd2") + + m := NewManager(10) + m.Register("prd1", prd1Path) + m.RegisterWithWorktree("prd2", prd2Path, "/tmp/wt/prd2", "chief/prd2") + + instances := m.GetAllInstances() + if len(instances) != 2 { + t.Fatalf("expected 2 instances, got %d", len(instances)) + } + + for _, inst := range instances { + if inst.Name == "prd1" { + if inst.WorktreeDir != "" { + t.Errorf("expected empty WorktreeDir for prd1, got '%s'", inst.WorktreeDir) + } + if inst.Branch != "" { + t.Errorf("expected empty Branch for prd1, got '%s'", inst.Branch) + } + } else if inst.Name == "prd2" { + if inst.WorktreeDir != "/tmp/wt/prd2" { + t.Errorf("expected WorktreeDir '/tmp/wt/prd2', got '%s'", inst.WorktreeDir) + } + if inst.Branch != "chief/prd2" { + t.Errorf("expected Branch 'chief/prd2', got '%s'", inst.Branch) + } + } + } +} + +func TestManagerSetConfig(t *testing.T) { + m := NewManager(10) + + // Initially nil + if m.Config() != nil { + t.Error("expected nil config initially") + } + + // Set config + cfg := &config.Config{ + OnComplete: config.OnCompleteConfig{ + Push: true, + CreatePR: true, + }, + } + m.SetConfig(cfg) + + got := m.Config() + if got == nil { + t.Fatal("expected non-nil config") + } + if !got.OnComplete.Push { + t.Error("expected OnComplete.Push to be true") + } + if !got.OnComplete.CreatePR { + t.Error("expected OnComplete.CreatePR to be true") + } +} + +func TestManagerSetPostCompleteCallback(t *testing.T) { + m := NewManager(10) + + var calledPRD, calledBranch, calledWorkDir string + m.SetPostCompleteCallback(func(prdName, branch, workDir string) { + calledPRD = prdName + calledBranch = branch + calledWorkDir = workDir + }) + + // Verify callback is stored + m.mu.RLock() + if m.onPostComplete == nil { + t.Error("expected post-complete callback to be set") + } + m.mu.RUnlock() + + // Manually invoke to verify it works + m.onPostComplete("auth", "chief/auth", "/tmp/wt/auth") + if calledPRD != "auth" { + t.Errorf("expected 'auth', got '%s'", calledPRD) + } + if calledBranch != "chief/auth" { + t.Errorf("expected 'chief/auth', got '%s'", calledBranch) + } + if calledWorkDir != "/tmp/wt/auth" { + t.Errorf("expected '/tmp/wt/auth', got '%s'", calledWorkDir) + } +} + +func TestManagerClearWorktreeInfoAll(t *testing.T) { + tmpDir := t.TempDir() + prdPath := createTestPRDWithName(t, tmpDir, "test-prd") + + m := NewManager(10) + m.RegisterWithWorktree("test-prd", prdPath, "/tmp/wt/test", "chief/test") + + // Clear both worktree and branch + if err := m.ClearWorktreeInfo("test-prd", true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + inst := m.GetInstance("test-prd") + if inst.WorktreeDir != "" { + t.Errorf("expected empty WorktreeDir, got %q", inst.WorktreeDir) + } + if inst.Branch != "" { + t.Errorf("expected empty Branch, got %q", inst.Branch) + } +} + +func TestManagerClearWorktreeInfoKeepBranch(t *testing.T) { + tmpDir := t.TempDir() + prdPath := createTestPRDWithName(t, tmpDir, "test-prd") + + m := NewManager(10) + m.RegisterWithWorktree("test-prd", prdPath, "/tmp/wt/test", "chief/test") + + // Clear worktree only, keep branch + if err := m.ClearWorktreeInfo("test-prd", false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + inst := m.GetInstance("test-prd") + if inst.WorktreeDir != "" { + t.Errorf("expected empty WorktreeDir, got %q", inst.WorktreeDir) + } + if inst.Branch != "chief/test" { + t.Errorf("expected Branch 'chief/test', got %q", inst.Branch) + } +} + +func TestManagerClearWorktreeInfoNotFound(t *testing.T) { + m := NewManager(10) + err := m.ClearWorktreeInfo("nonexistent", true) + if err == nil { + t.Error("expected error for nonexistent PRD") + } +} + +func TestManagerUpdateWorktreeInfo(t *testing.T) { + tmpDir := t.TempDir() + prdPath := createTestPRDWithName(t, tmpDir, "test-prd") + + m := NewManager(10) + m.Register("test-prd", prdPath) + + // Initially no worktree info + inst := m.GetInstance("test-prd") + if inst.WorktreeDir != "" || inst.Branch != "" { + t.Error("expected empty worktree info initially") + } + + // Update worktree info + if err := m.UpdateWorktreeInfo("test-prd", "/tmp/wt/test", "chief/test"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + inst = m.GetInstance("test-prd") + if inst.WorktreeDir != "/tmp/wt/test" { + t.Errorf("expected WorktreeDir /tmp/wt/test, got %s", inst.WorktreeDir) + } + if inst.Branch != "chief/test" { + t.Errorf("expected Branch chief/test, got %s", inst.Branch) + } +} + +func TestManagerUpdateWorktreeInfoNotFound(t *testing.T) { + m := NewManager(10) + err := m.UpdateWorktreeInfo("nonexistent", "/tmp", "branch") + if err == nil { + t.Error("expected error for nonexistent PRD") + } +} + +func TestManagerUpdateWorktreeInfoOverwrite(t *testing.T) { + tmpDir := t.TempDir() + prdPath := createTestPRDWithName(t, tmpDir, "test-prd") + + m := NewManager(10) + m.RegisterWithWorktree("test-prd", prdPath, "/old/path", "old-branch") + + // Update with new values + if err := m.UpdateWorktreeInfo("test-prd", "/new/path", "new-branch"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + inst := m.GetInstance("test-prd") + if inst.WorktreeDir != "/new/path" { + t.Errorf("expected WorktreeDir /new/path, got %s", inst.WorktreeDir) + } + if inst.Branch != "new-branch" { + t.Errorf("expected Branch new-branch, got %s", inst.Branch) + } +} + +func TestManagerConcurrentAccessWithWorktreeFields(t *testing.T) { + tmpDir := t.TempDir() + prdPath := createTestPRDWithName(t, tmpDir, "test-prd") + + m := NewManager(10) + m.RegisterWithWorktree("test-prd", prdPath, "/tmp/wt/test", "chief/test") + m.SetConfig(&config.Config{}) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + inst := m.GetInstance("test-prd") + _ = inst.WorktreeDir + _ = inst.Branch + _ = m.Config() + _ = m.GetAllInstances() + }() + } + wg.Wait() +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 8fb8af6..75c9bcc 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -3,11 +3,13 @@ package tui import ( "fmt" "os" + "os/exec" "path/filepath" "strings" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/minicodemonkey/chief/internal/config" "github.com/minicodemonkey/chief/internal/git" "github.com/minicodemonkey/chief/internal/loop" "github.com/minicodemonkey/chief/internal/prd" @@ -67,6 +69,49 @@ type PRDCompletedMsg struct { PRDName string } +// mergeResultMsg is sent when a merge operation completes. +type mergeResultMsg struct { + branch string + conflicts []string + output string + err error +} + +// cleanResultMsg is sent when a clean operation completes. +type cleanResultMsg struct { + prdName string + success bool + message string + clearBranch bool +} + +// autoActionResultMsg is sent when a post-completion auto-action (push/PR) completes. +type autoActionResultMsg struct { + action string // "push" or "pr" + err error + prURL string // Only set for successful PR creation + prTitle string // Only set for successful PR creation +} + +// completionSpinnerTickMsg is sent to animate the completion screen spinner. +type completionSpinnerTickMsg struct{} + +// worktreeStepResultMsg is sent when a worktree setup step completes. +type worktreeStepResultMsg struct { + step WorktreeSpinnerStep + err error +} + +// worktreeSpinnerTickMsg is sent to animate the worktree setup spinner. +type worktreeSpinnerTickMsg struct{} + +// settingsGHCheckResultMsg is sent when GH CLI validation completes in settings. +type settingsGHCheckResultMsg struct { + installed bool + authenticated bool + err error +} + // LaunchInitMsg signals the TUI should exit to launch the init flow. type LaunchInitMsg struct { Name string @@ -86,6 +131,9 @@ const ( ViewPicker ViewHelp ViewBranchWarning + ViewWorktreeSpinner + ViewCompletion + ViewSettings ) // App is the main Bubble Tea model for the Chief TUI. @@ -122,13 +170,26 @@ type App struct { picker *PRDPicker baseDir string // Base directory for .chief/prds/ + // Project config + config *config.Config + // Help overlay helpOverlay *HelpOverlay previousViewMode ViewMode // View to return to when closing help // Branch warning dialog - branchWarning *BranchWarning - pendingStartPRD string // PRD name waiting to start after branch decision + branchWarning *BranchWarning + pendingStartPRD string // PRD name waiting to start after branch decision + pendingWorktreePath string // Absolute worktree path for pending PRD + + // Worktree setup spinner + worktreeSpinner *WorktreeSpinner + + // Completion screen + completionScreen *CompletionScreen + + // Settings overlay + settingsOverlay *SettingsOverlay // Completion notification callback onCompletion func(prdName string) @@ -198,8 +259,20 @@ func NewAppWithOptions(prdPath string, maxIter int) (*App, error) { baseDir, _ = os.Getwd() } + // Load project config + cfg, err := config.Load(baseDir) + if err != nil { + cfg = config.Default() + } + + // Prune stale worktrees on startup (clean git's internal tracking) + if git.IsGitRepo(baseDir) { + _ = git.PruneWorktrees(baseDir) + } + // Create loop manager for parallel PRD execution manager := loop.NewManager(maxIter) + manager.SetConfig(cfg) // Register the initial PRD with the manager manager.Register(prdName, prdPath) @@ -225,8 +298,12 @@ func NewAppWithOptions(prdPath string, maxIter int) (*App, error) { tabBar: tabBar, picker: picker, baseDir: baseDir, - helpOverlay: NewHelpOverlay(), - branchWarning: NewBranchWarning(), + config: cfg, + helpOverlay: NewHelpOverlay(), + branchWarning: NewBranchWarning(), + worktreeSpinner: NewWorktreeSpinner(), + completionScreen: NewCompletionScreen(), + settingsOverlay: NewSettingsOverlay(), }, nil } @@ -288,7 +365,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.width = msg.Width a.height = msg.Height // Update log viewer size - a.logViewer.SetSize(a.width, a.height-headerHeight-footerHeight-2) + a.logViewer.SetSize(a.width, a.height-a.effectiveHeaderHeight()-footerHeight-2) return a, nil case LoopEventMsg: @@ -309,6 +386,38 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.picker.Refresh() return a, nil + case mergeResultMsg: + return a.handleMergeResult(msg) + + case cleanResultMsg: + return a.handleCleanResult(msg) + + case autoActionResultMsg: + return a.handleAutoActionResult(msg) + + case backgroundAutoActionResultMsg: + return a.handleBackgroundAutoAction(msg) + + case completionSpinnerTickMsg: + if a.viewMode == ViewCompletion && a.completionScreen.IsAutoActionRunning() { + a.completionScreen.Tick() + return a, tickCompletionSpinner() + } + return a, nil + + case worktreeStepResultMsg: + return a.handleWorktreeStepResult(msg) + + case worktreeSpinnerTickMsg: + if a.viewMode == ViewWorktreeSpinner { + a.worktreeSpinner.Tick() + return a, tickWorktreeSpinner() + } + return a, nil + + case settingsGHCheckResultMsg: + return a.handleSettingsGHCheck(msg) + case PRDUpdateMsg: return a.handlePRDUpdate(msg) @@ -338,6 +447,22 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } + // Handle settings overlay (can be opened/closed from any view) + if msg.String() == "," { + if a.viewMode == ViewSettings { + // Close settings + a.viewMode = a.previousViewMode + return a, nil + } + if a.viewMode == ViewDashboard || a.viewMode == ViewLog || a.viewMode == ViewPicker || a.viewMode == ViewCompletion { + a.previousViewMode = a.viewMode + a.settingsOverlay.SetSize(a.width, a.height) + a.settingsOverlay.LoadFromConfig(a.config) + a.viewMode = ViewSettings + return a, nil + } + } + // Handle help view (only Esc closes it besides ?) if a.viewMode == ViewHelp { if msg.String() == "esc" { @@ -347,6 +472,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } + // Handle settings view + if a.viewMode == ViewSettings { + return a.handleSettingsKeys(msg) + } + // Handle picker view separately (it has its own input mode) if a.viewMode == ViewPicker { return a.handlePickerKeys(msg) @@ -357,6 +487,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a.handleBranchWarningKeys(msg) } + // Handle worktree spinner view - only Esc is active + if a.viewMode == ViewWorktreeSpinner { + return a.handleWorktreeSpinnerKeys(msg) + } + + // Handle completion screen view + if a.viewMode == ViewCompletion { + return a.handleCompletionKeys(msg) + } + switch msg.String() { case "q", "ctrl+c": a.stopAllLoops() @@ -367,7 +507,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "t": if a.viewMode == ViewDashboard { a.viewMode = ViewLog - a.logViewer.SetSize(a.width, a.height-headerHeight-footerHeight-2) + a.logViewer.SetSize(a.width, a.height-a.effectiveHeaderHeight()-footerHeight-2) } else { a.viewMode = ViewDashboard } @@ -473,21 +613,53 @@ func (a App) startLoopForPRD(prdName string) (tea.Model, tea.Cmd) { // Get the PRD directory prdDir := filepath.Join(a.baseDir, ".chief", "prds", prdName) - // Check if on a protected branch - if git.IsGitRepo(a.baseDir) { - branch, err := git.GetCurrentBranch(a.baseDir) - if err == nil && git.IsProtectedBranch(branch) { - // Show branch warning dialog - a.branchWarning.SetSize(a.width, a.height) - a.branchWarning.SetContext(branch, prdName) - a.branchWarning.Reset() - a.pendingStartPRD = prdName - a.viewMode = ViewBranchWarning - return a, nil - } + if !git.IsGitRepo(a.baseDir) { + return a.doStartLoop(prdName, prdDir) } - return a.doStartLoop(prdName, prdDir) + branch, err := git.GetCurrentBranch(a.baseDir) + if err != nil { + return a.doStartLoop(prdName, prdDir) + } + + worktreePath := git.WorktreePathForPRD(a.baseDir, prdName) + relWorktreePath := fmt.Sprintf(".chief/worktrees/%s/", prdName) + + // Determine dialog context + isProtected := git.IsProtectedBranch(branch) + anotherRunningInSameDir := a.isAnotherPRDRunningInSameDir(prdName) + + var dialogCtx DialogContext + if isProtected { + dialogCtx = DialogProtectedBranch + } else if anotherRunningInSameDir { + dialogCtx = DialogAnotherPRDRunning + } else { + dialogCtx = DialogNoConflicts + } + + // Show the enhanced dialog + a.branchWarning.SetSize(a.width, a.height) + a.branchWarning.SetContext(branch, prdName, relWorktreePath) + a.branchWarning.SetDialogContext(dialogCtx) + a.branchWarning.Reset() + a.pendingStartPRD = prdName + a.pendingWorktreePath = worktreePath + a.viewMode = ViewBranchWarning + return a, nil +} + +// isAnotherPRDRunningInSameDir checks if another PRD is running in the project root (no worktree). +func (a *App) isAnotherPRDRunningInSameDir(prdName string) bool { + if a.manager == nil { + return false + } + for _, inst := range a.manager.GetAllInstances() { + if inst.Name != prdName && inst.State == loop.LoopStateRunning && inst.WorktreeDir == "" { + return true + } + } + return false } // doStartLoop actually starts the loop (after branch check). @@ -582,6 +754,8 @@ func (a App) handleLoopEvent(prdName string, event loop.Event) (tea.Model, tea.C a.logViewer.AddEvent(event) } + var autoActionCmd tea.Cmd + switch event.Type { case loop.EventIterationStart: if isCurrentPRD { @@ -612,6 +786,10 @@ func (a App) handleLoopEvent(prdName string, event loop.Event) (tea.Model, tea.C if isCurrentPRD { a.state = StateComplete a.lastActivity = "All stories complete!" + autoActionCmd = a.showCompletionScreen(prdName) + } else { + // For background PRDs, trigger auto-push/PR without showing completion screen + autoActionCmd = a.runBackgroundAutoActions(prdName) } // Trigger completion callback for any PRD if a.onCompletion != nil { @@ -648,7 +826,10 @@ func (a App) handleLoopEvent(prdName string, event loop.Event) (tea.Model, tea.C a.tabBar.Refresh() } - // Continue listening for manager events + // Continue listening for manager events, plus any auto-action commands + if autoActionCmd != nil { + return a, tea.Batch(a.listenForManagerEvents(), autoActionCmd) + } return a, a.listenForManagerEvents() } @@ -697,6 +878,12 @@ func (a App) View() string { return a.renderHelpView() case ViewBranchWarning: return a.renderBranchWarningView() + case ViewWorktreeSpinner: + return a.renderWorktreeSpinnerView() + case ViewCompletion: + return a.renderCompletionView() + case ViewSettings: + return a.renderSettingsView() default: return a.renderDashboard() } @@ -737,6 +924,7 @@ func (a App) handleBranchWarningKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc": a.viewMode = ViewDashboard a.pendingStartPRD = "" + a.pendingWorktreePath = "" a.lastActivity = "Cancelled" return a, nil @@ -749,8 +937,9 @@ func (a App) handleBranchWarningKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil case "e": - // Start editing branch name if on the create branch option - if a.branchWarning.GetSelectedOption() == BranchOptionCreateBranch { + // Start editing branch name if on an option that involves a branch + opt := a.branchWarning.GetSelectedOption() + if opt == BranchOptionCreateWorktree || opt == BranchOptionCreateBranch { a.branchWarning.StartEditMode() } return a, nil @@ -759,9 +948,34 @@ func (a App) handleBranchWarningKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { prdName := a.pendingStartPRD prdDir := filepath.Join(a.baseDir, ".chief", "prds", prdName) a.pendingStartPRD = "" + a.pendingWorktreePath = "" a.viewMode = ViewDashboard switch a.branchWarning.GetSelectedOption() { + case BranchOptionCreateWorktree: + branchName := a.branchWarning.GetSuggestedBranch() + worktreePath := git.WorktreePathForPRD(a.baseDir, prdName) + relWorktreePath := fmt.Sprintf(".chief/worktrees/%s/", prdName) + + // Detect default branch for display + defaultBranch := "main" + if db, err := git.GetDefaultBranch(a.baseDir); err == nil { + defaultBranch = db + } + + // Configure and show the spinner + a.worktreeSpinner.Configure(prdName, branchName, defaultBranch, relWorktreePath, a.config.Worktree.Setup) + a.worktreeSpinner.SetSize(a.width, a.height) + a.pendingStartPRD = prdName + a.pendingWorktreePath = worktreePath + a.viewMode = ViewWorktreeSpinner + + // Start the first async step (create worktree which includes branch creation) + return a, tea.Batch( + tickWorktreeSpinner(), + a.runWorktreeStep(SpinnerStepCreateBranch, a.baseDir, worktreePath, branchName), + ) + case BranchOptionCreateBranch: // Create the branch with (possibly edited) name branchName := a.branchWarning.GetSuggestedBranch() @@ -769,12 +983,16 @@ func (a App) handleBranchWarningKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.lastActivity = "Error creating branch: " + err.Error() return a, nil } + // Track the branch on the manager instance + if instance := a.manager.GetInstance(prdName); instance != nil { + a.manager.UpdateWorktreeInfo(prdName, "", branchName) + } a.lastActivity = "Created branch: " + branchName // Now start the loop return a.doStartLoop(prdName, prdDir) case BranchOptionContinue: - // Continue on current branch + // Continue on current branch / run in same directory return a.doStartLoop(prdName, prdDir) case BranchOptionCancel: @@ -786,6 +1004,599 @@ func (a App) handleBranchWarningKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } +// renderWorktreeSpinnerView renders the worktree setup spinner. +func (a *App) renderWorktreeSpinnerView() string { + a.worktreeSpinner.SetSize(a.width, a.height) + return a.worktreeSpinner.Render() +} + +// handleWorktreeSpinnerKeys handles keyboard input for the worktree spinner. +func (a App) handleWorktreeSpinnerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + // Cancel setup and clean up + a.worktreeSpinner.Cancel() + a.cleanupWorktreeSetup() + a.viewMode = ViewDashboard + a.lastActivity = "Worktree setup cancelled" + a.pendingStartPRD = "" + a.pendingWorktreePath = "" + return a, nil + } + // Ignore all other keys during spinner + return a, nil +} + +// cleanupWorktreeSetup cleans up a partially created worktree and branch. +func (a *App) cleanupWorktreeSetup() { + if a.pendingWorktreePath != "" { + // Try to remove the worktree if it was created + if git.IsWorktree(a.pendingWorktreePath) { + _ = git.RemoveWorktree(a.baseDir, a.pendingWorktreePath) + } + } +} + +// showCompletionScreen configures and shows the completion screen for a PRD. +// Returns a tea.Cmd if auto-actions need to be started, nil otherwise. +func (a *App) showCompletionScreen(prdName string) tea.Cmd { + // Count completed stories + completed := 0 + total := len(a.prd.UserStories) + for _, story := range a.prd.UserStories { + if story.Passes { + completed++ + } + } + + // Get branch from manager + branch := "" + if instance := a.manager.GetInstance(prdName); instance != nil { + branch = instance.Branch + } + + // Count commits on the branch + commitCount := 0 + if branch != "" { + commitCount = git.CommitCount(a.baseDir, branch) + } + + // Check if auto-actions are configured + hasAutoActions := a.config != nil && (a.config.OnComplete.Push || a.config.OnComplete.CreatePR) + + a.completionScreen.Configure(prdName, completed, total, branch, commitCount, hasAutoActions) + a.completionScreen.SetSize(a.width, a.height) + a.viewMode = ViewCompletion + + // Trigger auto-push if configured and branch is set + if a.config != nil && a.config.OnComplete.Push && branch != "" { + a.completionScreen.SetPushInProgress() + return tea.Batch( + tickCompletionSpinner(), + a.runAutoPush(), + ) + } + + // If only PR is configured (no push), we can't create a PR without pushing first + // So PR-only without push is a no-op (push is required for PR) + return nil +} + +// backgroundAutoActionResultMsg is sent when a background PRD auto-action completes. +type backgroundAutoActionResultMsg struct { + prdName string + action string // "push" or "pr" + err error +} + +// runBackgroundAutoActions triggers auto-push/PR for a background PRD that just completed. +func (a *App) runBackgroundAutoActions(prdName string) tea.Cmd { + if a.config == nil || !a.config.OnComplete.Push { + return nil + } + + instance := a.manager.GetInstance(prdName) + if instance == nil || instance.Branch == "" { + return nil + } + + branch := instance.Branch + dir := a.baseDir + if instance.WorktreeDir != "" { + dir = instance.WorktreeDir + } + + return func() tea.Msg { + if err := git.PushBranch(dir, branch); err != nil { + return backgroundAutoActionResultMsg{prdName: prdName, action: "push", err: err} + } + return backgroundAutoActionResultMsg{prdName: prdName, action: "push"} + } +} + +// handleAutoActionResult handles the result of an auto-action (push or PR creation). +func (a App) handleAutoActionResult(msg autoActionResultMsg) (tea.Model, tea.Cmd) { + switch msg.action { + case "push": + if msg.err != nil { + a.completionScreen.SetPushError(msg.err.Error()) + return a, nil + } + a.completionScreen.SetPushSuccess() + + // If PR creation is configured, start it now + if a.config != nil && a.config.OnComplete.CreatePR && a.completionScreen.HasBranch() { + a.completionScreen.SetPRInProgress() + return a, tea.Batch( + tickCompletionSpinner(), + a.runAutoCreatePR(), + ) + } + return a, nil + + case "pr": + if msg.err != nil { + a.completionScreen.SetPRError(msg.err.Error()) + return a, nil + } + a.completionScreen.SetPRSuccess(msg.prURL, msg.prTitle) + return a, nil + } + return a, nil +} + +// handleBackgroundAutoAction handles auto-action results for background PRDs. +func (a App) handleBackgroundAutoAction(msg backgroundAutoActionResultMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + // Log error but don't block - background action failed silently + return a, nil + } + + if msg.action == "push" && a.config != nil && a.config.OnComplete.CreatePR { + // Chain PR creation after successful push + instance := a.manager.GetInstance(msg.prdName) + if instance != nil && instance.Branch != "" { + prdName := msg.prdName + branch := instance.Branch + dir := a.baseDir + prdPath := filepath.Join(a.baseDir, ".chief", "prds", prdName, "prd.json") + return a, func() tea.Msg { + p, err := prd.LoadPRD(prdPath) + if err != nil { + return backgroundAutoActionResultMsg{prdName: prdName, action: "pr", err: err} + } + title := git.PRTitleFromPRD(prdName, p) + body := git.PRBodyFromPRD(p) + _, err = git.CreatePR(dir, branch, title, body) + return backgroundAutoActionResultMsg{prdName: prdName, action: "pr", err: err} + } + } + } + + return a, nil +} + +// runAutoPush returns a tea.Cmd that pushes the branch in the background. +func (a *App) runAutoPush() tea.Cmd { + branch := a.completionScreen.Branch() + // Use worktree dir if available, otherwise base dir + dir := a.baseDir + if instance := a.manager.GetInstance(a.completionScreen.PRDName()); instance != nil && instance.WorktreeDir != "" { + dir = instance.WorktreeDir + } + return func() tea.Msg { + err := git.PushBranch(dir, branch) + return autoActionResultMsg{action: "push", err: err} + } +} + +// runAutoCreatePR returns a tea.Cmd that creates a PR in the background. +func (a *App) runAutoCreatePR() tea.Cmd { + prdName := a.completionScreen.PRDName() + branch := a.completionScreen.Branch() + dir := a.baseDir + + // Load the PRD to generate PR content + prdPath := filepath.Join(a.baseDir, ".chief", "prds", prdName, "prd.json") + return func() tea.Msg { + p, err := prd.LoadPRD(prdPath) + if err != nil { + return autoActionResultMsg{action: "pr", err: fmt.Errorf("failed to load PRD: %s", err.Error())} + } + title := git.PRTitleFromPRD(prdName, p) + body := git.PRBodyFromPRD(p) + url, err := git.CreatePR(dir, branch, title, body) + if err != nil { + return autoActionResultMsg{action: "pr", err: err} + } + return autoActionResultMsg{action: "pr", prURL: url, prTitle: title} + } +} + +// renderCompletionView renders the completion screen. +func (a *App) renderCompletionView() string { + a.completionScreen.SetSize(a.width, a.height) + return a.completionScreen.Render() +} + +// renderSettingsView renders the settings overlay. +func (a *App) renderSettingsView() string { + a.settingsOverlay.SetSize(a.width, a.height) + return a.settingsOverlay.Render() +} + +// handleSettingsKeys handles keyboard input for the settings overlay. +func (a App) handleSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Dismiss GH error on any key + if a.settingsOverlay.HasGHError() { + a.settingsOverlay.DismissGHError() + return a, nil + } + + // Handle inline text editing + if a.settingsOverlay.IsEditing() { + switch msg.String() { + case "enter": + a.settingsOverlay.ConfirmEdit() + a.settingsOverlay.ApplyToConfig(a.config) + _ = config.Save(a.baseDir, a.config) + return a, nil + case "esc": + a.settingsOverlay.CancelEdit() + return a, nil + case "backspace": + a.settingsOverlay.DeleteEditChar() + return a, nil + default: + if len(msg.String()) == 1 { + a.settingsOverlay.AddEditChar(rune(msg.String()[0])) + } + return a, nil + } + } + + switch msg.String() { + case "esc": + a.viewMode = a.previousViewMode + return a, nil + case "q", "ctrl+c": + a.stopAllLoops() + a.stopWatcher() + return a, tea.Quit + case "up", "k": + a.settingsOverlay.MoveUp() + return a, nil + case "down", "j": + a.settingsOverlay.MoveDown() + return a, nil + case "enter": + item := a.settingsOverlay.GetSelectedItem() + if item == nil { + return a, nil + } + switch item.Type { + case SettingsItemBool: + key, newVal := a.settingsOverlay.ToggleBool() + if key == "onComplete.createPR" && newVal { + // Validate GH CLI asynchronously + return a, func() tea.Msg { + installed, authenticated, err := git.CheckGHCLI() + return settingsGHCheckResultMsg{installed: installed, authenticated: authenticated, err: err} + } + } + a.settingsOverlay.ApplyToConfig(a.config) + _ = config.Save(a.baseDir, a.config) + return a, nil + case SettingsItemString: + a.settingsOverlay.StartEditing() + return a, nil + } + } + + return a, nil +} + +// handleSettingsGHCheck handles the GH CLI check result from settings. +func (a App) handleSettingsGHCheck(msg settingsGHCheckResultMsg) (tea.Model, tea.Cmd) { + if a.viewMode != ViewSettings { + return a, nil + } + + if msg.err != nil || !msg.installed || !msg.authenticated { + // Validation failed - revert toggle and show error + a.settingsOverlay.RevertToggle() + errMsg := "GitHub CLI (gh) is not installed" + if msg.installed && !msg.authenticated { + errMsg = "GitHub CLI (gh) is not authenticated. Run: gh auth login" + } + if msg.err != nil { + errMsg = msg.err.Error() + } + a.settingsOverlay.SetGHError(errMsg) + return a, nil + } + + // Validation passed - save the config + a.settingsOverlay.ApplyToConfig(a.config) + _ = config.Save(a.baseDir, a.config) + return a, nil +} + +// handleCompletionKeys handles keyboard input for the completion screen. +func (a App) handleCompletionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + a.stopAllLoops() + a.stopWatcher() + return a, tea.Quit + + case "l": + // Switch to the picker + a.picker.Refresh() + a.picker.SetSize(a.width, a.height) + a.viewMode = ViewPicker + return a, nil + + case "m": + // Merge the completed PRD's branch + if a.completionScreen.HasBranch() { + branch := a.completionScreen.Branch() + baseDir := a.baseDir + a.viewMode = ViewDashboard + return a, func() tea.Msg { + conflicts, err := git.MergeBranch(baseDir, branch) + if err != nil { + return mergeResultMsg{branch: branch, conflicts: conflicts, err: err} + } + output := parseMergeSuccessMessage(baseDir, branch) + return mergeResultMsg{branch: branch, output: output} + } + } + return a, nil + + case "c": + // Clean the PRD's worktree - switch to picker with clean dialog + if a.completionScreen.HasBranch() { + prdName := a.completionScreen.PRDName() + a.picker.Refresh() + a.picker.SetSize(a.width, a.height) + // Select the completed PRD in the picker + for i, entry := range a.picker.entries { + if entry.Name == prdName { + a.picker.selectedIndex = i + break + } + } + if a.picker.CanClean() { + a.picker.StartCleanConfirmation() + } + a.viewMode = ViewPicker + } + return a, nil + + case "esc": + a.viewMode = ViewDashboard + return a, nil + } + + return a, nil +} + +// tickCompletionSpinner returns a tea.Cmd that ticks the completion screen spinner. +func tickCompletionSpinner() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { + return completionSpinnerTickMsg{} + }) +} + +// tickWorktreeSpinner returns a tea.Cmd that ticks the spinner animation. +func tickWorktreeSpinner() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { + return worktreeSpinnerTickMsg{} + }) +} + +// runWorktreeStep runs a worktree setup step asynchronously. +func (a *App) runWorktreeStep(step WorktreeSpinnerStep, baseDir, worktreePath, branchName string) tea.Cmd { + switch step { + case SpinnerStepCreateBranch: + return func() tea.Msg { + // CreateWorktree handles both branch creation and worktree addition + if err := git.CreateWorktree(baseDir, worktreePath, branchName); err != nil { + return worktreeStepResultMsg{step: SpinnerStepCreateBranch, err: err} + } + return worktreeStepResultMsg{step: SpinnerStepCreateBranch} + } + + case SpinnerStepRunSetup: + setupCmd := a.config.Worktree.Setup + return func() tea.Msg { + cmd := exec.Command("sh", "-c", setupCmd) + cmd.Dir = worktreePath + if out, err := cmd.CombinedOutput(); err != nil { + return worktreeStepResultMsg{ + step: SpinnerStepRunSetup, + err: fmt.Errorf("%s\n%s", err.Error(), strings.TrimSpace(string(out))), + } + } + return worktreeStepResultMsg{step: SpinnerStepRunSetup} + } + } + return nil +} + +// handleWorktreeStepResult handles the result of a worktree setup step. +func (a App) handleWorktreeStepResult(msg worktreeStepResultMsg) (tea.Model, tea.Cmd) { + // Ignore results if we've already cancelled or left the spinner view + if a.viewMode != ViewWorktreeSpinner || a.worktreeSpinner.IsCancelled() { + return a, nil + } + + if msg.err != nil { + a.worktreeSpinner.SetError(msg.err.Error()) + return a, nil + } + + switch msg.step { + case SpinnerStepCreateBranch: + // Branch creation completed - advance through both branch and worktree steps + // (CreateWorktree does both in one call) + a.worktreeSpinner.AdvanceStep() // Complete "Creating branch" + a.worktreeSpinner.AdvanceStep() // Complete "Creating worktree" + + // Check if we need to run setup + if a.worktreeSpinner.HasSetupCommand() { + return a, a.runWorktreeStep(SpinnerStepRunSetup, a.baseDir, a.pendingWorktreePath, "") + } + + // No setup - we're done, transition to loop + return a.finishWorktreeSetup() + + case SpinnerStepRunSetup: + a.worktreeSpinner.AdvanceStep() // Complete "Running setup" + return a.finishWorktreeSetup() + } + + return a, nil +} + +// finishWorktreeSetup completes the worktree setup and starts the loop. +func (a App) finishWorktreeSetup() (tea.Model, tea.Cmd) { + prdName := a.pendingStartPRD + worktreePath := a.pendingWorktreePath + branchName := a.worktreeSpinner.branchName + prdDir := filepath.Join(a.baseDir, ".chief", "prds", prdName) + + // Register or update with worktree info + prdPath := filepath.Join(prdDir, "prd.json") + if instance := a.manager.GetInstance(prdName); instance == nil { + a.manager.RegisterWithWorktree(prdName, prdPath, worktreePath, branchName) + } else { + a.manager.UpdateWorktreeInfo(prdName, worktreePath, branchName) + } + + a.lastActivity = fmt.Sprintf("Created worktree at %s on branch %s", worktreePath, branchName) + a.viewMode = ViewDashboard + a.pendingStartPRD = "" + a.pendingWorktreePath = "" + + return a.doStartLoop(prdName, prdDir) +} + +// handleMergeResult handles the result of an async merge operation. +func (a App) handleMergeResult(msg mergeResultMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + a.picker.SetMergeResult(&MergeResult{ + Success: false, + Message: fmt.Sprintf("Failed to merge %s into current branch", msg.branch), + Conflicts: msg.conflicts, + Branch: msg.branch, + }) + } else { + a.picker.SetMergeResult(&MergeResult{ + Success: true, + Message: msg.output, + Branch: msg.branch, + }) + a.lastActivity = fmt.Sprintf("Merged %s", msg.branch) + } + // Switch to picker to show the merge result if not already there + if a.viewMode != ViewPicker { + a.picker.Refresh() + a.picker.SetSize(a.width, a.height) + a.viewMode = ViewPicker + } + return a, nil +} + +// handleCleanConfirmationKeys handles keyboard input for the clean confirmation dialog. +func (a App) handleCleanConfirmationKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + a.picker.CancelCleanConfirmation() + return a, nil + case "up", "k": + a.picker.CleanConfirmMoveUp() + return a, nil + case "down", "j": + a.picker.CleanConfirmMoveDown() + return a, nil + case "enter": + cc := a.picker.GetCleanConfirmation() + if cc == nil { + return a, nil + } + + option := a.picker.GetCleanOption() + if option == CleanOptionCancel { + a.picker.CancelCleanConfirmation() + return a, nil + } + + prdName := cc.EntryName + branch := cc.Branch + clearBranch := option == CleanOptionRemoveAll + baseDir := a.baseDir + worktreePath := git.WorktreePathForPRD(baseDir, prdName) + + return a, func() tea.Msg { + // Remove the worktree + if err := git.RemoveWorktree(baseDir, worktreePath); err != nil { + return cleanResultMsg{ + prdName: prdName, + success: false, + message: fmt.Sprintf("Failed to remove worktree: %s", err.Error()), + } + } + + // Delete branch if requested + if clearBranch && branch != "" { + if err := git.DeleteBranch(baseDir, branch); err != nil { + return cleanResultMsg{ + prdName: prdName, + success: true, + message: fmt.Sprintf("Removed worktree but failed to delete branch: %s", err.Error()), + clearBranch: false, + } + } + } + + msg := fmt.Sprintf("Removed worktree for %s", prdName) + if clearBranch && branch != "" { + msg = fmt.Sprintf("Removed worktree and deleted branch %s", branch) + } + return cleanResultMsg{ + prdName: prdName, + success: true, + message: msg, + clearBranch: clearBranch, + } + } + } + + return a, nil +} + +// handleCleanResult handles the result of an async clean operation. +func (a App) handleCleanResult(msg cleanResultMsg) (tea.Model, tea.Cmd) { + a.picker.CancelCleanConfirmation() + a.picker.SetCleanResult(&CleanResult{ + Success: msg.success, + Message: msg.message, + }) + + if msg.success { + // Clear worktree info from manager + if a.manager != nil { + a.manager.ClearWorktreeInfo(msg.prdName, msg.clearBranch) + } + a.picker.Refresh() + a.lastActivity = fmt.Sprintf("Cleaned worktree for %s", msg.prdName) + } + + return a, nil +} + // renderHelpView renders the help overlay. func (a *App) renderHelpView() string { a.helpOverlay.SetSize(a.width, a.height) @@ -825,6 +1636,25 @@ func (a App) handlePickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } + // Dismiss clean result on any key + if a.picker.HasCleanResult() { + a.picker.ClearCleanResult() + a.picker.Refresh() + return a, nil + } + + // Handle clean confirmation dialog + if a.picker.HasCleanConfirmation() { + return a.handleCleanConfirmationKeys(msg) + } + + // Dismiss merge result on any key + if a.picker.HasMergeResult() { + a.picker.ClearMergeResult() + a.picker.Refresh() + return a, nil + } + // Normal picker mode switch msg.String() { case "esc", "l": @@ -895,11 +1725,46 @@ func (a App) handlePickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } return a, nil + + case "m": + // Merge completed PRD's branch + if a.picker.CanMerge() { + entry := a.picker.GetSelectedEntry() + branch := entry.Branch + baseDir := a.baseDir + return a, func() tea.Msg { + conflicts, err := git.MergeBranch(baseDir, branch) + if err != nil { + return mergeResultMsg{branch: branch, conflicts: conflicts, err: err} + } + // Build success message with merge details + output := parseMergeSuccessMessage(baseDir, branch) + return mergeResultMsg{branch: branch, output: output} + } + } + return a, nil + + case "c": + // Clean worktree for non-running PRD + if a.picker.CanClean() { + a.picker.StartCleanConfirmation() + } + return a, nil } return a, nil } +// parseMergeSuccessMessage constructs a success message after a merge. +func parseMergeSuccessMessage(repoDir, branch string) string { + // Try to get the default branch for display + defaultBranch := "current branch" + if db, err := git.GetDefaultBranch(repoDir); err == nil { + defaultBranch = db + } + return fmt.Sprintf("Merged %s into %s", branch, defaultBranch) +} + // switchToPRD switches to a different PRD (view only - does not stop other loops). func (a App) switchToPRD(name, prdPath string) (tea.Model, tea.Cmd) { // Stop current watcher (but NOT the loop - it can keep running) diff --git a/internal/tui/branch_warning.go b/internal/tui/branch_warning.go index fc2b12a..a2b9946 100644 --- a/internal/tui/branch_warning.go +++ b/internal/tui/branch_warning.go @@ -7,30 +7,54 @@ import ( "github.com/charmbracelet/lipgloss" ) -// BranchWarningOption represents an option in the branch warning dialog. +// BranchWarningOption represents the user's choice in the branch warning dialog. type BranchWarningOption int const ( - BranchOptionCreateBranch BranchWarningOption = iota - BranchOptionContinue - BranchOptionCancel + BranchOptionCreateWorktree BranchWarningOption = iota // Create worktree + branch + BranchOptionCreateBranch // Create branch only (no worktree) + BranchOptionContinue // Continue on current branch / run in same directory + BranchOptionCancel // Cancel ) +// DialogContext determines which set of options to show. +type DialogContext int + +const ( + // DialogProtectedBranch: on a protected branch (main/master) + DialogProtectedBranch DialogContext = iota + // DialogAnotherPRDRunning: another PRD is already running in the same directory + DialogAnotherPRDRunning + // DialogNoConflicts: not protected, nothing else running in same dir + DialogNoConflicts +) + +// dialogOption represents a single option in the dialog. +type dialogOption struct { + label string // Display label + hint string // Path hint (e.g., ".chief/worktrees/auth/") + recommended bool // Whether this is the recommended option + option BranchWarningOption // The option value this maps to +} + // BranchWarning manages the branch warning dialog state. type BranchWarning struct { width int height int currentBranch string prdName string + worktreePath string // Relative worktree path (e.g., ".chief/worktrees/auth/") selectedIndex int editMode bool // Whether we're editing the branch name branchName string // The current branch name (editable) + context DialogContext + options []dialogOption } // NewBranchWarning creates a new branch warning dialog. func NewBranchWarning() *BranchWarning { return &BranchWarning{ - selectedIndex: 0, // Default to "Create branch" option + selectedIndex: 0, } } @@ -40,11 +64,83 @@ func (b *BranchWarning) SetSize(width, height int) { b.height = height } -// SetContext sets the branch and PRD context for the warning. -func (b *BranchWarning) SetContext(currentBranch, prdName string) { +// SetContext sets the branch, PRD context, and worktree path for the warning. +func (b *BranchWarning) SetContext(currentBranch, prdName, worktreePath string) { b.currentBranch = currentBranch b.prdName = prdName b.branchName = fmt.Sprintf("chief/%s", prdName) + b.worktreePath = worktreePath +} + +// SetDialogContext sets which context mode the dialog should display. +func (b *BranchWarning) SetDialogContext(ctx DialogContext) { + b.context = ctx + b.buildOptions() +} + +// buildOptions creates the option list based on the dialog context. +func (b *BranchWarning) buildOptions() { + switch b.context { + case DialogProtectedBranch: + b.options = []dialogOption{ + { + label: "Create worktree + branch", + hint: b.worktreePath, + recommended: true, + option: BranchOptionCreateWorktree, + }, + { + label: "Create branch only", + hint: "./ (current directory)", + option: BranchOptionCreateBranch, + }, + { + label: fmt.Sprintf("Continue on %s", b.currentBranch), + hint: "./ (current directory)", + option: BranchOptionContinue, + }, + { + label: "Cancel", + option: BranchOptionCancel, + }, + } + case DialogAnotherPRDRunning: + b.options = []dialogOption{ + { + label: "Create worktree", + hint: b.worktreePath, + recommended: true, + option: BranchOptionCreateWorktree, + }, + { + label: "Run in same directory", + hint: "./ (current directory)", + option: BranchOptionContinue, + }, + { + label: "Cancel", + option: BranchOptionCancel, + }, + } + case DialogNoConflicts: + b.options = []dialogOption{ + { + label: "Run in current directory", + hint: "./ (current directory)", + recommended: true, + option: BranchOptionContinue, + }, + { + label: "Create worktree + branch", + hint: b.worktreePath, + option: BranchOptionCreateWorktree, + }, + { + label: "Cancel", + option: BranchOptionCancel, + }, + } + } } // GetSuggestedBranch returns the branch name (may be edited by user). @@ -61,14 +157,22 @@ func (b *BranchWarning) MoveUp() { // MoveDown moves selection down. func (b *BranchWarning) MoveDown() { - if b.selectedIndex < 2 { + if b.selectedIndex < len(b.options)-1 { b.selectedIndex++ } } // GetSelectedOption returns the currently selected option. func (b *BranchWarning) GetSelectedOption() BranchWarningOption { - return BranchWarningOption(b.selectedIndex) + if b.selectedIndex >= 0 && b.selectedIndex < len(b.options) { + return b.options[b.selectedIndex].option + } + return BranchOptionCancel +} + +// GetDialogContext returns the current dialog context. +func (b *BranchWarning) GetDialogContext() DialogContext { + return b.context } // Reset resets the dialog state. @@ -109,103 +213,58 @@ func (b *BranchWarning) DeleteInputChar() { } } +// selectedOptionHasBranch returns true if the currently selected option involves branch creation. +func (b *BranchWarning) selectedOptionHasBranch() bool { + opt := b.GetSelectedOption() + return opt == BranchOptionCreateWorktree || opt == BranchOptionCreateBranch +} + // Render renders the branch warning dialog. func (b *BranchWarning) Render() string { // Modal dimensions - modalWidth := min(60, b.width-10) - modalHeight := min(16, b.height-6) + modalWidth := min(65, b.width-10) + modalHeight := min(20, b.height-6) if modalWidth < 40 { modalWidth = 40 } - if modalHeight < 12 { - modalHeight = 12 + if modalHeight < 14 { + modalHeight = 14 } // Build modal content var content strings.Builder - // Warning icon and title - warningStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(WarningColor) - content.WriteString(warningStyle.Render("⚠️ Protected Branch Warning")) - content.WriteString("\n") - content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) - content.WriteString("\n\n") + // Title and message based on context + b.renderHeader(&content, modalWidth) - // Warning message - messageStyle := lipgloss.NewStyle().Foreground(TextColor) - content.WriteString(messageStyle.Render(fmt.Sprintf("You are on the '%s' branch.", b.currentBranch))) - content.WriteString("\n") - content.WriteString(messageStyle.Render("Starting the loop will make changes directly to this branch.")) - content.WriteString("\n\n") + // Branch name (shown when any option involves a branch) + b.renderBranchName(&content) // Options - optionStyle := lipgloss.NewStyle().Foreground(TextColor) - selectedOptionStyle := lipgloss.NewStyle(). - Foreground(PrimaryColor). - Bold(true) - - // Render the "Create branch" option with editable field - if b.selectedIndex == 0 { - content.WriteString(selectedOptionStyle.Render("▶ Create branch ")) - if b.editMode { - // Show editable input field - inputStyle := lipgloss.NewStyle(). - Foreground(TextBrightColor). - Background(lipgloss.Color("237")) - cursorStyle := lipgloss.NewStyle().Foreground(PrimaryColor).Blink(true) - content.WriteString(inputStyle.Render(b.branchName)) - content.WriteString(cursorStyle.Render("▌")) - } else { - // Show branch name with edit hint - content.WriteString(selectedOptionStyle.Render(fmt.Sprintf("'%s'", b.branchName))) - content.WriteString(" ") - content.WriteString(lipgloss.NewStyle().Foreground(SuccessColor).Render("(Recommended)")) - content.WriteString(" ") - content.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Render("[e: edit]")) - } - } else { - content.WriteString(optionStyle.Render(fmt.Sprintf(" Create branch '%s'", b.branchName))) - content.WriteString(" ") - content.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Render("(Recommended)")) - } - content.WriteString("\n") - - // Render "Continue on current branch" option - if b.selectedIndex == 1 { - content.WriteString(selectedOptionStyle.Render(fmt.Sprintf("▶ Continue on '%s'", b.currentBranch))) - } else { - content.WriteString(optionStyle.Render(fmt.Sprintf(" Continue on '%s'", b.currentBranch))) - } - content.WriteString("\n") - - // Render "Cancel" option - if b.selectedIndex == 2 { - content.WriteString(selectedOptionStyle.Render("▶ Cancel")) - } else { - content.WriteString(optionStyle.Render(" Cancel")) - } - content.WriteString("\n") + b.renderOptions(&content) // Footer content.WriteString("\n") content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) content.WriteString("\n") - footerStyle := lipgloss.NewStyle(). - Foreground(MutedColor) + footerStyle := lipgloss.NewStyle().Foreground(MutedColor) if b.editMode { content.WriteString(footerStyle.Render("Enter: confirm Esc: cancel edit")) } else { - content.WriteString(footerStyle.Render("↑/↓: Navigate Enter: Select Esc: Cancel")) + content.WriteString(footerStyle.Render("↑/↓: Navigate Enter: Select e: Edit branch Esc: Cancel")) + } + + // Modal box style - use warning color for protected branch, primary for others + borderColor := PrimaryColor + if b.context == DialogProtectedBranch { + borderColor = WarningColor } - // Modal box style modalStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(WarningColor). + BorderForeground(borderColor). Padding(1, 2). Width(modalWidth). Height(modalHeight) @@ -216,6 +275,99 @@ func (b *BranchWarning) Render() string { return b.centerModal(modal) } +// renderHeader renders the dialog title and message. +func (b *BranchWarning) renderHeader(content *strings.Builder, modalWidth int) { + titleStyle := lipgloss.NewStyle().Bold(true) + + switch b.context { + case DialogProtectedBranch: + content.WriteString(titleStyle.Foreground(WarningColor).Render("⚠️ Protected Branch Warning")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + messageStyle := lipgloss.NewStyle().Foreground(TextColor) + content.WriteString(messageStyle.Render(fmt.Sprintf("You are on the '%s' branch.", b.currentBranch))) + content.WriteString("\n") + content.WriteString(messageStyle.Render("It's recommended to isolate work in a worktree.")) + content.WriteString("\n\n") + + case DialogAnotherPRDRunning: + content.WriteString(titleStyle.Foreground(PrimaryColor).Render("Directory In Use")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + messageStyle := lipgloss.NewStyle().Foreground(TextColor) + content.WriteString(messageStyle.Render("Another PRD is already running in this directory.")) + content.WriteString("\n") + content.WriteString(messageStyle.Render("A worktree will avoid file conflicts.")) + content.WriteString("\n\n") + + case DialogNoConflicts: + content.WriteString(titleStyle.Foreground(PrimaryColor).Render("Start PRD")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + messageStyle := lipgloss.NewStyle().Foreground(TextColor) + content.WriteString(messageStyle.Render("Choose where Claude should work:")) + content.WriteString("\n\n") + } +} + +// renderBranchName renders the branch name display/editor. +func (b *BranchWarning) renderBranchName(content *strings.Builder) { + branchLabelStyle := lipgloss.NewStyle().Foreground(MutedColor) + + if b.editMode { + content.WriteString(branchLabelStyle.Render("Branch: ")) + inputStyle := lipgloss.NewStyle(). + Foreground(TextBrightColor). + Background(lipgloss.Color("237")) + cursorStyle := lipgloss.NewStyle().Foreground(PrimaryColor).Blink(true) + content.WriteString(inputStyle.Render(b.branchName)) + content.WriteString(cursorStyle.Render("▌")) + content.WriteString("\n\n") + } else { + content.WriteString(branchLabelStyle.Render(fmt.Sprintf("Branch: %s", b.branchName))) + content.WriteString("\n\n") + } +} + +// renderOptions renders the selectable options list. +func (b *BranchWarning) renderOptions(content *strings.Builder) { + optionStyle := lipgloss.NewStyle().Foreground(TextColor) + selectedStyle := lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(MutedColor) + recommendedStyle := lipgloss.NewStyle().Foreground(SuccessColor) + + for i, opt := range b.options { + isSelected := i == b.selectedIndex + + // Render option label + if isSelected { + content.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", opt.label))) + } else { + content.WriteString(optionStyle.Render(fmt.Sprintf(" %s", opt.label))) + } + + // Render recommended tag + if opt.recommended { + content.WriteString(" ") + content.WriteString(recommendedStyle.Render("(Recommended)")) + } + + content.WriteString("\n") + + // Render path hint (indented under the option) + if opt.hint != "" { + content.WriteString(hintStyle.Render(fmt.Sprintf(" → %s", opt.hint))) + content.WriteString("\n") + } + } +} + // centerModal centers the modal on the screen. func (b *BranchWarning) centerModal(modal string) string { lines := strings.Split(modal, "\n") diff --git a/internal/tui/branch_warning_test.go b/internal/tui/branch_warning_test.go new file mode 100644 index 0000000..9488015 --- /dev/null +++ b/internal/tui/branch_warning_test.go @@ -0,0 +1,258 @@ +package tui + +import ( + "testing" +) + +func TestBranchWarningProtectedBranch(t *testing.T) { + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogProtectedBranch) + bw.Reset() + + // Should have 4 options: worktree+branch, branch only, continue on main, cancel + if len(bw.options) != 4 { + t.Fatalf("expected 4 options for protected branch, got %d", len(bw.options)) + } + + // First option should be "Create worktree + branch" (recommended) + if bw.options[0].option != BranchOptionCreateWorktree { + t.Errorf("expected first option to be CreateWorktree, got %v", bw.options[0].option) + } + if !bw.options[0].recommended { + t.Error("expected first option to be recommended") + } + + // Default selection should be first option + if bw.GetSelectedOption() != BranchOptionCreateWorktree { + t.Errorf("expected default selection to be CreateWorktree, got %v", bw.GetSelectedOption()) + } + + // Second option should be "Create branch only" + if bw.options[1].option != BranchOptionCreateBranch { + t.Errorf("expected second option to be CreateBranch, got %v", bw.options[1].option) + } + + // Third option should be "Continue on main" + if bw.options[2].option != BranchOptionContinue { + t.Errorf("expected third option to be Continue, got %v", bw.options[2].option) + } + + // Fourth option should be Cancel + if bw.options[3].option != BranchOptionCancel { + t.Errorf("expected fourth option to be Cancel, got %v", bw.options[3].option) + } +} + +func TestBranchWarningAnotherPRDRunning(t *testing.T) { + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("feature/x", "payments", ".chief/worktrees/payments/") + bw.SetDialogContext(DialogAnotherPRDRunning) + bw.Reset() + + // Should have 3 options: create worktree, run in same dir, cancel + if len(bw.options) != 3 { + t.Fatalf("expected 3 options for another PRD running, got %d", len(bw.options)) + } + + if bw.options[0].option != BranchOptionCreateWorktree { + t.Errorf("expected first option to be CreateWorktree, got %v", bw.options[0].option) + } + if !bw.options[0].recommended { + t.Error("expected first option to be recommended") + } + + if bw.options[1].option != BranchOptionContinue { + t.Errorf("expected second option to be Continue, got %v", bw.options[1].option) + } + + if bw.options[2].option != BranchOptionCancel { + t.Errorf("expected third option to be Cancel, got %v", bw.options[2].option) + } +} + +func TestBranchWarningNoConflicts(t *testing.T) { + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("feature/x", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogNoConflicts) + bw.Reset() + + // Should have 3 options: run in current dir, create worktree+branch, cancel + if len(bw.options) != 3 { + t.Fatalf("expected 3 options for no conflicts, got %d", len(bw.options)) + } + + // First option should be "Run in current directory" (recommended) + if bw.options[0].option != BranchOptionContinue { + t.Errorf("expected first option to be Continue (current dir), got %v", bw.options[0].option) + } + if !bw.options[0].recommended { + t.Error("expected first option to be recommended") + } + + // Second option should be "Create worktree + branch" + if bw.options[1].option != BranchOptionCreateWorktree { + t.Errorf("expected second option to be CreateWorktree, got %v", bw.options[1].option) + } + + // Third option should be Cancel + if bw.options[2].option != BranchOptionCancel { + t.Errorf("expected third option to be Cancel, got %v", bw.options[2].option) + } +} + +func TestBranchWarningNavigation(t *testing.T) { + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogProtectedBranch) + bw.Reset() + + // Start at index 0 + if bw.selectedIndex != 0 { + t.Fatalf("expected initial index 0, got %d", bw.selectedIndex) + } + + // Move down + bw.MoveDown() + if bw.selectedIndex != 1 { + t.Errorf("expected index 1 after MoveDown, got %d", bw.selectedIndex) + } + + // Move down to the end + bw.MoveDown() + bw.MoveDown() + if bw.selectedIndex != 3 { + t.Errorf("expected index 3, got %d", bw.selectedIndex) + } + + // Can't go past the end + bw.MoveDown() + if bw.selectedIndex != 3 { + t.Errorf("expected index to stay at 3, got %d", bw.selectedIndex) + } + + // Move up + bw.MoveUp() + if bw.selectedIndex != 2 { + t.Errorf("expected index 2 after MoveUp, got %d", bw.selectedIndex) + } + + // Move up to the start + bw.MoveUp() + bw.MoveUp() + if bw.selectedIndex != 0 { + t.Errorf("expected index 0, got %d", bw.selectedIndex) + } + + // Can't go past the start + bw.MoveUp() + if bw.selectedIndex != 0 { + t.Errorf("expected index to stay at 0, got %d", bw.selectedIndex) + } +} + +func TestBranchWarningBranchEdit(t *testing.T) { + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogProtectedBranch) + bw.Reset() + + // Default branch name + if bw.GetSuggestedBranch() != "chief/auth" { + t.Errorf("expected branch 'chief/auth', got %q", bw.GetSuggestedBranch()) + } + + // Enter edit mode + bw.StartEditMode() + if !bw.IsEditMode() { + t.Error("expected edit mode to be true") + } + + // Delete and add chars + bw.DeleteInputChar() + bw.DeleteInputChar() + bw.DeleteInputChar() + bw.DeleteInputChar() + bw.AddInputChar('m') + bw.AddInputChar('y') + bw.AddInputChar('-') + bw.AddInputChar('p') + bw.AddInputChar('r') + bw.AddInputChar('d') + if bw.GetSuggestedBranch() != "chief/my-prd" { + t.Errorf("expected 'chief/my-prd', got %q", bw.GetSuggestedBranch()) + } + + // Invalid characters should be rejected + bw.AddInputChar(' ') + bw.AddInputChar('!') + if bw.GetSuggestedBranch() != "chief/my-prd" { + t.Errorf("expected 'chief/my-prd' (unchanged), got %q", bw.GetSuggestedBranch()) + } + + // Cancel edit mode + bw.CancelEditMode() + if bw.IsEditMode() { + t.Error("expected edit mode to be false") + } +} + +func TestBranchWarningPathHints(t *testing.T) { + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogProtectedBranch) + + // Check that options have correct path hints + if bw.options[0].hint != ".chief/worktrees/auth/" { + t.Errorf("expected worktree path hint, got %q", bw.options[0].hint) + } + if bw.options[1].hint != "./ (current directory)" { + t.Errorf("expected current dir hint for branch only, got %q", bw.options[1].hint) + } + if bw.options[2].hint != "./ (current directory)" { + t.Errorf("expected current dir hint for continue, got %q", bw.options[2].hint) + } +} + +func TestBranchWarningRender(t *testing.T) { + // Test that Render doesn't panic for each context + contexts := []DialogContext{DialogProtectedBranch, DialogAnotherPRDRunning, DialogNoConflicts} + for _, ctx := range contexts { + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(ctx) + bw.Reset() + + output := bw.Render() + if output == "" { + t.Errorf("expected non-empty render for context %d", ctx) + } + } +} + +func TestBranchWarningGetDialogContext(t *testing.T) { + bw := NewBranchWarning() + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + + bw.SetDialogContext(DialogProtectedBranch) + if bw.GetDialogContext() != DialogProtectedBranch { + t.Error("expected DialogProtectedBranch") + } + + bw.SetDialogContext(DialogAnotherPRDRunning) + if bw.GetDialogContext() != DialogAnotherPRDRunning { + t.Error("expected DialogAnotherPRDRunning") + } + + bw.SetDialogContext(DialogNoConflicts) + if bw.GetDialogContext() != DialogNoConflicts { + t.Error("expected DialogNoConflicts") + } +} diff --git a/internal/tui/completion.go b/internal/tui/completion.go new file mode 100644 index 0000000..c3881d9 --- /dev/null +++ b/internal/tui/completion.go @@ -0,0 +1,305 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// AutoActionState represents the progress of an auto-action (push or PR). +type AutoActionState int + +const ( + AutoActionIdle AutoActionState = iota // Not configured or not started + AutoActionInProgress // Currently running + AutoActionSuccess // Completed successfully + AutoActionError // Failed with error +) + +// CompletionScreen manages the completion screen state shown when a PRD finishes. +type CompletionScreen struct { + width int + height int + + prdName string + completed int + total int + branch string + commitCount int + hasAutoActions bool // Whether push/PR auto-actions are configured + + // Auto-action state + pushState AutoActionState + pushError string + prState AutoActionState + prError string + prURL string + prTitle string + spinnerFrame int +} + +// NewCompletionScreen creates a new completion screen. +func NewCompletionScreen() *CompletionScreen { + return &CompletionScreen{} +} + +// Configure sets up the completion screen with PRD completion data. +func (c *CompletionScreen) Configure(prdName string, completed, total int, branch string, commitCount int, hasAutoActions bool) { + c.prdName = prdName + c.completed = completed + c.total = total + c.branch = branch + c.commitCount = commitCount + c.hasAutoActions = hasAutoActions + // Reset auto-action state + c.pushState = AutoActionIdle + c.pushError = "" + c.prState = AutoActionIdle + c.prError = "" + c.prURL = "" + c.prTitle = "" + c.spinnerFrame = 0 +} + +// SetSize sets the screen dimensions. +func (c *CompletionScreen) SetSize(width, height int) { + c.width = width + c.height = height +} + +// PRDName returns the PRD name shown on the completion screen. +func (c *CompletionScreen) PRDName() string { + return c.prdName +} + +// Branch returns the branch shown on the completion screen. +func (c *CompletionScreen) Branch() string { + return c.branch +} + +// HasBranch returns true if the completion screen has a branch set. +func (c *CompletionScreen) HasBranch() bool { + return c.branch != "" +} + +// SetPushInProgress marks the push as in progress. +func (c *CompletionScreen) SetPushInProgress() { + c.pushState = AutoActionInProgress +} + +// SetPushSuccess marks the push as successful. +func (c *CompletionScreen) SetPushSuccess() { + c.pushState = AutoActionSuccess +} + +// SetPushError marks the push as failed with an error message. +func (c *CompletionScreen) SetPushError(errMsg string) { + c.pushState = AutoActionError + c.pushError = errMsg +} + +// SetPRInProgress marks the PR creation as in progress. +func (c *CompletionScreen) SetPRInProgress() { + c.prState = AutoActionInProgress +} + +// SetPRSuccess marks the PR creation as successful. +func (c *CompletionScreen) SetPRSuccess(url, title string) { + c.prState = AutoActionSuccess + c.prURL = url + c.prTitle = title +} + +// SetPRError marks the PR creation as failed with an error message. +func (c *CompletionScreen) SetPRError(errMsg string) { + c.prState = AutoActionError + c.prError = errMsg +} + +// Tick advances the spinner animation frame. +func (c *CompletionScreen) Tick() { + c.spinnerFrame++ +} + +// IsAutoActionRunning returns true if any auto-action is currently in progress. +func (c *CompletionScreen) IsAutoActionRunning() bool { + return c.pushState == AutoActionInProgress || c.prState == AutoActionInProgress +} + +// Render renders the completion screen. +func (c *CompletionScreen) Render() string { + modalWidth := min(60, c.width-10) + modalHeight := min(20, c.height-6) + + if modalWidth < 30 { + modalWidth = 30 + } + if modalHeight < 10 { + modalHeight = 10 + } + + var content strings.Builder + + // Header + headerStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(SuccessColor). + Padding(0, 1) + content.WriteString(headerStyle.Render(fmt.Sprintf("PRD Complete! %s %d/%d stories", c.prdName, c.completed, c.total))) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + // Branch and commit info + infoStyle := lipgloss.NewStyle(). + Foreground(TextColor). + Padding(0, 1) + + if c.branch != "" { + content.WriteString(infoStyle.Render(fmt.Sprintf("Branch: %s", c.branch))) + content.WriteString("\n") + + commitLabel := "commit" + if c.commitCount != 1 { + commitLabel = "commits" + } + content.WriteString(infoStyle.Render(fmt.Sprintf("Commits: %d %s on branch", c.commitCount, commitLabel))) + content.WriteString("\n") + } + content.WriteString("\n") + + // Auto-actions progress or hint + if c.pushState != AutoActionIdle || c.prState != AutoActionIdle { + // Show auto-action progress + content.WriteString(c.renderAutoActions(modalWidth)) + content.WriteString("\n") + } else if !c.hasAutoActions { + hintStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + content.WriteString(hintStyle.Render("Configure auto-push and PR in settings (,)")) + content.WriteString("\n\n") + } + + // Footer + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + + footerStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + + var shortcuts []string + if c.branch != "" { + shortcuts = append(shortcuts, "m: merge") + shortcuts = append(shortcuts, "c: clean") + } + shortcuts = append(shortcuts, "l: switch PRD") + shortcuts = append(shortcuts, "q: quit") + content.WriteString(footerStyle.Render(strings.Join(shortcuts, " │ "))) + + // Modal box style + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(SuccessColor). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight) + + modal := modalStyle.Render(content.String()) + + // Center the modal on screen + return centerModal(modal, c.width, c.height) +} + +// spinnerChars are the animation frames for the completion screen spinner. +var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// renderAutoActions renders the auto-action progress section. +func (c *CompletionScreen) renderAutoActions(modalWidth int) string { + var lines strings.Builder + + infoStyle := lipgloss.NewStyle(). + Foreground(TextColor). + Padding(0, 1) + successStyle := lipgloss.NewStyle(). + Foreground(SuccessColor). + Padding(0, 1) + errorStyle := lipgloss.NewStyle(). + Foreground(ErrorColor). + Padding(0, 1) + spinnerStyle := lipgloss.NewStyle(). + Foreground(PrimaryColor). + Padding(0, 1) + + // Push status + if c.pushState != AutoActionIdle { + switch c.pushState { + case AutoActionInProgress: + frame := spinnerChars[c.spinnerFrame%len(spinnerChars)] + lines.WriteString(spinnerStyle.Render(fmt.Sprintf("%s Pushing branch to remote...", frame))) + case AutoActionSuccess: + lines.WriteString(successStyle.Render("✓ Pushed branch to remote")) + case AutoActionError: + lines.WriteString(errorStyle.Render(fmt.Sprintf("✗ Push failed: %s", c.pushError))) + } + lines.WriteString("\n") + } + + // PR status + if c.prState != AutoActionIdle { + switch c.prState { + case AutoActionInProgress: + frame := spinnerChars[c.spinnerFrame%len(spinnerChars)] + lines.WriteString(spinnerStyle.Render(fmt.Sprintf("%s Creating pull request...", frame))) + case AutoActionSuccess: + lines.WriteString(successStyle.Render(fmt.Sprintf("✓ Created PR: %s", c.prTitle))) + lines.WriteString("\n") + lines.WriteString(infoStyle.Render(fmt.Sprintf(" %s", c.prURL))) + case AutoActionError: + lines.WriteString(errorStyle.Render(fmt.Sprintf("✗ PR creation failed: %s", c.prError))) + } + lines.WriteString("\n") + } + + _ = modalWidth + return lines.String() +} + +// centerModal centers a modal string on the screen. +func centerModal(modal string, screenWidth, screenHeight int) string { + lines := strings.Split(modal, "\n") + modalHeight := len(lines) + modalWidth := 0 + for _, line := range lines { + if lipgloss.Width(line) > modalWidth { + modalWidth = lipgloss.Width(line) + } + } + + topPadding := (screenHeight - modalHeight) / 2 + leftPadding := (screenWidth - modalWidth) / 2 + + if topPadding < 0 { + topPadding = 0 + } + if leftPadding < 0 { + leftPadding = 0 + } + + var result strings.Builder + + for i := 0; i < topPadding; i++ { + result.WriteString("\n") + } + + leftPad := strings.Repeat(" ", leftPadding) + for _, line := range lines { + result.WriteString(leftPad) + result.WriteString(line) + result.WriteString("\n") + } + + return result.String() +} diff --git a/internal/tui/completion_test.go b/internal/tui/completion_test.go new file mode 100644 index 0000000..fc54064 --- /dev/null +++ b/internal/tui/completion_test.go @@ -0,0 +1,322 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestCompletionScreen_Configure(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 10, "chief/auth", 5, true) + + if cs.PRDName() != "auth" { + t.Errorf("expected prdName 'auth', got '%s'", cs.PRDName()) + } + if cs.Branch() != "chief/auth" { + t.Errorf("expected branch 'chief/auth', got '%s'", cs.Branch()) + } + if !cs.HasBranch() { + t.Error("expected HasBranch() to be true") + } +} + +func TestCompletionScreen_NoBranch(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "", 0, false) + + if cs.HasBranch() { + t.Error("expected HasBranch() to be false when branch is empty") + } +} + +func TestCompletionScreen_RenderHeader(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 10, "chief/auth", 5, true) + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "PRD Complete!") { + t.Error("expected 'PRD Complete!' in render output") + } + if !strings.Contains(rendered, "auth") { + t.Error("expected PRD name 'auth' in render output") + } + if !strings.Contains(rendered, "8/10") { + t.Error("expected '8/10' stories count in render output") + } +} + +func TestCompletionScreen_RenderBranchInfo(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "chief/auth") { + t.Error("expected branch 'chief/auth' in render output") + } + if !strings.Contains(rendered, "5 commits") { + t.Error("expected '5 commits' in render output") + } +} + +func TestCompletionScreen_RenderSingleCommit(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 1, 1, "chief/auth", 1, false) + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "1 commit on branch") { + t.Error("expected '1 commit on branch' (singular) in render output") + } +} + +func TestCompletionScreen_RenderNoBranch(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "", 0, false) + cs.SetSize(80, 40) + + rendered := cs.Render() + if strings.Contains(rendered, "Branch:") { + t.Error("expected no 'Branch:' when no branch is set") + } + if strings.Contains(rendered, "Commits:") { + t.Error("expected no 'Commits:' when no branch is set") + } +} + +func TestCompletionScreen_RenderNoAutoActions(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, false) + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "Configure auto-push and PR in settings") { + t.Error("expected auto-actions hint when hasAutoActions is false") + } +} + +func TestCompletionScreen_RenderWithAutoActions(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetSize(80, 40) + + rendered := cs.Render() + if strings.Contains(rendered, "Configure auto-push and PR in settings") { + t.Error("expected no auto-actions hint when hasAutoActions is true") + } +} + +func TestCompletionScreen_RenderFooterWithBranch(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "m: merge") { + t.Error("expected 'm: merge' in footer when branch is set") + } + if !strings.Contains(rendered, "c: clean") { + t.Error("expected 'c: clean' in footer when branch is set") + } + if !strings.Contains(rendered, "l: switch PRD") { + t.Error("expected 'l: switch PRD' in footer") + } + if !strings.Contains(rendered, "q: quit") { + t.Error("expected 'q: quit' in footer") + } +} + +func TestCompletionScreen_RenderFooterNoBranch(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "", 0, false) + cs.SetSize(80, 40) + + rendered := cs.Render() + if strings.Contains(rendered, "m: merge") { + t.Error("expected no 'm: merge' in footer when no branch is set") + } + if strings.Contains(rendered, "c: clean") { + t.Error("expected no 'c: clean' in footer when no branch is set") + } + if !strings.Contains(rendered, "l: switch PRD") { + t.Error("expected 'l: switch PRD' in footer") + } + if !strings.Contains(rendered, "q: quit") { + t.Error("expected 'q: quit' in footer") + } +} + +func TestCompletionScreen_PushInProgress(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushInProgress() + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "Pushing branch to remote") { + t.Error("expected 'Pushing branch to remote' when push is in progress") + } + if cs.pushState != AutoActionInProgress { + t.Errorf("expected push state to be AutoActionInProgress, got %d", cs.pushState) + } + if !cs.IsAutoActionRunning() { + t.Error("expected IsAutoActionRunning() to be true when push is in progress") + } +} + +func TestCompletionScreen_PushSuccess(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushSuccess() + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "Pushed branch to remote") { + t.Error("expected 'Pushed branch to remote' when push succeeded") + } + // Should not show the "configure" hint when auto-actions are active + if strings.Contains(rendered, "Configure auto-push") { + t.Error("expected no auto-push hint when push is active") + } +} + +func TestCompletionScreen_PushError(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushError("authentication failed") + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "Push failed") { + t.Error("expected 'Push failed' when push errored") + } + if !strings.Contains(rendered, "authentication failed") { + t.Error("expected error message in render output") + } +} + +func TestCompletionScreen_PRInProgress(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushSuccess() + cs.SetPRInProgress() + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "Creating pull request") { + t.Error("expected 'Creating pull request' when PR is in progress") + } + if !cs.IsAutoActionRunning() { + t.Error("expected IsAutoActionRunning() to be true when PR is in progress") + } +} + +func TestCompletionScreen_PRSuccess(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushSuccess() + cs.SetPRSuccess("https://github.com/org/repo/pull/42", "feat(auth): Authentication") + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "Created PR") { + t.Error("expected 'Created PR' when PR succeeded") + } + if !strings.Contains(rendered, "feat(auth): Authentication") { + t.Error("expected PR title in render output") + } + if !strings.Contains(rendered, "https://github.com/org/repo/pull/42") { + t.Error("expected PR URL in render output") + } + if cs.IsAutoActionRunning() { + t.Error("expected IsAutoActionRunning() to be false when all actions complete") + } +} + +func TestCompletionScreen_PRError(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushSuccess() + cs.SetPRError("gh not found, Install: https://cli.github.com") + cs.SetSize(80, 40) + + rendered := cs.Render() + if !strings.Contains(rendered, "PR creation failed") { + t.Error("expected 'PR creation failed' when PR errored") + } + if !strings.Contains(rendered, "gh not found") { + t.Error("expected error message in render output") + } +} + +func TestCompletionScreen_ConfigureResetsAutoActions(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushSuccess() + cs.SetPRSuccess("https://example.com", "title") + + // Reconfigure should reset + cs.Configure("payments", 3, 5, "chief/payments", 2, false) + + if cs.pushState != AutoActionIdle { + t.Error("expected push state to be reset after Configure") + } + if cs.prState != AutoActionIdle { + t.Error("expected PR state to be reset after Configure") + } + if cs.prURL != "" { + t.Error("expected prURL to be empty after Configure") + } +} + +func TestCompletionScreen_Tick(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushInProgress() + + initial := cs.spinnerFrame + cs.Tick() + if cs.spinnerFrame != initial+1 { + t.Error("expected spinner frame to advance on Tick()") + } +} + +func TestCompletionScreen_PushErrorNonBlocking(t *testing.T) { + cs := NewCompletionScreen() + cs.Configure("auth", 8, 8, "chief/auth", 5, true) + cs.SetPushError("network error") + cs.SetSize(80, 40) + + rendered := cs.Render() + // Footer should still be present (keybindings remain usable) + if !strings.Contains(rendered, "m: merge") { + t.Error("expected footer keybindings to remain usable after push error") + } + if !strings.Contains(rendered, "q: quit") { + t.Error("expected 'q: quit' in footer after error") + } +} + +func TestCenterModal(t *testing.T) { + modal := "test modal content" + result := centerModal(modal, 80, 40) + + // Should have top padding and left padding + lines := strings.Split(result, "\n") + if len(lines) < 2 { + t.Fatal("expected centered modal to have multiple lines") + } + + // First lines should be empty (top padding) + hasTopPadding := false + for _, line := range lines { + if line == "" { + hasTopPadding = true + break + } + } + if !hasTopPadding { + t.Error("expected top padding in centered modal") + } +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index ac6aaa0..5730b2f 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -41,7 +41,7 @@ func (a *App) renderDashboard() string { footer := a.renderFooter() // Calculate content area height - contentHeight := a.height - headerHeight - footerHeight - 2 // -2 for panel borders + contentHeight := a.height - a.effectiveHeaderHeight() - footerHeight - 2 // -2 for panel borders // Render panels storiesWidth := (a.width * storiesPanelPct / 100) - 2 @@ -63,7 +63,7 @@ func (a *App) renderStackedDashboard() string { footer := a.renderNarrowFooter() // Calculate content area height - contentHeight := a.height - headerHeight - footerHeight - 2 // -2 for panel borders + contentHeight := a.height - a.effectiveHeaderHeight() - footerHeight - 2 // -2 for panel borders // Split height between stories (40%) and details (60%) storiesHeight := max((contentHeight*40)/100, 5) @@ -81,6 +81,55 @@ func (a *App) renderStackedDashboard() string { return lipgloss.JoinVertical(lipgloss.Left, header, content, footer) } +// getWorktreeInfo returns the branch and directory info for the current PRD. +// Returns empty strings if no branch is set (backward compatible). +func (a *App) getWorktreeInfo() (branch, dir string) { + if a.manager == nil { + return "", "" + } + instance := a.manager.GetInstance(a.prdName) + if instance == nil || instance.Branch == "" { + return "", "" + } + branch = instance.Branch + if instance.WorktreeDir != "" { + // Convert absolute worktree path to relative for display + dir = fmt.Sprintf(".chief/worktrees/%s/", a.prdName) + } else { + dir = "./ (current directory)" + } + return branch, dir +} + +// hasWorktreeInfo returns true if the current PRD has branch info to display. +func (a *App) hasWorktreeInfo() bool { + branch, _ := a.getWorktreeInfo() + return branch != "" +} + +// effectiveHeaderHeight returns the header height accounting for worktree info line. +func (a *App) effectiveHeaderHeight() int { + if a.hasWorktreeInfo() { + return headerHeight + 1 + } + return headerHeight +} + +// renderWorktreeInfoLine renders the branch and directory info line for the header. +func (a *App) renderWorktreeInfoLine() string { + branch, dir := a.getWorktreeInfo() + if branch == "" { + return "" + } + + branchLabel := SubtitleStyle.Render("branch:") + branchValue := lipgloss.NewStyle().Foreground(PrimaryColor).Render(" " + branch) + dirLabel := SubtitleStyle.Render(" dir:") + dirValue := lipgloss.NewStyle().Foreground(TextColor).Render(" " + dir) + + return lipgloss.JoinHorizontal(lipgloss.Center, " ", branchLabel, branchValue, dirLabel, dirValue) +} + // renderHeader renders the header with branding, state, iteration, and elapsed time. func (a *App) renderHeader() string { // Branding @@ -108,9 +157,15 @@ func (a *App) renderHeader() string { // Tab bar tabBarLine := a.renderTabBar() + // Worktree info line (only shown when branch is set) + worktreeInfoLine := a.renderWorktreeInfoLine() + // Add a border below border := DividerStyle.Render(strings.Repeat("─", a.width)) + if worktreeInfoLine != "" { + return lipgloss.JoinVertical(lipgloss.Left, headerLine, tabBarLine, worktreeInfoLine, border) + } return lipgloss.JoinVertical(lipgloss.Left, headerLine, tabBarLine, border) } @@ -150,9 +205,15 @@ func (a *App) renderNarrowHeader() string { // Tab bar (compact) tabBarLine := a.renderTabBar() + // Worktree info line (only shown when branch is set) + worktreeInfoLine := a.renderWorktreeInfoLine() + // Add a border below border := DividerStyle.Render(strings.Repeat("─", a.width)) + if worktreeInfoLine != "" { + return lipgloss.JoinVertical(lipgloss.Left, headerLine, tabBarLine, worktreeInfoLine, border) + } return lipgloss.JoinVertical(lipgloss.Left, headerLine, tabBarLine, border) } diff --git a/internal/tui/first_time_setup.go b/internal/tui/first_time_setup.go index 37f4fb3..061cad7 100644 --- a/internal/tui/first_time_setup.go +++ b/internal/tui/first_time_setup.go @@ -1,20 +1,40 @@ package tui import ( + "bytes" "fmt" + "os/exec" "regexp" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/minicodemonkey/chief/embed" "github.com/minicodemonkey/chief/internal/git" ) +// ghCheckResultMsg is sent when the gh CLI check completes. +type ghCheckResultMsg struct { + installed bool + authenticated bool + err error +} + +// detectSetupResultMsg is sent when Claude finishes detecting setup commands. +type detectSetupResultMsg struct { + command string + err error +} + // FirstTimeSetupResult contains the result of the first-time setup flow. type FirstTimeSetupResult struct { - PRDName string - AddedGitignore bool - Cancelled bool + PRDName string + AddedGitignore bool + Cancelled bool + PushOnComplete bool + CreatePROnComplete bool + WorktreeSetup string } // FirstTimeSetupStep represents the current step in the setup flow. @@ -23,6 +43,11 @@ type FirstTimeSetupStep int const ( StepGitignore FirstTimeSetupStep = iota StepPRDName + StepPostCompletion + StepGHError + StepWorktreeSetup + StepDetecting + StepDetectResult ) // FirstTimeSetup is a TUI for first-time project setup. @@ -40,6 +65,25 @@ type FirstTimeSetup struct { prdName string prdNameError string + // Post-completion config step + pushSelected int // 0 = Yes, 1 = No + createPRSelected int // 0 = Yes, 1 = No + postCompField int // 0 = push toggle, 1 = PR toggle + + // GH CLI error step + ghErrorMsg string + ghErrorSelected int // 0 = Continue without PR, 1 = Try again + + // Worktree setup step + worktreeSetupSelected int // 0 = Let Claude figure it out, 1 = Enter manually, 2 = Skip + worktreeSetupInput string + worktreeSetupEditing bool // true when editing the manual input or detected result + + // Detect result step + detectedCommand string + detectResultSelected int // 0 = Use this command, 1 = Edit, 2 = Skip + detectSpinnerFrame int + // Result result FirstTimeSetupResult @@ -58,6 +102,8 @@ func NewFirstTimeSetup(baseDir string, showGitignore bool) *FirstTimeSetup { step: step, gitignoreSelected: 0, // Default to "Yes" prdName: "main", + pushSelected: 0, // Default to "Yes" + createPRSelected: 0, // Default to "Yes" } } @@ -74,17 +120,47 @@ func (f FirstTimeSetup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.height = msg.Height return f, nil + case ghCheckResultMsg: + return f.handleGHCheckResult(msg) + + case detectSetupResultMsg: + return f.handleDetectSetupResult(msg) + + case spinnerTickMsg: + if f.step == StepDetecting { + f.detectSpinnerFrame++ + return f, tickSpinner() + } + return f, nil + case tea.KeyMsg: switch f.step { case StepGitignore: return f.handleGitignoreKeys(msg) case StepPRDName: return f.handlePRDNameKeys(msg) + case StepPostCompletion: + return f.handlePostCompletionKeys(msg) + case StepGHError: + return f.handleGHErrorKeys(msg) + case StepWorktreeSetup: + return f.handleWorktreeSetupKeys(msg) + case StepDetectResult: + return f.handleDetectResultKeys(msg) } } return f, nil } +// spinnerTickMsg is sent to animate the spinner. +type spinnerTickMsg struct{} + +func tickSpinner() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { + return spinnerTickMsg{} + }) +} + func (f FirstTimeSetup) handleGitignoreKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c", "esc": @@ -159,7 +235,8 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return f, nil } f.result.PRDName = name - return f, tea.Quit + f.step = StepPostCompletion + return f, nil case "backspace": if len(f.prdName) > 0 { @@ -189,6 +266,360 @@ func isValidPRDName(name string) bool { return validName.MatchString(name) } +func (f FirstTimeSetup) handlePostCompletionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + f.result.Cancelled = true + return f, tea.Quit + + case "esc": + // Go back to PRD name step + f.step = StepPRDName + return f, nil + + case "up", "k": + if f.postCompField > 0 { + f.postCompField-- + } + return f, nil + + case "down", "j": + if f.postCompField < 1 { + f.postCompField++ + } + return f, nil + + case "left", "h": + // Toggle to Yes (0) + if f.postCompField == 0 { + f.pushSelected = 0 + } else { + f.createPRSelected = 0 + } + return f, nil + + case "right", "l": + // Toggle to No (1) + if f.postCompField == 0 { + f.pushSelected = 1 + } else { + f.createPRSelected = 1 + } + return f, nil + + case " ", "tab": + // Toggle the current field + if f.postCompField == 0 { + f.pushSelected = 1 - f.pushSelected + } else { + f.createPRSelected = 1 - f.createPRSelected + } + return f, nil + + case "y", "Y": + if f.postCompField == 0 { + f.pushSelected = 0 + } else { + f.createPRSelected = 0 + } + return f, nil + + case "n", "N": + if f.postCompField == 0 { + f.pushSelected = 1 + } else { + f.createPRSelected = 1 + } + return f, nil + + case "enter": + return f.confirmPostCompletion() + } + return f, nil +} + +func (f FirstTimeSetup) confirmPostCompletion() (tea.Model, tea.Cmd) { + f.result.PushOnComplete = f.pushSelected == 0 + f.result.CreatePROnComplete = f.createPRSelected == 0 + + // If PR creation is enabled, validate gh CLI + if f.result.CreatePROnComplete { + return f, func() tea.Msg { + installed, authenticated, err := git.CheckGHCLI() + return ghCheckResultMsg{installed: installed, authenticated: authenticated, err: err} + } + } + + f.step = StepWorktreeSetup + return f, nil +} + +func (f FirstTimeSetup) handleGHCheckResult(msg ghCheckResultMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + f.ghErrorMsg = fmt.Sprintf("Error checking gh CLI: %s", msg.err.Error()) + f.ghErrorSelected = 0 + f.step = StepGHError + return f, nil + } + + if !msg.installed { + f.ghErrorMsg = "GitHub CLI (gh) is not installed.\nInstall it from: https://cli.github.com" + f.ghErrorSelected = 0 + f.step = StepGHError + return f, nil + } + + if !msg.authenticated { + f.ghErrorMsg = "GitHub CLI (gh) is not authenticated.\nRun: gh auth login" + f.ghErrorSelected = 0 + f.step = StepGHError + return f, nil + } + + // gh is installed and authenticated - proceed to worktree setup + f.step = StepWorktreeSetup + return f, nil +} + +func (f FirstTimeSetup) handleGHErrorKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + f.result.Cancelled = true + return f, tea.Quit + + case "esc": + // Go back to post-completion step + f.step = StepPostCompletion + return f, nil + + case "up", "k": + if f.ghErrorSelected > 0 { + f.ghErrorSelected-- + } + return f, nil + + case "down", "j": + if f.ghErrorSelected < 1 { + f.ghErrorSelected++ + } + return f, nil + + case "enter": + if f.ghErrorSelected == 0 { + // Continue without PR creation + f.result.CreatePROnComplete = false + f.step = StepWorktreeSetup + return f, nil + } + // Try again + return f, func() tea.Msg { + installed, authenticated, err := git.CheckGHCLI() + return ghCheckResultMsg{installed: installed, authenticated: authenticated, err: err} + } + } + return f, nil +} + +func (f FirstTimeSetup) handleWorktreeSetupKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if f.worktreeSetupEditing { + return f.handleWorktreeSetupInputKeys(msg) + } + + switch msg.String() { + case "ctrl+c": + f.result.Cancelled = true + return f, tea.Quit + + case "esc": + // Go back to post-completion step + f.step = StepPostCompletion + return f, nil + + case "up", "k": + if f.worktreeSetupSelected > 0 { + f.worktreeSetupSelected-- + } + return f, nil + + case "down", "j": + if f.worktreeSetupSelected < 2 { + f.worktreeSetupSelected++ + } + return f, nil + + case "enter": + return f.confirmWorktreeSetup() + } + return f, nil +} + +func (f FirstTimeSetup) handleWorktreeSetupInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + f.result.Cancelled = true + return f, tea.Quit + + case "esc": + // Cancel editing, go back to options + f.worktreeSetupEditing = false + f.worktreeSetupInput = "" + return f, nil + + case "enter": + cmd := strings.TrimSpace(f.worktreeSetupInput) + if cmd != "" { + f.result.WorktreeSetup = cmd + } + return f, tea.Quit + + case "backspace": + if len(f.worktreeSetupInput) > 0 { + f.worktreeSetupInput = f.worktreeSetupInput[:len(f.worktreeSetupInput)-1] + } + return f, nil + + default: + if len(msg.String()) == 1 { + f.worktreeSetupInput += msg.String() + } + return f, nil + } +} + +func (f FirstTimeSetup) confirmWorktreeSetup() (tea.Model, tea.Cmd) { + switch f.worktreeSetupSelected { + case 0: + // Let Claude figure it out + f.step = StepDetecting + f.detectSpinnerFrame = 0 + return f, tea.Batch(f.runDetectSetup(), tickSpinner()) + case 1: + // Enter manually + f.worktreeSetupEditing = true + f.worktreeSetupInput = "" + return f, nil + case 2: + // Skip + return f, tea.Quit + } + return f, nil +} + +func (f FirstTimeSetup) runDetectSetup() tea.Cmd { + return func() tea.Msg { + prompt := embed.GetDetectSetupPrompt() + cmd := exec.Command("claude", "-p", prompt, "--output-format", "text") + cmd.Dir = f.baseDir + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + err := cmd.Run() + if err != nil { + return detectSetupResultMsg{err: fmt.Errorf("Claude detection failed: %w", err)} + } + + result := strings.TrimSpace(stdout.String()) + return detectSetupResultMsg{command: result} + } +} + +func (f FirstTimeSetup) handleDetectSetupResult(msg detectSetupResultMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + // Detection failed, go to worktree setup step so user can enter manually or skip + f.step = StepWorktreeSetup + return f, nil + } + + f.detectedCommand = msg.command + f.detectResultSelected = 0 + f.step = StepDetectResult + return f, nil +} + +func (f FirstTimeSetup) handleDetectResultKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if f.worktreeSetupEditing { + return f.handleDetectResultEditKeys(msg) + } + + switch msg.String() { + case "ctrl+c": + f.result.Cancelled = true + return f, tea.Quit + + case "esc": + // Go back to worktree setup options + f.step = StepWorktreeSetup + return f, nil + + case "up", "k": + if f.detectResultSelected > 0 { + f.detectResultSelected-- + } + return f, nil + + case "down", "j": + if f.detectResultSelected < 2 { + f.detectResultSelected++ + } + return f, nil + + case "enter": + return f.confirmDetectResult() + } + return f, nil +} + +func (f FirstTimeSetup) handleDetectResultEditKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + f.result.Cancelled = true + return f, tea.Quit + + case "esc": + // Cancel editing, go back to options + f.worktreeSetupEditing = false + return f, nil + + case "enter": + cmd := strings.TrimSpace(f.worktreeSetupInput) + if cmd != "" { + f.result.WorktreeSetup = cmd + } + return f, tea.Quit + + case "backspace": + if len(f.worktreeSetupInput) > 0 { + f.worktreeSetupInput = f.worktreeSetupInput[:len(f.worktreeSetupInput)-1] + } + return f, nil + + default: + if len(msg.String()) == 1 { + f.worktreeSetupInput += msg.String() + } + return f, nil + } +} + +func (f FirstTimeSetup) confirmDetectResult() (tea.Model, tea.Cmd) { + switch f.detectResultSelected { + case 0: + // Use this command + f.result.WorktreeSetup = f.detectedCommand + return f, tea.Quit + case 1: + // Edit + f.worktreeSetupEditing = true + f.worktreeSetupInput = f.detectedCommand + return f, nil + case 2: + // Skip + return f, tea.Quit + } + return f, nil +} + // View renders the TUI. func (f FirstTimeSetup) View() string { switch f.step { @@ -196,6 +627,16 @@ func (f FirstTimeSetup) View() string { return f.renderGitignoreStep() case StepPRDName: return f.renderPRDNameStep() + case StepPostCompletion: + return f.renderPostCompletionStep() + case StepGHError: + return f.renderGHErrorStep() + case StepWorktreeSetup: + return f.renderWorktreeSetupStep() + case StepDetecting: + return f.renderDetectingStep() + case StepDetectResult: + return f.renderDetectResultStep() default: return "" } @@ -358,6 +799,438 @@ func (f FirstTimeSetup) renderPRDNameStep() string { return f.centerModal(modal) } +func (f FirstTimeSetup) renderPostCompletionStep() string { + modalWidth := min(65, f.width-10) + if modalWidth < 45 { + modalWidth = 45 + } + + var content strings.Builder + + // Success indicators for previous steps + successStyle := lipgloss.NewStyle().Foreground(SuccessColor) + if f.result.AddedGitignore { + content.WriteString(successStyle.Render("✓ Added .chief to .gitignore")) + content.WriteString("\n") + } + content.WriteString(successStyle.Render(fmt.Sprintf("✓ PRD: %s", f.result.PRDName))) + content.WriteString("\n\n") + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(PrimaryColor) + content.WriteString(titleStyle.Render("Post-Completion Settings")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + // Description + descStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(descStyle.Render("When a PRD completes, Chief can automatically push")) + content.WriteString("\n") + content.WriteString(descStyle.Render("the branch and create a pull request for you.")) + content.WriteString("\n\n") + + // Toggle styles + activeFieldStyle := lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true) + inactiveFieldStyle := lipgloss.NewStyle().Foreground(TextColor) + yesStyle := lipgloss.NewStyle().Foreground(SuccessColor).Bold(true) + noStyle := lipgloss.NewStyle().Foreground(MutedColor) + recommendedStyle := lipgloss.NewStyle().Foreground(SuccessColor) + + // Push toggle + pushLabel := "Push branch to remote?" + if f.postCompField == 0 { + content.WriteString(activeFieldStyle.Render("▶ " + pushLabel)) + } else { + content.WriteString(inactiveFieldStyle.Render(" " + pushLabel)) + } + content.WriteString(" ") + if f.pushSelected == 0 { + content.WriteString(yesStyle.Render("[Yes]")) + content.WriteString(" ") + content.WriteString(noStyle.Render(" No ")) + content.WriteString(" ") + content.WriteString(recommendedStyle.Render("(Recommended)")) + } else { + content.WriteString(noStyle.Render(" Yes ")) + content.WriteString(" ") + content.WriteString(yesStyle.Render("[No]")) + } + content.WriteString("\n\n") + + // PR toggle + prLabel := "Automatically create a pull request?" + if f.postCompField == 1 { + content.WriteString(activeFieldStyle.Render("▶ " + prLabel)) + } else { + content.WriteString(inactiveFieldStyle.Render(" " + prLabel)) + } + content.WriteString(" ") + if f.createPRSelected == 0 { + content.WriteString(yesStyle.Render("[Yes]")) + content.WriteString(" ") + content.WriteString(noStyle.Render(" No ")) + content.WriteString(" ") + content.WriteString(recommendedStyle.Render("(Recommended)")) + } else { + content.WriteString(noStyle.Render(" Yes ")) + content.WriteString(" ") + content.WriteString(yesStyle.Render("[No]")) + } + content.WriteString("\n\n") + + // Hint + hintStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(hintStyle.Render("You can change these later with ,")) + + // Footer + content.WriteString("\n\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + + footerStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(footerStyle.Render("↑/↓: Navigate ←/→/Space: Toggle y/n: Quick set Enter: Continue Esc: Back")) + + // Modal box + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(1, 2). + Width(modalWidth) + + modal := modalStyle.Render(content.String()) + + return f.centerModal(modal) +} + +func (f FirstTimeSetup) renderGHErrorStep() string { + modalWidth := min(60, f.width-10) + if modalWidth < 45 { + modalWidth = 45 + } + + var content strings.Builder + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(ErrorColor) + content.WriteString(titleStyle.Render("GitHub CLI Issue")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + // Error message + errorStyle := lipgloss.NewStyle().Foreground(ErrorColor) + for _, line := range strings.Split(f.ghErrorMsg, "\n") { + content.WriteString(errorStyle.Render(line)) + content.WriteString("\n") + } + content.WriteString("\n") + + // Options + optionStyle := lipgloss.NewStyle().Foreground(TextColor) + selectedOptionStyle := lipgloss.NewStyle(). + Foreground(PrimaryColor). + Bold(true) + + options := []string{ + "Continue without PR creation", + "Try again", + } + + for i, opt := range options { + if i == f.ghErrorSelected { + content.WriteString(selectedOptionStyle.Render(fmt.Sprintf("▶ %s", opt))) + } else { + content.WriteString(optionStyle.Render(fmt.Sprintf(" %s", opt))) + } + content.WriteString("\n") + } + + // Footer + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + + footerStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(footerStyle.Render("↑/↓: Navigate Enter: Select Esc: Back")) + + // Modal box + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ErrorColor). + Padding(1, 2). + Width(modalWidth) + + modal := modalStyle.Render(content.String()) + + return f.centerModal(modal) +} + +func (f FirstTimeSetup) renderWorktreeSetupStep() string { + modalWidth := min(65, f.width-10) + if modalWidth < 45 { + modalWidth = 45 + } + + var content strings.Builder + + // Success indicators for previous steps + successStyle := lipgloss.NewStyle().Foreground(SuccessColor) + if f.result.AddedGitignore { + content.WriteString(successStyle.Render("✓ Added .chief to .gitignore")) + content.WriteString("\n") + } + content.WriteString(successStyle.Render(fmt.Sprintf("✓ PRD: %s", f.result.PRDName))) + content.WriteString("\n") + content.WriteString(successStyle.Render("✓ Post-completion configured")) + content.WriteString("\n\n") + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(PrimaryColor) + content.WriteString(titleStyle.Render("Worktree Setup Command")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + // Description + descStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(descStyle.Render("When creating a worktree, Chief can run a setup command")) + content.WriteString("\n") + content.WriteString(descStyle.Render("to install dependencies (e.g., npm install, go mod download).")) + content.WriteString("\n\n") + + if f.worktreeSetupEditing { + // Show inline text input + messageStyle := lipgloss.NewStyle().Foreground(TextColor) + content.WriteString(messageStyle.Render("Enter setup command:")) + content.WriteString("\n\n") + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(0, 1). + Width(modalWidth - 8) + + displayInput := f.worktreeSetupInput + if displayInput == "" { + displayInput = " " + } + content.WriteString(inputStyle.Render(displayInput + "█")) + content.WriteString("\n") + + // Footer + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + footerStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(footerStyle.Render("Enter: Confirm Esc: Back")) + } else { + // Show options + optionStyle := lipgloss.NewStyle().Foreground(TextColor) + selectedOptionStyle := lipgloss.NewStyle(). + Foreground(PrimaryColor). + Bold(true) + recommendedStyle := lipgloss.NewStyle().Foreground(SuccessColor) + + options := []struct { + label string + desc string + }{ + {"Let Claude figure it out", "(Recommended)"}, + {"Enter manually", ""}, + {"Skip", ""}, + } + + for i, opt := range options { + if i == f.worktreeSetupSelected { + content.WriteString(selectedOptionStyle.Render(fmt.Sprintf("▶ %s", opt.label))) + if opt.desc != "" { + content.WriteString(" " + recommendedStyle.Render(opt.desc)) + } + } else { + content.WriteString(optionStyle.Render(fmt.Sprintf(" %s", opt.label))) + if opt.desc != "" { + content.WriteString(" " + lipgloss.NewStyle().Foreground(MutedColor).Render(opt.desc)) + } + } + content.WriteString("\n") + } + + // Hint + content.WriteString("\n") + hintStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(hintStyle.Render("You can change these later with ,")) + + // Footer + content.WriteString("\n\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + footerStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(footerStyle.Render("↑/↓: Navigate Enter: Select Esc: Back")) + } + + // Modal box + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(1, 2). + Width(modalWidth) + + modal := modalStyle.Render(content.String()) + + return f.centerModal(modal) +} + +func (f FirstTimeSetup) renderDetectingStep() string { + modalWidth := min(65, f.width-10) + if modalWidth < 45 { + modalWidth = 45 + } + + var content strings.Builder + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(PrimaryColor) + content.WriteString(titleStyle.Render("Worktree Setup Command")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + // Spinner + spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + frame := spinnerFrames[f.detectSpinnerFrame%len(spinnerFrames)] + spinnerStyle := lipgloss.NewStyle().Foreground(PrimaryColor) + messageStyle := lipgloss.NewStyle().Foreground(TextColor) + + content.WriteString(spinnerStyle.Render(frame)) + content.WriteString(" ") + content.WriteString(messageStyle.Render("Analyzing project for setup commands...")) + content.WriteString("\n") + + // Modal box + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(1, 2). + Width(modalWidth) + + modal := modalStyle.Render(content.String()) + + return f.centerModal(modal) +} + +func (f FirstTimeSetup) renderDetectResultStep() string { + modalWidth := min(65, f.width-10) + if modalWidth < 45 { + modalWidth = 45 + } + + var content strings.Builder + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(PrimaryColor) + content.WriteString(titleStyle.Render("Detected Setup Command")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + if f.worktreeSetupEditing { + // Show inline text input for editing + messageStyle := lipgloss.NewStyle().Foreground(TextColor) + content.WriteString(messageStyle.Render("Edit setup command:")) + content.WriteString("\n\n") + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(0, 1). + Width(modalWidth - 8) + + displayInput := f.worktreeSetupInput + if displayInput == "" { + displayInput = " " + } + content.WriteString(inputStyle.Render(displayInput + "█")) + content.WriteString("\n") + + // Footer + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + footerStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(footerStyle.Render("Enter: Confirm Esc: Back")) + } else { + // Show detected command + commandStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(SuccessColor). + Padding(0, 1). + Width(modalWidth - 8) + + content.WriteString(commandStyle.Render(f.detectedCommand)) + content.WriteString("\n\n") + + // Options + optionStyle := lipgloss.NewStyle().Foreground(TextColor) + selectedOptionStyle := lipgloss.NewStyle(). + Foreground(PrimaryColor). + Bold(true) + recommendedStyle := lipgloss.NewStyle().Foreground(SuccessColor) + + options := []struct { + label string + desc string + }{ + {"Use this command", "(Recommended)"}, + {"Edit", ""}, + {"Skip", ""}, + } + + for i, opt := range options { + if i == f.detectResultSelected { + content.WriteString(selectedOptionStyle.Render(fmt.Sprintf("▶ %s", opt.label))) + if opt.desc != "" { + content.WriteString(" " + recommendedStyle.Render(opt.desc)) + } + } else { + content.WriteString(optionStyle.Render(fmt.Sprintf(" %s", opt.label))) + if opt.desc != "" { + content.WriteString(" " + lipgloss.NewStyle().Foreground(MutedColor).Render(opt.desc)) + } + } + content.WriteString("\n") + } + + // Footer + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + footerStyle := lipgloss.NewStyle().Foreground(MutedColor) + content.WriteString(footerStyle.Render("↑/↓: Navigate Enter: Select Esc: Back")) + } + + // Modal box + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(1, 2). + Width(modalWidth) + + modal := modalStyle.Render(content.String()) + + return f.centerModal(modal) +} + func (f FirstTimeSetup) centerModal(modal string) string { lines := strings.Split(modal, "\n") modalHeight := len(lines) diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index 66b607b..b82f610 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -1,7 +1,10 @@ package tui import ( + "strings" "testing" + + "github.com/minicodemonkey/chief/internal/loop" ) func TestIsNarrowMode(t *testing.T) { @@ -203,3 +206,127 @@ func TestMinMaxHelpers(t *testing.T) { t.Error("max(5, 5) should return 5") } } + +func TestGetWorktreeInfo_NoBranch(t *testing.T) { + // No manager - should return empty + app := &App{prdName: "auth"} + branch, dir := app.getWorktreeInfo() + if branch != "" || dir != "" { + t.Errorf("expected empty worktree info without manager, got branch=%q dir=%q", branch, dir) + } +} + +func TestGetWorktreeInfo_WithBranch(t *testing.T) { + mgr := loop.NewManager(10) + mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "/tmp/.chief/worktrees/auth", "chief/auth") + + app := &App{prdName: "auth", manager: mgr} + branch, dir := app.getWorktreeInfo() + if branch != "chief/auth" { + t.Errorf("branch = %q, want %q", branch, "chief/auth") + } + if dir != ".chief/worktrees/auth/" { + t.Errorf("dir = %q, want %q", dir, ".chief/worktrees/auth/") + } +} + +func TestGetWorktreeInfo_WithBranchNoWorktree(t *testing.T) { + // Branch set but no worktree dir (branch-only mode) + mgr := loop.NewManager(10) + mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "", "chief/auth") + + app := &App{prdName: "auth", manager: mgr} + branch, dir := app.getWorktreeInfo() + if branch != "chief/auth" { + t.Errorf("branch = %q, want %q", branch, "chief/auth") + } + if dir != "./ (current directory)" { + t.Errorf("dir = %q, want %q", dir, "./ (current directory)") + } +} + +func TestGetWorktreeInfo_RegisteredNoBranch(t *testing.T) { + // Registered without worktree - should return empty (backward compatible) + mgr := loop.NewManager(10) + mgr.Register("auth", "/tmp/prd.json") + + app := &App{prdName: "auth", manager: mgr} + branch, dir := app.getWorktreeInfo() + if branch != "" || dir != "" { + t.Errorf("expected empty worktree info for no-branch PRD, got branch=%q dir=%q", branch, dir) + } +} + +func TestHasWorktreeInfo(t *testing.T) { + // No manager + app := &App{prdName: "auth"} + if app.hasWorktreeInfo() { + t.Error("expected hasWorktreeInfo=false without manager") + } + + // With branch + mgr := loop.NewManager(10) + mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "/tmp/.chief/worktrees/auth", "chief/auth") + app.manager = mgr + if !app.hasWorktreeInfo() { + t.Error("expected hasWorktreeInfo=true with branch set") + } +} + +func TestEffectiveHeaderHeight_NoBranch(t *testing.T) { + app := &App{prdName: "auth"} + if got := app.effectiveHeaderHeight(); got != headerHeight { + t.Errorf("effectiveHeaderHeight() = %d, want %d (no branch)", got, headerHeight) + } +} + +func TestEffectiveHeaderHeight_WithBranch(t *testing.T) { + mgr := loop.NewManager(10) + mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "/tmp/.chief/worktrees/auth", "chief/auth") + + app := &App{prdName: "auth", manager: mgr} + if got := app.effectiveHeaderHeight(); got != headerHeight+1 { + t.Errorf("effectiveHeaderHeight() = %d, want %d (with branch)", got, headerHeight+1) + } +} + +func TestRenderWorktreeInfoLine_NoBranch(t *testing.T) { + app := &App{prdName: "auth"} + if got := app.renderWorktreeInfoLine(); got != "" { + t.Errorf("renderWorktreeInfoLine() should be empty for no-branch, got %q", got) + } +} + +func TestRenderWorktreeInfoLine_WithBranch(t *testing.T) { + mgr := loop.NewManager(10) + mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "/tmp/.chief/worktrees/auth", "chief/auth") + + app := &App{prdName: "auth", manager: mgr} + got := app.renderWorktreeInfoLine() + if got == "" { + t.Error("renderWorktreeInfoLine() should not be empty with branch set") + } + if !strings.Contains(got, "branch:") { + t.Errorf("renderWorktreeInfoLine() should contain 'branch:', got %q", got) + } + if !strings.Contains(got, "chief/auth") { + t.Errorf("renderWorktreeInfoLine() should contain branch name 'chief/auth', got %q", got) + } + if !strings.Contains(got, "dir:") { + t.Errorf("renderWorktreeInfoLine() should contain 'dir:', got %q", got) + } + if !strings.Contains(got, ".chief/worktrees/auth/") { + t.Errorf("renderWorktreeInfoLine() should contain worktree path, got %q", got) + } +} + +func TestRenderWorktreeInfoLine_BranchNoWorktree(t *testing.T) { + mgr := loop.NewManager(10) + mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "", "chief/auth") + + app := &App{prdName: "auth", manager: mgr} + got := app.renderWorktreeInfoLine() + if !strings.Contains(got, "current directory") { + t.Errorf("renderWorktreeInfoLine() should contain 'current directory' for branch-only mode, got %q", got) + } +} diff --git a/internal/tui/picker.go b/internal/tui/picker.go index db276f9..3652584 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -7,21 +7,56 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/minicodemonkey/chief/internal/git" "github.com/minicodemonkey/chief/internal/loop" "github.com/minicodemonkey/chief/internal/prd" ) // PRDEntry represents a PRD in the picker list. type PRDEntry struct { - Name string // Directory name (e.g., "main", "feature-x") - Path string // Full path to prd.json - PRD *prd.PRD // Loaded PRD data - LoadError error // Error if PRD couldn't be loaded - Completed int // Number of completed stories - Total int // Total number of stories - InProgress bool // Whether any story is in progress - LoopState loop.LoopState // Current loop state from manager - Iteration int // Current iteration if running + Name string // Directory name (e.g., "main", "feature-x") + Path string // Full path to prd.json + PRD *prd.PRD // Loaded PRD data + LoadError error // Error if PRD couldn't be loaded + Completed int // Number of completed stories + Total int // Total number of stories + InProgress bool // Whether any story is in progress + LoopState loop.LoopState // Current loop state from manager + Iteration int // Current iteration if running + Branch string // Git branch for this PRD (empty = no branch) + WorktreeDir string // Worktree directory (empty = current directory) + Orphaned bool // True if worktree exists on disk but no running PRD tracks it +} + +// MergeResult holds the result of a merge operation for display. +type MergeResult struct { + Success bool // Whether the merge succeeded + Message string // Success message or error summary + Conflicts []string // Conflicting file list (empty on success) + Branch string // The branch that was merged +} + +// CleanOption represents the user's choice in the clean confirmation dialog. +type CleanOption int + +const ( + CleanOptionRemoveAll CleanOption = iota // Remove worktree + delete branch + CleanOptionWorktreeOnly // Remove worktree only (keep branch) + CleanOptionCancel // Cancel +) + +// CleanConfirmation holds the state of the clean confirmation dialog. +type CleanConfirmation struct { + EntryName string // Name of the PRD being cleaned + Branch string // Branch name to display + WorktreeDir string // Worktree path to display + SelectedIdx int // Selected option index (0-2) +} + +// CleanResult holds the result of a clean operation for display. +type CleanResult struct { + Success bool // Whether the clean succeeded + Message string // Success or error message } // PRDPicker manages the PRD picker modal state. @@ -34,7 +69,10 @@ type PRDPicker struct { currentPRD string // Name of the currently active PRD inputMode bool // Whether we're in input mode for new PRD name inputValue string // The current input value for new PRD name - manager *loop.Manager // Reference to the loop manager for status updates + manager *loop.Manager // Reference to the loop manager for status updates + mergeResult *MergeResult // Result of the last merge operation (nil = none) + cleanConfirmation *CleanConfirmation // Active clean confirmation dialog (nil = none) + cleanResult *CleanResult // Result of the last clean operation (nil = none) } // NewPRDPicker creates a new PRD picker. @@ -94,6 +132,51 @@ func (p *PRDPicker) Refresh() { addedNames["main"] = true } + // Detect orphaned worktrees - worktrees on disk not tracked by any manager instance + diskWorktrees := git.DetectOrphanedWorktrees(p.basePath) + if len(diskWorktrees) > 0 { + // Build set of tracked worktree dirs from manager + trackedDirs := make(map[string]bool) + if p.manager != nil { + for _, inst := range p.manager.GetAllInstances() { + if inst.WorktreeDir != "" { + trackedDirs[inst.WorktreeDir] = true + } + } + } + + for prdName, absPath := range diskWorktrees { + if trackedDirs[absPath] { + continue // This worktree is tracked by a running/registered PRD + } + // Mark the matching entry as orphaned, or note it on existing entries + found := false + for i := range p.entries { + if p.entries[i].Name == prdName { + p.entries[i].Orphaned = true + // Also set WorktreeDir if not already set (so CanClean works) + if p.entries[i].WorktreeDir == "" { + p.entries[i].WorktreeDir = absPath + } + found = true + break + } + } + // If no matching PRD entry exists, the worktree is truly orphaned + // (no prd.json at all). Still show it so the user knows it exists. + if !found { + p.entries = append(p.entries, PRDEntry{ + Name: prdName, + Path: filepath.Join(p.basePath, ".chief", "prds", prdName, "prd.json"), + LoopState: loop.LoopStateReady, + WorktreeDir: absPath, + Orphaned: true, + LoadError: fmt.Errorf("orphaned worktree (no prd.json)"), + }) + } + } + } + // Ensure selected index is valid if p.selectedIndex >= len(p.entries) { p.selectedIndex = len(p.entries) - 1 @@ -128,12 +211,16 @@ func (p *PRDPicker) loadPRDEntry(name, prdPath string) PRDEntry { } } - // Get loop state from manager if available + // Get loop state and worktree info from manager if available if p.manager != nil { if state, iteration, _ := p.manager.GetState(name); state != 0 || iteration != 0 { prdEntry.LoopState = state prdEntry.Iteration = iteration } + if instance := p.manager.GetInstance(name); instance != nil { + prdEntry.Branch = instance.Branch + prdEntry.WorktreeDir = instance.WorktreeDir + } } return prdEntry @@ -221,6 +308,116 @@ func (p *PRDPicker) SetCurrentPRD(name string) { p.currentPRD = name } +// CanMerge returns true if the selected entry is a completed PRD with a branch set. +func (p *PRDPicker) CanMerge() bool { + entry := p.GetSelectedEntry() + if entry == nil || entry.Branch == "" { + return false + } + // Allow merge for completed loop state or all stories passed + return entry.LoopState == loop.LoopStateComplete || (entry.Completed == entry.Total && entry.Total > 0) +} + +// SetMergeResult sets the merge result for display. +func (p *PRDPicker) SetMergeResult(result *MergeResult) { + p.mergeResult = result +} + +// ClearMergeResult clears any displayed merge result. +func (p *PRDPicker) ClearMergeResult() { + p.mergeResult = nil +} + +// HasMergeResult returns true if there is a merge result to display. +func (p *PRDPicker) HasMergeResult() bool { + return p.mergeResult != nil +} + +// CanClean returns true if the selected entry is a non-running PRD with a worktree. +func (p *PRDPicker) CanClean() bool { + entry := p.GetSelectedEntry() + if entry == nil || entry.WorktreeDir == "" { + return false + } + // Disabled for running PRDs - user must stop first + return entry.LoopState != loop.LoopStateRunning +} + +// StartCleanConfirmation opens the clean confirmation dialog for the selected entry. +func (p *PRDPicker) StartCleanConfirmation() { + entry := p.GetSelectedEntry() + if entry == nil { + return + } + p.cleanConfirmation = &CleanConfirmation{ + EntryName: entry.Name, + Branch: entry.Branch, + WorktreeDir: p.worktreeDisplayPath(*entry), + SelectedIdx: 0, + } +} + +// CancelCleanConfirmation closes the clean confirmation dialog. +func (p *PRDPicker) CancelCleanConfirmation() { + p.cleanConfirmation = nil +} + +// HasCleanConfirmation returns true if the clean confirmation dialog is active. +func (p *PRDPicker) HasCleanConfirmation() bool { + return p.cleanConfirmation != nil +} + +// GetCleanConfirmation returns the current clean confirmation state. +func (p *PRDPicker) GetCleanConfirmation() *CleanConfirmation { + return p.cleanConfirmation +} + +// CleanConfirmMoveUp moves the selection up in the clean confirmation dialog. +func (p *PRDPicker) CleanConfirmMoveUp() { + if p.cleanConfirmation != nil && p.cleanConfirmation.SelectedIdx > 0 { + p.cleanConfirmation.SelectedIdx-- + } +} + +// CleanConfirmMoveDown moves the selection down in the clean confirmation dialog. +func (p *PRDPicker) CleanConfirmMoveDown() { + if p.cleanConfirmation != nil && p.cleanConfirmation.SelectedIdx < 2 { + p.cleanConfirmation.SelectedIdx++ + } +} + +// GetCleanOption returns the selected clean option. +func (p *PRDPicker) GetCleanOption() CleanOption { + if p.cleanConfirmation == nil { + return CleanOptionCancel + } + switch p.cleanConfirmation.SelectedIdx { + case 0: + return CleanOptionRemoveAll + case 1: + return CleanOptionWorktreeOnly + case 2: + return CleanOptionCancel + default: + return CleanOptionCancel + } +} + +// SetCleanResult sets the clean result for display. +func (p *PRDPicker) SetCleanResult(result *CleanResult) { + p.cleanResult = result +} + +// ClearCleanResult clears any displayed clean result. +func (p *PRDPicker) ClearCleanResult() { + p.cleanResult = nil +} + +// HasCleanResult returns true if there is a clean result to display. +func (p *PRDPicker) HasCleanResult() bool { + return p.cleanResult != nil +} + // Render renders the PRD picker modal. func (p *PRDPicker) Render() string { // Modal dimensions @@ -234,6 +431,21 @@ func (p *PRDPicker) Render() string { modalHeight = 10 } + // If there's a clean result, render that instead + if p.cleanResult != nil { + return p.renderCleanResult(modalWidth, modalHeight) + } + + // If there's a clean confirmation, render that instead + if p.cleanConfirmation != nil { + return p.renderCleanConfirmation(modalWidth, modalHeight) + } + + // If there's a merge result, render that instead + if p.mergeResult != nil { + return p.renderMergeResult(modalWidth, modalHeight) + } + // Build modal content var content strings.Builder @@ -334,7 +546,18 @@ func (p *PRDPicker) renderEntry(entry PRDEntry, selected bool, width int) string line.WriteString(nameStyle.Render(fmt.Sprintf("%-12s", name))) line.WriteString(" ") - if entry.LoadError != nil { + if entry.Orphaned && entry.LoadError != nil { + // Orphaned worktree with no PRD - show orphaned indicator + orphanedStyle := lipgloss.NewStyle().Foreground(WarningColor) + line.WriteString(orphanedStyle.Render("[orphaned worktree]")) + // Show worktree path if space allows + remaining := width - 32 - 18 // account for indicator + name + orphaned label + if remaining > 10 && entry.WorktreeDir != "" { + pathStyle := lipgloss.NewStyle().Foreground(MutedColor) + displayPath := p.worktreeDisplayPath(entry) + line.WriteString(pathStyle.Render(" " + displayPath)) + } + } else if entry.LoadError != nil { // Show error indicator errorStyle := lipgloss.NewStyle().Foreground(ErrorColor) line.WriteString(errorStyle.Render("[error]")) @@ -360,6 +583,35 @@ func (p *PRDPicker) renderEntry(entry PRDEntry, selected bool, width int) string // Loop state indicator line.WriteString(" ") line.WriteString(p.renderLoopStateIndicator(entry)) + + // Orphaned worktree indicator (for entries with PRD but orphaned worktree) + if entry.Orphaned { + orphanedStyle := lipgloss.NewStyle().Foreground(WarningColor) + line.WriteString(" ") + line.WriteString(orphanedStyle.Render("[orphaned]")) + } + + // Branch and worktree path (only if branch is set) + if entry.Branch != "" { + branchPathStyle := lipgloss.NewStyle().Foreground(MutedColor) + // Calculate remaining space for branch and path info + // Base content uses: 2 (indicator) + 12 (name) + 1 (space) + 8 (progress) + 1 (space) + ~3 (count) + 1 (space) + ~2 (state) = ~30 + remaining := width - 32 + if remaining > 10 { + branchStr := entry.Branch + pathStr := p.worktreeDisplayPath(entry) + // Truncate to fit within remaining space: " branch path" + infoStr := p.formatBranchPath(branchStr, pathStr, remaining) + line.WriteString(branchPathStyle.Render(infoStr)) + } + } else if entry.Branch == "" && p.hasAnyBranch() { + // If other entries have branches, show "(current directory)" for alignment + branchPathStyle := lipgloss.NewStyle().Foreground(MutedColor) + remaining := width - 32 + if remaining > 20 { + line.WriteString(branchPathStyle.Render(" (current directory)")) + } + } } result := line.String() @@ -372,6 +624,65 @@ func (p *PRDPicker) renderEntry(entry PRDEntry, selected bool, width int) string return result } +// worktreeDisplayPath returns a display-friendly worktree path. +func (p *PRDPicker) worktreeDisplayPath(entry PRDEntry) string { + if entry.WorktreeDir == "" { + return "(current directory)" + } + // Show relative path from base dir + rel, err := filepath.Rel(p.basePath, entry.WorktreeDir) + if err != nil { + return entry.WorktreeDir + } + return rel + "/" +} + +// formatBranchPath formats branch and path info to fit within maxWidth. +// maxWidth is in display characters (runes). +func (p *PRDPicker) formatBranchPath(branch, path string, maxWidth int) string { + // Format: " " + prefix := " " + separator := " " + prefixLen := 2 + sepLen := 2 + + branchRunes := []rune(branch) + pathRunes := []rune(path) + + fullLen := prefixLen + len(branchRunes) + sepLen + len(pathRunes) + if fullLen <= maxWidth { + return prefix + branch + separator + path + } + + // Try truncating path first + availForPath := maxWidth - prefixLen - len(branchRunes) - sepLen + if availForPath > 5 { + if len(pathRunes) > availForPath { + // "…" takes 1 display character + keep := availForPath - 1 + pathRunes = append([]rune("…"), pathRunes[len(pathRunes)-keep:]...) + } + return prefix + branch + separator + string(pathRunes) + } + + // Not enough room for path, just show branch (truncated if needed) + availForBranch := maxWidth - prefixLen + if availForBranch > 3 && len(branchRunes) > availForBranch { + branchRunes = append(branchRunes[:availForBranch-1], '…') + } + return prefix + string(branchRunes) +} + +// hasAnyBranch returns true if any entry has a branch set. +func (p *PRDPicker) hasAnyBranch() bool { + for _, entry := range p.entries { + if entry.Branch != "" { + return true + } + } + return false +} + // renderLoopStateIndicator renders a visual indicator for the loop state. func (p *PRDPicker) renderLoopStateIndicator(entry PRDEntry) string { switch entry.LoopState { @@ -448,17 +759,254 @@ func (p *PRDPicker) buildFooterShortcuts() string { // Base shortcuts base := "Enter: select │ n: new │ e: edit │ Esc/l: close" + // Add merge shortcut for completed PRDs with a branch + mergeHint := "" + if p.CanMerge() { + mergeHint = "m: merge │ " + } + + // Add clean shortcut for non-running PRDs with a worktree + cleanHint := "" + if p.CanClean() { + cleanHint = "c: clean │ " + } + // Add state-specific controls switch entry.LoopState { case loop.LoopStateReady, loop.LoopStatePaused, loop.LoopStateStopped, loop.LoopStateError: - return "s: start │ " + base + return "s: start │ " + mergeHint + cleanHint + base case loop.LoopStateRunning: return "p: pause │ x: stop │ " + base case loop.LoopStateComplete: - return base + return mergeHint + cleanHint + base default: - return "s: start │ " + base + return "s: start │ " + mergeHint + cleanHint + base + } +} + +// renderMergeResult renders the merge result dialog. +func (p *PRDPicker) renderMergeResult(modalWidth, modalHeight int) string { + var content strings.Builder + + if p.mergeResult.Success { + // Success display + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(SuccessColor). + Padding(0, 1) + content.WriteString(titleStyle.Render("Merge Successful")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + msgStyle := lipgloss.NewStyle(). + Foreground(TextColor). + Padding(0, 1) + content.WriteString(msgStyle.Render(p.mergeResult.Message)) + content.WriteString("\n") + } else { + // Error/conflict display + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(ErrorColor). + Padding(0, 1) + content.WriteString(titleStyle.Render("Merge Conflict")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + msgStyle := lipgloss.NewStyle(). + Foreground(TextColor). + Padding(0, 1) + content.WriteString(msgStyle.Render(p.mergeResult.Message)) + content.WriteString("\n\n") + + if len(p.mergeResult.Conflicts) > 0 { + conflictHeaderStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(WarningColor). + Padding(0, 1) + content.WriteString(conflictHeaderStyle.Render("Conflicting files:")) + content.WriteString("\n") + + conflictStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 2) + maxFiles := modalHeight - 12 + if maxFiles < 3 { + maxFiles = 3 + } + for i, f := range p.mergeResult.Conflicts { + if i >= maxFiles { + content.WriteString(conflictStyle.Render(fmt.Sprintf(" ... and %d more", len(p.mergeResult.Conflicts)-maxFiles))) + content.WriteString("\n") + break + } + content.WriteString(conflictStyle.Render(" " + f)) + content.WriteString("\n") + } + + content.WriteString("\n") + hintStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + content.WriteString(hintStyle.Render("To resolve manually:")) + content.WriteString("\n") + content.WriteString(hintStyle.Render(fmt.Sprintf(" cd "))) + content.WriteString("\n") + content.WriteString(hintStyle.Render(fmt.Sprintf(" git merge %s", p.mergeResult.Branch))) + content.WriteString("\n") + content.WriteString(hintStyle.Render(" # resolve conflicts, then git commit")) + content.WriteString("\n") + } + } + + // Footer + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + footerStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + content.WriteString(footerStyle.Render("Press any key to continue")) + + // Modal box style + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight) + + modal := modalStyle.Render(content.String()) + return p.centerModal(modal) +} + +// renderCleanConfirmation renders the clean confirmation dialog. +func (p *PRDPicker) renderCleanConfirmation(modalWidth, modalHeight int) string { + var content strings.Builder + cc := p.cleanConfirmation + + // Title + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(WarningColor). + Padding(0, 1) + content.WriteString(titleStyle.Render("Clean Worktree")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + // Show what will be removed + infoStyle := lipgloss.NewStyle(). + Foreground(TextColor). + Padding(0, 1) + content.WriteString(infoStyle.Render(fmt.Sprintf("PRD: %s", cc.EntryName))) + content.WriteString("\n") + content.WriteString(infoStyle.Render(fmt.Sprintf("Worktree: %s", cc.WorktreeDir))) + content.WriteString("\n") + if cc.Branch != "" { + content.WriteString(infoStyle.Render(fmt.Sprintf("Branch: %s", cc.Branch))) + content.WriteString("\n") + } + content.WriteString("\n") + + // Options + options := []struct { + label string + hint string + }{ + {"Remove worktree + delete branch (Recommended)", "Removes worktree directory and deletes the local branch"}, + {"Remove worktree only (keep branch)", "Removes worktree directory but keeps the branch for later use"}, + {"Cancel", ""}, + } + + for i, opt := range options { + prefix := " " + style := lipgloss.NewStyle().Foreground(TextColor) + if i == cc.SelectedIdx { + prefix = "▸ " + style = style.Bold(true).Foreground(TextBrightColor) + } + content.WriteString(style.Render(prefix + opt.label)) + content.WriteString("\n") + if opt.hint != "" && i == cc.SelectedIdx { + hintStyle := lipgloss.NewStyle().Foreground(MutedColor).Padding(0, 2) + content.WriteString(hintStyle.Render(" " + opt.hint)) + content.WriteString("\n") + } + } + + // Footer + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + footerStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + content.WriteString(footerStyle.Render("↑/k ↓/j: nav │ Enter: confirm │ Esc: cancel")) + + // Modal box style + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(WarningColor). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight) + + modal := modalStyle.Render(content.String()) + return p.centerModal(modal) +} + +// renderCleanResult renders the clean result dialog. +func (p *PRDPicker) renderCleanResult(modalWidth, modalHeight int) string { + var content strings.Builder + + if p.cleanResult.Success { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(SuccessColor). + Padding(0, 1) + content.WriteString(titleStyle.Render("Clean Successful")) + } else { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(ErrorColor). + Padding(0, 1) + content.WriteString(titleStyle.Render("Clean Failed")) + } + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + msgStyle := lipgloss.NewStyle(). + Foreground(TextColor). + Padding(0, 1) + content.WriteString(msgStyle.Render(p.cleanResult.Message)) + content.WriteString("\n") + + // Footer + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + footerStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + content.WriteString(footerStyle.Render("Press any key to continue")) + + // Modal box style + borderColor := SuccessColor + if !p.cleanResult.Success { + borderColor = ErrorColor } + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight) + + modal := modalStyle.Render(content.String()) + return p.centerModal(modal) } // centerModal centers the modal on the screen. diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go new file mode 100644 index 0000000..d769744 --- /dev/null +++ b/internal/tui/picker_test.go @@ -0,0 +1,910 @@ +package tui + +import ( + "fmt" + "testing" + "unicode/utf8" + + "github.com/minicodemonkey/chief/internal/loop" +) + +func TestRenderEntryWithBranchAndWorktree(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + currentPRD: "", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + } + + result := p.renderEntry(p.entries[0], false, 80) + if result == "" { + t.Fatal("expected non-empty render result") + } + // Should contain branch name + if !containsText(result, "chief/auth") { + t.Errorf("expected branch 'chief/auth' in output, got: %s", result) + } + // Should contain worktree path + if !containsText(result, ".chief/worktrees/auth/") { + t.Errorf("expected worktree path in output, got: %s", result) + } +} + +func TestRenderEntryNoBranch(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + currentPRD: "", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 3, + Total: 8, + LoopState: loop.LoopStateRunning, + Iteration: 2, + Branch: "", + }, + }, + } + + result := p.renderEntry(p.entries[0], false, 80) + if result == "" { + t.Fatal("expected non-empty render result") + } + // Should NOT contain branch brackets + if containsText(result, "chief/") { + t.Errorf("expected no branch in output when branch is empty, got: %s", result) + } +} + +func TestRenderEntryNoBranchShowsCurrentDirectoryWhenOthersHaveBranch(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + currentPRD: "", + entries: []PRDEntry{ + { + Name: "legacy", + Completed: 5, + Total: 5, + LoopState: loop.LoopStateComplete, + Branch: "", + }, + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + } + + // Render the entry without a branch — should show "(current directory)" since another has a branch + result := p.renderEntry(p.entries[0], false, 80) + if !containsText(result, "(current directory)") { + t.Errorf("expected '(current directory)' for branchless entry when others have branches, got: %s", result) + } +} + +func TestRenderEntryNarrowTerminalOmitsBranchPath(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + currentPRD: "", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + } + + // Very narrow width — should not crash and should omit branch/path info + result := p.renderEntry(p.entries[0], false, 35) + if result == "" { + t.Fatal("expected non-empty render result even at narrow width") + } + // At 35 chars wide, remaining space (35-32=3) is too small for branch info + if containsText(result, "chief/auth") { + t.Errorf("expected branch to be omitted at narrow width, got: %s", result) + } +} + +func TestFormatBranchPathFull(t *testing.T) { + p := &PRDPicker{} + + result := p.formatBranchPath("chief/auth", ".chief/worktrees/auth/", 50) + expected := " chief/auth .chief/worktrees/auth/" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestFormatBranchPathTruncatesPath(t *testing.T) { + p := &PRDPicker{} + + result := p.formatBranchPath("chief/auth", ".chief/worktrees/auth/", 30) + // Should contain branch but path should be truncated with … + if !containsSubstring(result, "chief/auth") { + t.Errorf("expected branch in truncated output, got: %s", result) + } + runeCount := utf8.RuneCountInString(result) + if runeCount > 30 { + t.Errorf("expected result to fit within 30 display chars, got %d: %s", runeCount, result) + } +} + +func TestFormatBranchPathTruncatesBranch(t *testing.T) { + p := &PRDPicker{} + + // Very small width — only room for branch (truncated) + result := p.formatBranchPath("chief/very-long-branch-name", ".chief/worktrees/auth/", 15) + runeCount := utf8.RuneCountInString(result) + if runeCount > 15 { + t.Errorf("expected result to fit within 15 display chars, got %d: %s", runeCount, result) + } +} + +func TestWorktreeDisplayPathWithWorktree(t *testing.T) { + p := &PRDPicker{basePath: "/project"} + + entry := PRDEntry{WorktreeDir: "/project/.chief/worktrees/auth"} + result := p.worktreeDisplayPath(entry) + if result != ".chief/worktrees/auth/" { + t.Errorf("expected '.chief/worktrees/auth/', got %q", result) + } +} + +func TestWorktreeDisplayPathWithoutWorktree(t *testing.T) { + p := &PRDPicker{basePath: "/project"} + + entry := PRDEntry{WorktreeDir: ""} + result := p.worktreeDisplayPath(entry) + if result != "(current directory)" { + t.Errorf("expected '(current directory)', got %q", result) + } +} + +func TestHasAnyBranch(t *testing.T) { + p := &PRDPicker{ + entries: []PRDEntry{ + {Name: "a", Branch: ""}, + {Name: "b", Branch: "chief/b"}, + }, + } + if !p.hasAnyBranch() { + t.Error("expected hasAnyBranch() to return true when one entry has a branch") + } + + p2 := &PRDPicker{ + entries: []PRDEntry{ + {Name: "a", Branch: ""}, + {Name: "c", Branch: ""}, + }, + } + if p2.hasAnyBranch() { + t.Error("expected hasAnyBranch() to return false when no entries have branches") + } +} + +func TestRenderEntryWithLoadError(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "broken", + LoadError: fmt.Errorf("parse error"), + Branch: "chief/broken", + WorktreeDir: "/project/.chief/worktrees/broken", + }, + }, + } + + result := p.renderEntry(p.entries[0], false, 80) + // With load error, should show [error] but not branch/worktree info + if !containsText(result, "error") { + t.Errorf("expected [error] in output, got: %s", result) + } +} + +func TestCanMergeCompletedWithBranch(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + }, + }, + selectedIndex: 0, + } + if !p.CanMerge() { + t.Error("expected CanMerge() to return true for completed PRD with branch") + } +} + +func TestCanMergeNoBranch(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "", + }, + }, + selectedIndex: 0, + } + if p.CanMerge() { + t.Error("expected CanMerge() to return false for completed PRD without branch") + } +} + +func TestCanMergeRunningPRD(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 3, + Total: 8, + LoopState: loop.LoopStateRunning, + Branch: "chief/auth", + }, + }, + selectedIndex: 0, + } + if p.CanMerge() { + t.Error("expected CanMerge() to return false for running PRD") + } +} + +func TestCanMergeAllPassedButNotCompleteState(t *testing.T) { + // All stories pass but loop state is Ready (e.g., not started via loop) + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 5, + Total: 5, + LoopState: loop.LoopStateReady, + Branch: "chief/auth", + }, + }, + selectedIndex: 0, + } + if !p.CanMerge() { + t.Error("expected CanMerge() to return true when all stories pass, even if LoopState is Ready") + } +} + +func TestMergeResultSuccessRendering(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + width: 80, + height: 24, + entries: []PRDEntry{ + {Name: "auth", Branch: "chief/auth"}, + }, + mergeResult: &MergeResult{ + Success: true, + Message: "Merged chief/auth into main", + Branch: "chief/auth", + }, + } + + result := p.Render() + if !containsText(result, "Merge Successful") { + t.Errorf("expected 'Merge Successful' in success render, got: %s", stripAnsi(result)) + } + if !containsText(result, "Merged chief/auth into main") { + t.Errorf("expected merge message in output, got: %s", stripAnsi(result)) + } + if !containsText(result, "Press any key to continue") { + t.Errorf("expected dismiss hint in output, got: %s", stripAnsi(result)) + } +} + +func TestMergeResultConflictRendering(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + width: 80, + height: 24, + entries: []PRDEntry{ + {Name: "auth", Branch: "chief/auth"}, + }, + mergeResult: &MergeResult{ + Success: false, + Message: "Failed to merge chief/auth into current branch", + Conflicts: []string{"src/auth.go", "src/handler.go"}, + Branch: "chief/auth", + }, + } + + result := p.Render() + if !containsText(result, "Merge Conflict") { + t.Errorf("expected 'Merge Conflict' in conflict render, got: %s", stripAnsi(result)) + } + if !containsText(result, "src/auth.go") { + t.Errorf("expected conflicting file in output, got: %s", stripAnsi(result)) + } + if !containsText(result, "src/handler.go") { + t.Errorf("expected conflicting file in output, got: %s", stripAnsi(result)) + } + if !containsText(result, "git merge chief/auth") { + t.Errorf("expected manual merge instruction in output, got: %s", stripAnsi(result)) + } +} + +func TestMergeResultClearsOnDismiss(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + mergeResult: &MergeResult{ + Success: true, + Message: "Merged", + Branch: "chief/auth", + }, + } + + if !p.HasMergeResult() { + t.Error("expected HasMergeResult() to return true") + } + + p.ClearMergeResult() + + if p.HasMergeResult() { + t.Error("expected HasMergeResult() to return false after clear") + } +} + +func TestFooterShowsMergeHintForCompletedPRD(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + }, + }, + selectedIndex: 0, + } + + shortcuts := p.buildFooterShortcuts() + if !containsSubstring(shortcuts, "m: merge") { + t.Errorf("expected 'm: merge' in footer for completed PRD with branch, got: %s", shortcuts) + } +} + +func TestFooterHidesMergeHintForRunningPRD(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 3, + Total: 8, + LoopState: loop.LoopStateRunning, + Iteration: 2, + Branch: "chief/auth", + }, + }, + selectedIndex: 0, + } + + shortcuts := p.buildFooterShortcuts() + if containsSubstring(shortcuts, "m: merge") { + t.Errorf("expected no 'm: merge' in footer for running PRD, got: %s", shortcuts) + } +} + +// --- Clean Action Tests --- + +func TestCanCleanNonRunningWithWorktree(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + if !p.CanClean() { + t.Error("expected CanClean() to return true for completed non-running PRD with worktree") + } +} + +func TestCanCleanDisabledForRunningPRD(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 3, + Total: 8, + LoopState: loop.LoopStateRunning, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + if p.CanClean() { + t.Error("expected CanClean() to return false for running PRD") + } +} + +func TestCanCleanDisabledWithoutWorktree(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + }, + }, + selectedIndex: 0, + } + if p.CanClean() { + t.Error("expected CanClean() to return false for PRD without worktree") + } +} + +func TestCanCleanStoppedPRD(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 3, + Total: 8, + LoopState: loop.LoopStateStopped, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + if !p.CanClean() { + t.Error("expected CanClean() to return true for stopped PRD with worktree") + } +} + +func TestCleanConfirmationDialog(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + + // Start clean confirmation + p.StartCleanConfirmation() + + if !p.HasCleanConfirmation() { + t.Fatal("expected HasCleanConfirmation() to return true after start") + } + + cc := p.GetCleanConfirmation() + if cc.EntryName != "auth" { + t.Errorf("expected EntryName 'auth', got %q", cc.EntryName) + } + if cc.Branch != "chief/auth" { + t.Errorf("expected Branch 'chief/auth', got %q", cc.Branch) + } + if cc.SelectedIdx != 0 { + t.Errorf("expected SelectedIdx 0, got %d", cc.SelectedIdx) + } + + // Default selection is RemoveAll + if p.GetCleanOption() != CleanOptionRemoveAll { + t.Errorf("expected CleanOptionRemoveAll by default, got %d", p.GetCleanOption()) + } +} + +func TestCleanConfirmationNavigation(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + p.StartCleanConfirmation() + + // Move down to "Remove worktree only" + p.CleanConfirmMoveDown() + if p.GetCleanOption() != CleanOptionWorktreeOnly { + t.Errorf("expected CleanOptionWorktreeOnly after move down, got %d", p.GetCleanOption()) + } + + // Move down to "Cancel" + p.CleanConfirmMoveDown() + if p.GetCleanOption() != CleanOptionCancel { + t.Errorf("expected CleanOptionCancel after two moves down, got %d", p.GetCleanOption()) + } + + // Move down again - should stay at Cancel (index 2) + p.CleanConfirmMoveDown() + if p.GetCleanOption() != CleanOptionCancel { + t.Errorf("expected CleanOptionCancel to remain after extra move down, got %d", p.GetCleanOption()) + } + + // Move back up + p.CleanConfirmMoveUp() + if p.GetCleanOption() != CleanOptionWorktreeOnly { + t.Errorf("expected CleanOptionWorktreeOnly after move up, got %d", p.GetCleanOption()) + } +} + +func TestCleanConfirmationCancel(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + p.StartCleanConfirmation() + + if !p.HasCleanConfirmation() { + t.Fatal("expected confirmation to be active") + } + + p.CancelCleanConfirmation() + + if p.HasCleanConfirmation() { + t.Error("expected confirmation to be cancelled") + } +} + +func TestCleanConfirmationRendering(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + width: 80, + height: 24, + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + p.StartCleanConfirmation() + + result := p.Render() + stripped := stripAnsi(result) + + if !containsText(result, "Clean Worktree") { + t.Errorf("expected 'Clean Worktree' in render, got: %s", stripped) + } + if !containsText(result, "auth") { + t.Errorf("expected PRD name 'auth' in render, got: %s", stripped) + } + if !containsText(result, "chief/auth") { + t.Errorf("expected branch 'chief/auth' in render, got: %s", stripped) + } + if !containsText(result, "Remove worktree + delete branch") { + t.Errorf("expected option text in render, got: %s", stripped) + } + if !containsText(result, "Remove worktree only") { + t.Errorf("expected option text in render, got: %s", stripped) + } + if !containsText(result, "Cancel") { + t.Errorf("expected 'Cancel' option in render, got: %s", stripped) + } +} + +func TestCleanResultSuccessRendering(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + width: 80, + height: 24, + entries: []PRDEntry{ + {Name: "auth"}, + }, + cleanResult: &CleanResult{ + Success: true, + Message: "Removed worktree and deleted branch chief/auth", + }, + } + + result := p.Render() + if !containsText(result, "Clean Successful") { + t.Errorf("expected 'Clean Successful' in success render, got: %s", stripAnsi(result)) + } + if !containsText(result, "Removed worktree and deleted branch chief/auth") { + t.Errorf("expected clean message in output, got: %s", stripAnsi(result)) + } + if !containsText(result, "Press any key to continue") { + t.Errorf("expected dismiss hint in output, got: %s", stripAnsi(result)) + } +} + +func TestCleanResultErrorRendering(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + width: 80, + height: 24, + entries: []PRDEntry{ + {Name: "auth"}, + }, + cleanResult: &CleanResult{ + Success: false, + Message: "Failed to remove worktree: permission denied", + }, + } + + result := p.Render() + if !containsText(result, "Clean Failed") { + t.Errorf("expected 'Clean Failed' in error render, got: %s", stripAnsi(result)) + } + if !containsText(result, "permission denied") { + t.Errorf("expected error message in output, got: %s", stripAnsi(result)) + } +} + +func TestCleanResultClearsOnDismiss(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + cleanResult: &CleanResult{ + Success: true, + Message: "Cleaned", + }, + } + + if !p.HasCleanResult() { + t.Error("expected HasCleanResult() to return true") + } + + p.ClearCleanResult() + + if p.HasCleanResult() { + t.Error("expected HasCleanResult() to return false after clear") + } +} + +func TestFooterShowsCleanHintForNonRunningPRDWithWorktree(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + + shortcuts := p.buildFooterShortcuts() + if !containsSubstring(shortcuts, "c: clean") { + t.Errorf("expected 'c: clean' in footer for completed PRD with worktree, got: %s", shortcuts) + } +} + +func TestFooterHidesCleanHintForRunningPRD(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 3, + Total: 8, + LoopState: loop.LoopStateRunning, + Iteration: 2, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + }, + }, + selectedIndex: 0, + } + + shortcuts := p.buildFooterShortcuts() + if containsSubstring(shortcuts, "c: clean") { + t.Errorf("expected no 'c: clean' in footer for running PRD, got: %s", shortcuts) + } +} + +func TestFooterHidesCleanHintForPRDWithoutWorktree(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + }, + }, + selectedIndex: 0, + } + + shortcuts := p.buildFooterShortcuts() + if containsSubstring(shortcuts, "c: clean") { + t.Errorf("expected no 'c: clean' in footer for PRD without worktree, got: %s", shortcuts) + } +} + +// --- Orphaned Worktree Tests --- + +func TestRenderEntryOrphanedWithPRD(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + currentPRD: "", + entries: []PRDEntry{ + { + Name: "auth", + Completed: 8, + Total: 8, + LoopState: loop.LoopStateComplete, + Branch: "chief/auth", + WorktreeDir: "/project/.chief/worktrees/auth", + Orphaned: true, + }, + }, + } + + result := p.renderEntry(p.entries[0], false, 80) + if !containsText(result, "[orphaned]") { + t.Errorf("expected '[orphaned]' indicator for orphaned entry with PRD, got: %s", stripAnsi(result)) + } + // Should still show progress since PRD is loaded + if !containsText(result, "8/8") { + t.Errorf("expected progress '8/8' for orphaned entry with PRD, got: %s", stripAnsi(result)) + } +} + +func TestRenderEntryOrphanedWithoutPRD(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + currentPRD: "", + entries: []PRDEntry{ + { + Name: "stale-project", + WorktreeDir: "/project/.chief/worktrees/stale-project", + Orphaned: true, + LoadError: fmt.Errorf("orphaned worktree (no prd.json)"), + }, + }, + } + + result := p.renderEntry(p.entries[0], false, 80) + if !containsText(result, "[orphaned worktree]") { + t.Errorf("expected '[orphaned worktree]' for orphaned entry without PRD, got: %s", stripAnsi(result)) + } +} + +func TestCanCleanOrphanedWorktree(t *testing.T) { + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "auth", + WorktreeDir: "/project/.chief/worktrees/auth", + Orphaned: true, + LoopState: loop.LoopStateReady, + }, + }, + selectedIndex: 0, + } + if !p.CanClean() { + t.Error("expected CanClean() to return true for orphaned worktree") + } +} + +func TestOrphanedWorktreeNotTracked(t *testing.T) { + // An orphaned entry with no PRD loaded should still be cleanable + p := &PRDPicker{ + basePath: "/project", + entries: []PRDEntry{ + { + Name: "stale", + WorktreeDir: "/project/.chief/worktrees/stale", + Orphaned: true, + LoopState: loop.LoopStateReady, + LoadError: fmt.Errorf("orphaned worktree (no prd.json)"), + }, + }, + selectedIndex: 0, + } + if !p.CanClean() { + t.Error("expected CanClean() to return true for orphaned worktree without PRD") + } +} + +// containsText checks if rendered output contains a substring (ignoring ANSI codes). +func containsText(rendered, substr string) bool { + // Strip ANSI escape sequences for comparison + return containsSubstring(stripAnsi(rendered), substr) +} + +// containsSubstring is a simple substring check. +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && indexOf(s, substr) >= 0 +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +// stripAnsi removes ANSI escape codes from a string. +func stripAnsi(s string) string { + var result []byte + i := 0 + for i < len(s) { + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { + // Skip to end of escape sequence + j := i + 2 + for j < len(s) && !((s[j] >= 'A' && s[j] <= 'Z') || (s[j] >= 'a' && s[j] <= 'z')) { + j++ + } + if j < len(s) { + j++ // skip the final letter + } + i = j + } else { + result = append(result, s[i]) + i++ + } + } + return string(result) +} diff --git a/internal/tui/settings.go b/internal/tui/settings.go new file mode 100644 index 0000000..8365fe0 --- /dev/null +++ b/internal/tui/settings.go @@ -0,0 +1,383 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/minicodemonkey/chief/internal/config" +) + +// SettingsItemType represents the type of a settings item. +type SettingsItemType int + +const ( + SettingsItemBool SettingsItemType = iota + SettingsItemString +) + +// SettingsItem represents a single editable setting. +type SettingsItem struct { + Section string + Label string + Key string // config key for identification + Type SettingsItemType + BoolVal bool + StringVal string +} + +// SettingsOverlay manages the settings modal overlay state. +type SettingsOverlay struct { + width int + height int + + items []SettingsItem + selectedIndex int + + // Inline text editing + editing bool + editBuffer string + + // GH CLI validation error + ghError string + showGHError bool +} + +// NewSettingsOverlay creates a new settings overlay. +func NewSettingsOverlay() *SettingsOverlay { + return &SettingsOverlay{} +} + +// SetSize sets the overlay dimensions. +func (s *SettingsOverlay) SetSize(width, height int) { + s.width = width + s.height = height +} + +// LoadFromConfig populates settings items from a config. +func (s *SettingsOverlay) LoadFromConfig(cfg *config.Config) { + s.items = []SettingsItem{ + {Section: "Worktree", Label: "Setup command", Key: "worktree.setup", Type: SettingsItemString, StringVal: cfg.Worktree.Setup}, + {Section: "On Complete", Label: "Push to remote", Key: "onComplete.push", Type: SettingsItemBool, BoolVal: cfg.OnComplete.Push}, + {Section: "On Complete", Label: "Create pull request", Key: "onComplete.createPR", Type: SettingsItemBool, BoolVal: cfg.OnComplete.CreatePR}, + } + s.selectedIndex = 0 + s.editing = false + s.editBuffer = "" + s.ghError = "" + s.showGHError = false +} + +// ApplyToConfig writes the current settings values back to a config. +func (s *SettingsOverlay) ApplyToConfig(cfg *config.Config) { + for _, item := range s.items { + switch item.Key { + case "worktree.setup": + cfg.Worktree.Setup = item.StringVal + case "onComplete.push": + cfg.OnComplete.Push = item.BoolVal + case "onComplete.createPR": + cfg.OnComplete.CreatePR = item.BoolVal + } + } +} + +// MoveUp moves the selection up. +func (s *SettingsOverlay) MoveUp() { + if s.selectedIndex > 0 { + s.selectedIndex-- + } +} + +// MoveDown moves the selection down. +func (s *SettingsOverlay) MoveDown() { + if s.selectedIndex < len(s.items)-1 { + s.selectedIndex++ + } +} + +// IsEditing returns true if a string value is being edited. +func (s *SettingsOverlay) IsEditing() bool { + return s.editing +} + +// StartEditing begins inline editing of the selected string value. +func (s *SettingsOverlay) StartEditing() { + if s.selectedIndex < len(s.items) && s.items[s.selectedIndex].Type == SettingsItemString { + s.editing = true + s.editBuffer = s.items[s.selectedIndex].StringVal + } +} + +// ConfirmEdit saves the edit buffer to the selected item. +func (s *SettingsOverlay) ConfirmEdit() { + if s.editing && s.selectedIndex < len(s.items) { + s.items[s.selectedIndex].StringVal = s.editBuffer + s.editing = false + s.editBuffer = "" + } +} + +// CancelEdit discards the edit buffer. +func (s *SettingsOverlay) CancelEdit() { + s.editing = false + s.editBuffer = "" +} + +// AddEditChar adds a character to the edit buffer. +func (s *SettingsOverlay) AddEditChar(ch rune) { + s.editBuffer += string(ch) +} + +// DeleteEditChar removes the last character from the edit buffer. +func (s *SettingsOverlay) DeleteEditChar() { + if len(s.editBuffer) > 0 { + runes := []rune(s.editBuffer) + s.editBuffer = string(runes[:len(runes)-1]) + } +} + +// ToggleBool toggles the selected boolean value. +// Returns the key and new value for the caller to act on. +func (s *SettingsOverlay) ToggleBool() (key string, newVal bool) { + if s.selectedIndex < len(s.items) && s.items[s.selectedIndex].Type == SettingsItemBool { + s.items[s.selectedIndex].BoolVal = !s.items[s.selectedIndex].BoolVal + return s.items[s.selectedIndex].Key, s.items[s.selectedIndex].BoolVal + } + return "", false +} + +// RevertToggle reverts the last toggle (used when validation fails). +func (s *SettingsOverlay) RevertToggle() { + if s.selectedIndex < len(s.items) && s.items[s.selectedIndex].Type == SettingsItemBool { + s.items[s.selectedIndex].BoolVal = !s.items[s.selectedIndex].BoolVal + } +} + +// SetGHError sets the GH CLI error message. +func (s *SettingsOverlay) SetGHError(msg string) { + s.ghError = msg + s.showGHError = true +} + +// HasGHError returns true if a GH CLI error is being displayed. +func (s *SettingsOverlay) HasGHError() bool { + return s.showGHError +} + +// DismissGHError clears the GH CLI error. +func (s *SettingsOverlay) DismissGHError() { + s.showGHError = false + s.ghError = "" +} + +// GetSelectedItem returns the currently selected settings item. +func (s *SettingsOverlay) GetSelectedItem() *SettingsItem { + if s.selectedIndex >= 0 && s.selectedIndex < len(s.items) { + return &s.items[s.selectedIndex] + } + return nil +} + +// Render renders the settings overlay. +func (s *SettingsOverlay) Render() string { + modalWidth := min(60, s.width-10) + modalHeight := min(18, s.height-6) + + if modalWidth < 40 { + modalWidth = 40 + } + if modalHeight < 12 { + modalHeight = 12 + } + + var content strings.Builder + + // Header: "Settings" left-aligned, ".chief/config.yaml" right-aligned + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(PrimaryColor) + pathStyle := lipgloss.NewStyle(). + Foreground(MutedColor) + + title := titleStyle.Render("Settings") + path := pathStyle.Render(".chief/config.yaml") + titleWidth := lipgloss.Width(title) + pathWidth := lipgloss.Width(path) + titlePadding := modalWidth - 4 - titleWidth - pathWidth + if titlePadding < 1 { + titlePadding = 1 + } + content.WriteString(" ") + content.WriteString(title) + content.WriteString(strings.Repeat(" ", titlePadding)) + content.WriteString(path) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + // GH error dialog overlay + if s.showGHError { + content.WriteString(s.renderGHError(modalWidth)) + } else { + // Render settings items grouped by section + content.WriteString(s.renderItems(modalWidth)) + } + + // Footer + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + + footerStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + + if s.showGHError { + content.WriteString(footerStyle.Render("Press any key to dismiss")) + } else if s.editing { + content.WriteString(footerStyle.Render("Enter: save │ Esc: cancel")) + } else { + content.WriteString(footerStyle.Render("Enter: toggle/edit │ j/k: navigate │ Esc: close")) + } + + // Modal box style + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight) + + modal := modalStyle.Render(content.String()) + + return centerModal(modal, s.width, s.height) +} + +// renderItems renders the settings items grouped by section. +func (s *SettingsOverlay) renderItems(modalWidth int) string { + var result strings.Builder + + sectionStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(PrimaryColor). + Padding(0, 1) + labelStyle := lipgloss.NewStyle(). + Foreground(TextColor) + selectedLabelStyle := lipgloss.NewStyle(). + Foreground(TextBrightColor). + Bold(true) + valueStyle := lipgloss.NewStyle(). + Foreground(SuccessColor) + valueOffStyle := lipgloss.NewStyle(). + Foreground(MutedColor) + cursorStyle := lipgloss.NewStyle(). + Foreground(PrimaryColor). + Bold(true) + + currentSection := "" + for i, item := range s.items { + // Section header + if item.Section != currentSection { + if currentSection != "" { + result.WriteString("\n") + } + result.WriteString(sectionStyle.Render(item.Section)) + result.WriteString("\n") + currentSection = item.Section + } + + isSelected := i == s.selectedIndex + + // Cursor + if isSelected { + result.WriteString(cursorStyle.Render(" > ")) + } else { + result.WriteString(" ") + } + + // Label + label := item.Label + if isSelected { + result.WriteString(selectedLabelStyle.Render(label)) + } else { + result.WriteString(labelStyle.Render(label)) + } + + // Value (right-aligned) + var valueStr string + switch item.Type { + case SettingsItemBool: + if item.BoolVal { + valueStr = valueStyle.Render("Yes") + } else { + valueStr = valueOffStyle.Render("No") + } + case SettingsItemString: + if isSelected && s.editing { + // Show edit buffer with cursor + editStyle := lipgloss.NewStyle().Foreground(TextBrightColor) + cursorChar := lipgloss.NewStyle().Foreground(PrimaryColor).Render("█") + if s.editBuffer == "" { + valueStr = editStyle.Render("(empty)") + cursorChar + } else { + valueStr = editStyle.Render(s.editBuffer) + cursorChar + } + } else if item.StringVal == "" { + valueStr = valueOffStyle.Render("(not set)") + } else { + // Truncate long values + val := item.StringVal + maxValWidth := modalWidth - 4 - 4 - len(label) - 4 + if maxValWidth < 10 { + maxValWidth = 10 + } + if len(val) > maxValWidth { + val = val[:maxValWidth-1] + "…" + } + valueStr = valueStyle.Render(val) + } + } + + // Calculate padding between label and value + labelWidth := lipgloss.Width(label) + 4 // cursor prefix + valWidth := lipgloss.Width(valueStr) + padding := modalWidth - 4 - labelWidth - valWidth - 2 + if padding < 2 { + padding = 2 + } + result.WriteString(strings.Repeat(" ", padding)) + result.WriteString(valueStr) + result.WriteString("\n") + } + + return result.String() +} + +// renderGHError renders the GH CLI error dialog. +func (s *SettingsOverlay) renderGHError(modalWidth int) string { + var result strings.Builder + + errorHeaderStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(ErrorColor). + Padding(0, 1) + errorMsgStyle := lipgloss.NewStyle(). + Foreground(TextColor). + Padding(0, 1) + + result.WriteString(errorHeaderStyle.Render("GitHub CLI Error")) + result.WriteString("\n\n") + result.WriteString(errorMsgStyle.Render(s.ghError)) + result.WriteString("\n\n") + + hintStyle := lipgloss.NewStyle(). + Foreground(MutedColor). + Padding(0, 1) + result.WriteString(hintStyle.Render(fmt.Sprintf("Install: https://cli.github.com"))) + result.WriteString("\n") + result.WriteString(hintStyle.Render("PR creation has been disabled.")) + + _ = modalWidth + return result.String() +} diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go new file mode 100644 index 0000000..015ec8e --- /dev/null +++ b/internal/tui/settings_test.go @@ -0,0 +1,365 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/minicodemonkey/chief/internal/config" +) + +func TestSettingsOverlay_LoadFromConfig(t *testing.T) { + s := NewSettingsOverlay() + cfg := &config.Config{ + Worktree: config.WorktreeConfig{ + Setup: "npm install", + }, + OnComplete: config.OnCompleteConfig{ + Push: true, + CreatePR: false, + }, + } + s.LoadFromConfig(cfg) + + if len(s.items) != 3 { + t.Fatalf("expected 3 items, got %d", len(s.items)) + } + if s.items[0].Key != "worktree.setup" || s.items[0].StringVal != "npm install" { + t.Errorf("worktree.setup item: got key=%s val=%s", s.items[0].Key, s.items[0].StringVal) + } + if s.items[1].Key != "onComplete.push" || !s.items[1].BoolVal { + t.Errorf("onComplete.push item: got key=%s val=%v", s.items[1].Key, s.items[1].BoolVal) + } + if s.items[2].Key != "onComplete.createPR" || s.items[2].BoolVal { + t.Errorf("onComplete.createPR item: got key=%s val=%v", s.items[2].Key, s.items[2].BoolVal) + } + if s.selectedIndex != 0 { + t.Errorf("expected selectedIndex=0, got %d", s.selectedIndex) + } +} + +func TestSettingsOverlay_ApplyToConfig(t *testing.T) { + s := NewSettingsOverlay() + cfg := config.Default() + s.LoadFromConfig(cfg) + + // Modify items + s.items[0].StringVal = "go mod download" + s.items[1].BoolVal = true + s.items[2].BoolVal = true + + resultCfg := config.Default() + s.ApplyToConfig(resultCfg) + + if resultCfg.Worktree.Setup != "go mod download" { + t.Errorf("expected setup='go mod download', got '%s'", resultCfg.Worktree.Setup) + } + if !resultCfg.OnComplete.Push { + t.Error("expected push=true") + } + if !resultCfg.OnComplete.CreatePR { + t.Error("expected createPR=true") + } +} + +func TestSettingsOverlay_Navigation(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + + if s.selectedIndex != 0 { + t.Fatalf("expected initial index=0, got %d", s.selectedIndex) + } + + s.MoveDown() + if s.selectedIndex != 1 { + t.Errorf("expected index=1 after MoveDown, got %d", s.selectedIndex) + } + + s.MoveDown() + if s.selectedIndex != 2 { + t.Errorf("expected index=2 after second MoveDown, got %d", s.selectedIndex) + } + + // Can't go beyond last item + s.MoveDown() + if s.selectedIndex != 2 { + t.Errorf("expected index=2 (clamped), got %d", s.selectedIndex) + } + + s.MoveUp() + if s.selectedIndex != 1 { + t.Errorf("expected index=1 after MoveUp, got %d", s.selectedIndex) + } + + // Can't go before first item + s.MoveUp() + s.MoveUp() + if s.selectedIndex != 0 { + t.Errorf("expected index=0 (clamped), got %d", s.selectedIndex) + } +} + +func TestSettingsOverlay_ToggleBool(t *testing.T) { + s := NewSettingsOverlay() + cfg := &config.Config{ + OnComplete: config.OnCompleteConfig{Push: false}, + } + s.LoadFromConfig(cfg) + + // Select "Push to remote" (index 1) + s.MoveDown() + + key, val := s.ToggleBool() + if key != "onComplete.push" { + t.Errorf("expected key='onComplete.push', got '%s'", key) + } + if !val { + t.Error("expected val=true after toggle") + } + + // Toggle back + key, val = s.ToggleBool() + if val { + t.Error("expected val=false after second toggle") + } + _ = key +} + +func TestSettingsOverlay_ToggleBool_OnStringItem(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + + // Selected item is "Setup command" (string type) + key, _ := s.ToggleBool() + if key != "" { + t.Errorf("expected empty key for string item toggle, got '%s'", key) + } +} + +func TestSettingsOverlay_RevertToggle(t *testing.T) { + s := NewSettingsOverlay() + cfg := &config.Config{ + OnComplete: config.OnCompleteConfig{Push: false}, + } + s.LoadFromConfig(cfg) + + s.MoveDown() // Select "Push to remote" + s.ToggleBool() + if !s.items[1].BoolVal { + t.Fatal("expected true after toggle") + } + + s.RevertToggle() + if s.items[1].BoolVal { + t.Error("expected false after revert") + } +} + +func TestSettingsOverlay_StringEditing(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + + // Selected item is "Setup command" (index 0) + if s.IsEditing() { + t.Fatal("should not be editing initially") + } + + s.StartEditing() + if !s.IsEditing() { + t.Fatal("should be editing after StartEditing") + } + if s.editBuffer != "" { + t.Errorf("expected empty edit buffer, got '%s'", s.editBuffer) + } + + s.AddEditChar('n') + s.AddEditChar('p') + s.AddEditChar('m') + if s.editBuffer != "npm" { + t.Errorf("expected 'npm', got '%s'", s.editBuffer) + } + + s.DeleteEditChar() + if s.editBuffer != "np" { + t.Errorf("expected 'np' after delete, got '%s'", s.editBuffer) + } + + s.ConfirmEdit() + if s.IsEditing() { + t.Fatal("should not be editing after ConfirmEdit") + } + if s.items[0].StringVal != "np" { + t.Errorf("expected StringVal='np', got '%s'", s.items[0].StringVal) + } +} + +func TestSettingsOverlay_CancelEdit(t *testing.T) { + s := NewSettingsOverlay() + cfg := &config.Config{ + Worktree: config.WorktreeConfig{Setup: "original"}, + } + s.LoadFromConfig(cfg) + + s.StartEditing() + s.AddEditChar('x') + s.CancelEdit() + + if s.IsEditing() { + t.Fatal("should not be editing after CancelEdit") + } + if s.items[0].StringVal != "original" { + t.Errorf("expected 'original' preserved, got '%s'", s.items[0].StringVal) + } +} + +func TestSettingsOverlay_StartEditingOnBoolItem(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + s.MoveDown() // Select "Push to remote" (bool) + + s.StartEditing() + if s.IsEditing() { + t.Error("should not start editing on a bool item") + } +} + +func TestSettingsOverlay_GHError(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + + if s.HasGHError() { + t.Fatal("should not have GH error initially") + } + + s.SetGHError("gh not found") + if !s.HasGHError() { + t.Fatal("should have GH error after SetGHError") + } + + s.DismissGHError() + if s.HasGHError() { + t.Fatal("should not have GH error after dismiss") + } +} + +func TestSettingsOverlay_Render(t *testing.T) { + s := NewSettingsOverlay() + cfg := &config.Config{ + Worktree: config.WorktreeConfig{Setup: "npm install"}, + OnComplete: config.OnCompleteConfig{ + Push: true, + CreatePR: false, + }, + } + s.LoadFromConfig(cfg) + s.SetSize(80, 24) + + rendered := s.Render() + + // Check header + if !strings.Contains(rendered, "Settings") { + t.Error("expected 'Settings' in header") + } + if !strings.Contains(rendered, ".chief/config.yaml") { + t.Error("expected config path in header") + } + + // Check section headers + if !strings.Contains(rendered, "Worktree") { + t.Error("expected 'Worktree' section") + } + if !strings.Contains(rendered, "On Complete") { + t.Error("expected 'On Complete' section") + } + + // Check values + if !strings.Contains(rendered, "npm install") { + t.Error("expected 'npm install' value") + } + if !strings.Contains(rendered, "Yes") { + t.Error("expected 'Yes' for push") + } + if !strings.Contains(rendered, "No") { + t.Error("expected 'No' for createPR") + } + + // Check footer + if !strings.Contains(rendered, "Esc: close") { + t.Error("expected 'Esc: close' in footer") + } +} + +func TestSettingsOverlay_RenderGHError(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + s.SetSize(80, 24) + + s.SetGHError("gh not found") + rendered := s.Render() + + if !strings.Contains(rendered, "GitHub CLI Error") { + t.Error("expected 'GitHub CLI Error' in rendered output") + } + if !strings.Contains(rendered, "gh not found") { + t.Error("expected error message in rendered output") + } + if !strings.Contains(rendered, "Press any key to dismiss") { + t.Error("expected dismiss hint in footer") + } +} + +func TestSettingsOverlay_RenderEditing(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + s.SetSize(80, 24) + + s.StartEditing() + rendered := s.Render() + + if !strings.Contains(rendered, "Enter: save") { + t.Error("expected 'Enter: save' in footer during editing") + } +} + +func TestSettingsOverlay_RenderSelectedIndicator(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + s.SetSize(80, 24) + + rendered := s.Render() + + // The selected item should have a ">" indicator + if !strings.Contains(rendered, ">") { + t.Error("expected '>' cursor indicator for selected item") + } +} + +func TestSettingsOverlay_RenderEmptyStringValue(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + s.SetSize(80, 24) + + rendered := s.Render() + + if !strings.Contains(rendered, "(not set)") { + t.Error("expected '(not set)' for empty setup command") + } +} + +func TestSettingsOverlay_GetSelectedItem(t *testing.T) { + s := NewSettingsOverlay() + s.LoadFromConfig(config.Default()) + + item := s.GetSelectedItem() + if item == nil { + t.Fatal("expected non-nil selected item") + } + if item.Key != "worktree.setup" { + t.Errorf("expected first item key='worktree.setup', got '%s'", item.Key) + } + + s.MoveDown() + item = s.GetSelectedItem() + if item.Key != "onComplete.push" { + t.Errorf("expected second item key='onComplete.push', got '%s'", item.Key) + } +} diff --git a/internal/tui/tabbar.go b/internal/tui/tabbar.go index 91cdc96..a562715 100644 --- a/internal/tui/tabbar.go +++ b/internal/tui/tabbar.go @@ -15,6 +15,7 @@ import ( type TabEntry struct { Name string // Directory name (e.g., "main", "feature-x") Path string // Full path to prd.json + Branch string // Git branch name (e.g., "chief/auth"), empty if none LoopState loop.LoopState // Current loop state from manager Completed int // Number of completed stories Total int // Total number of stories @@ -108,12 +109,15 @@ func (t *TabBar) loadTabEntry(name, prdPath string) TabEntry { } } - // Get loop state from manager if available + // Get loop state and branch from manager if available if t.manager != nil { if state, iteration, _ := t.manager.GetState(name); state != 0 || iteration != 0 { tabEntry.LoopState = state tabEntry.Iteration = iteration } + if inst := t.manager.GetInstance(name); inst != nil { + tabEntry.Branch = inst.Branch + } } return tabEntry @@ -212,6 +216,16 @@ func (t *TabBar) renderTab(entry TabEntry, number int) string { content.WriteString(activeIndicator) content.WriteString(name) + if entry.Branch != "" { + branch := entry.Branch + maxBranchLen := 20 + if len(branch) > maxBranchLen { + branch = branch[:maxBranchLen-1] + "…" + } + content.WriteString(" [") + content.WriteString(branch) + content.WriteString("]") + } content.WriteString(stateIndicator) tabContent := content.String() diff --git a/internal/tui/tabbar_test.go b/internal/tui/tabbar_test.go new file mode 100644 index 0000000..978218a --- /dev/null +++ b/internal/tui/tabbar_test.go @@ -0,0 +1,132 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/minicodemonkey/chief/internal/loop" +) + +func TestRenderTabWithBranch(t *testing.T) { + tb := &TabBar{} + + entry := TabEntry{ + Name: "auth", + Branch: "chief/auth", + LoopState: loop.LoopStateRunning, + Iteration: 3, + Total: 8, + Completed: 3, + } + + result := tb.renderTab(entry, 1) + if !strings.Contains(result, "[chief/auth]") { + t.Errorf("expected tab to contain [chief/auth], got: %s", result) + } + if !strings.Contains(result, "auth") { + t.Errorf("expected tab to contain name 'auth', got: %s", result) + } +} + +func TestRenderTabWithoutBranch(t *testing.T) { + tb := &TabBar{} + + entry := TabEntry{ + Name: "auth", + LoopState: loop.LoopStateReady, + Total: 8, + Completed: 3, + } + + result := tb.renderTab(entry, 1) + // Should not contain a branch bracket like [chief/auth], but may contain [3/8] progress + if strings.Contains(result, "[chief/") { + t.Errorf("expected tab without branch to not contain branch brackets, got: %s", result) + } +} + +func TestRenderTabBranchTruncation(t *testing.T) { + tb := &TabBar{} + + entry := TabEntry{ + Name: "auth", + Branch: "chief/very-long-branch-name-that-is-too-long", + LoopState: loop.LoopStateReady, + Total: 5, + Completed: 2, + } + + result := tb.renderTab(entry, 1) + // Branch should be truncated to 20 chars max (19 + "…") + if strings.Contains(result, "chief/very-long-branch-name-that-is-too-long") { + t.Errorf("expected long branch name to be truncated, got: %s", result) + } + // Should contain the truncated version + if !strings.Contains(result, "chief/very-long-bra…") { + t.Errorf("expected truncated branch name, got: %s", result) + } +} + +func TestRenderCompactTabOmitsBranch(t *testing.T) { + tb := &TabBar{} + + entry := TabEntry{ + Name: "auth", + Branch: "chief/auth", + LoopState: loop.LoopStateRunning, + } + + result := tb.renderCompactTab(entry, 1) + if strings.Contains(result, "chief/auth") { + t.Errorf("expected compact tab to omit branch, got: %s", result) + } +} + +func TestTabEntryBranchField(t *testing.T) { + entry := TabEntry{ + Name: "payments", + Branch: "chief/payments", + } + + if entry.Branch != "chief/payments" { + t.Errorf("expected Branch to be 'chief/payments', got: %s", entry.Branch) + } +} + +func TestRenderTabBranchWithActiveIndicator(t *testing.T) { + tb := &TabBar{} + + entry := TabEntry{ + Name: "auth", + Branch: "chief/auth", + LoopState: loop.LoopStateReady, + IsActive: true, + Total: 8, + Completed: 3, + } + + result := tb.renderTab(entry, 1) + if !strings.Contains(result, "[chief/auth]") { + t.Errorf("expected active tab to contain [chief/auth], got: %s", result) + } + if !strings.Contains(result, "◉") { + t.Errorf("expected active tab to contain active indicator, got: %s", result) + } +} + +func TestRenderTabEmptyBranch(t *testing.T) { + tb := &TabBar{} + + entry := TabEntry{ + Name: "auth", + Branch: "", + LoopState: loop.LoopStateReady, + Total: 5, + Completed: 2, + } + + result := tb.renderTab(entry, 1) + if strings.Contains(result, "[]") { + t.Errorf("expected empty branch to not show empty brackets, got: %s", result) + } +} diff --git a/internal/tui/worktree_spinner.go b/internal/tui/worktree_spinner.go new file mode 100644 index 0000000..c50694b --- /dev/null +++ b/internal/tui/worktree_spinner.go @@ -0,0 +1,280 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// WorktreeSpinnerStep represents a step in the worktree setup process. +type WorktreeSpinnerStep int + +const ( + SpinnerStepCreateBranch WorktreeSpinnerStep = iota + SpinnerStepCreateWorktree + SpinnerStepRunSetup + SpinnerStepDone +) + +// stepInfo holds the display info for each setup step. +type stepInfo struct { + label string + complete bool + active bool + errMsg string +} + +// WorktreeSpinner manages the worktree setup spinner overlay state. +type WorktreeSpinner struct { + width int + height int + + prdName string + branchName string + defaultBranch string + worktreePath string // Relative path for display (e.g., ".chief/worktrees/auth/") + setupCommand string // Empty if no setup command configured + + currentStep WorktreeSpinnerStep + spinnerFrame int + steps []stepInfo + errMsg string // Overall error message + cancelled bool +} + +// NewWorktreeSpinner creates a new worktree setup spinner. +func NewWorktreeSpinner() *WorktreeSpinner { + return &WorktreeSpinner{} +} + +// Configure sets up the spinner with the given parameters. +func (w *WorktreeSpinner) Configure(prdName, branchName, defaultBranch, worktreePath, setupCommand string) { + w.prdName = prdName + w.branchName = branchName + w.defaultBranch = defaultBranch + w.worktreePath = worktreePath + w.setupCommand = setupCommand + w.currentStep = SpinnerStepCreateBranch + w.spinnerFrame = 0 + w.errMsg = "" + w.cancelled = false + + // Build steps list + w.steps = []stepInfo{ + {label: fmt.Sprintf("Creating branch '%s' from '%s'", branchName, defaultBranch)}, + {label: fmt.Sprintf("Creating worktree at %s", worktreePath)}, + } + if setupCommand != "" { + w.steps = append(w.steps, stepInfo{label: fmt.Sprintf("Running setup: %s", setupCommand)}) + } + + // Mark first step as active + if len(w.steps) > 0 { + w.steps[0].active = true + } +} + +// SetSize sets the spinner dimensions. +func (w *WorktreeSpinner) SetSize(width, height int) { + w.width = width + w.height = height +} + +// AdvanceStep marks the current step as complete and moves to the next. +func (w *WorktreeSpinner) AdvanceStep() { + idx := int(w.currentStep) + if idx < len(w.steps) { + w.steps[idx].complete = true + w.steps[idx].active = false + } + + w.currentStep++ + + // Skip setup step if no setup command + if w.currentStep == SpinnerStepRunSetup && w.setupCommand == "" { + w.currentStep = SpinnerStepDone + } + + nextIdx := int(w.currentStep) + if nextIdx < len(w.steps) { + w.steps[nextIdx].active = true + } +} + +// SetError sets an error on the current step. +func (w *WorktreeSpinner) SetError(err string) { + w.errMsg = err + idx := int(w.currentStep) + if idx < len(w.steps) { + w.steps[idx].errMsg = err + w.steps[idx].active = false + } +} + +// HasError returns true if there is an error. +func (w *WorktreeSpinner) HasError() bool { + return w.errMsg != "" +} + +// IsDone returns true if all steps are complete. +func (w *WorktreeSpinner) IsDone() bool { + return w.currentStep >= SpinnerStepDone +} + +// GetCurrentStep returns the current step. +func (w *WorktreeSpinner) GetCurrentStep() WorktreeSpinnerStep { + return w.currentStep +} + +// HasSetupCommand returns true if a setup command is configured. +func (w *WorktreeSpinner) HasSetupCommand() bool { + return w.setupCommand != "" +} + +// IsCancelled returns true if the user cancelled. +func (w *WorktreeSpinner) IsCancelled() bool { + return w.cancelled +} + +// Cancel marks the spinner as cancelled. +func (w *WorktreeSpinner) Cancel() { + w.cancelled = true +} + +// Tick advances the spinner animation frame. +func (w *WorktreeSpinner) Tick() { + w.spinnerFrame++ +} + +// completedStepLabels returns the labels for completed steps (for display after done). +func (w *WorktreeSpinner) completedStepLabels() []string { + var labels []string + labels = append(labels, fmt.Sprintf("Created branch '%s' from '%s'", w.branchName, w.defaultBranch)) + labels = append(labels, fmt.Sprintf("Created worktree at %s", w.worktreePath)) + if w.setupCommand != "" { + labels = append(labels, fmt.Sprintf("Ran setup: %s", w.setupCommand)) + } + return labels +} + +// Render renders the spinner overlay. +func (w *WorktreeSpinner) Render() string { + modalWidth := min(65, w.width-10) + if modalWidth < 40 { + modalWidth = 40 + } + + var content strings.Builder + + // Title + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(PrimaryColor) + content.WriteString(titleStyle.Render("Setting up worktree")) + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n\n") + + spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + spinnerStyle := lipgloss.NewStyle().Foreground(PrimaryColor) + checkStyle := lipgloss.NewStyle().Foreground(SuccessColor) + errorStyle := lipgloss.NewStyle().Foreground(ErrorColor) + textStyle := lipgloss.NewStyle().Foreground(TextColor) + mutedStyle := lipgloss.NewStyle().Foreground(MutedColor) + + // Render steps + for _, step := range w.steps { + if step.complete { + content.WriteString(checkStyle.Render("✓")) + content.WriteString(" ") + // Show completed label + completedLabel := strings.Replace(step.label, "Creating branch", "Created branch", 1) + completedLabel = strings.Replace(completedLabel, "Creating worktree", "Created worktree", 1) + completedLabel = strings.Replace(completedLabel, "Running setup", "Ran setup", 1) + content.WriteString(textStyle.Render(completedLabel)) + } else if step.errMsg != "" { + content.WriteString(errorStyle.Render("✗")) + content.WriteString(" ") + content.WriteString(errorStyle.Render(step.label)) + content.WriteString("\n") + content.WriteString(" ") + content.WriteString(errorStyle.Render(step.errMsg)) + } else if step.active { + frame := spinnerFrames[w.spinnerFrame%len(spinnerFrames)] + content.WriteString(spinnerStyle.Render(frame)) + content.WriteString(" ") + content.WriteString(textStyle.Render(step.label)) + } else { + content.WriteString(mutedStyle.Render("○")) + content.WriteString(" ") + content.WriteString(mutedStyle.Render(step.label)) + } + content.WriteString("\n") + } + + // Done state - show "Starting loop..." + if w.IsDone() { + content.WriteString("\n") + content.WriteString(checkStyle.Render("Starting loop...")) + } + + // Footer + content.WriteString("\n") + content.WriteString(DividerStyle.Render(strings.Repeat("─", modalWidth-4))) + content.WriteString("\n") + + footerStyle := lipgloss.NewStyle().Foreground(MutedColor) + if w.HasError() { + content.WriteString(footerStyle.Render("Esc: Cancel and clean up")) + } else if w.IsDone() { + // No footer needed when transitioning + } else { + content.WriteString(footerStyle.Render("Esc: Cancel")) + } + + // Modal box + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(1, 2). + Width(modalWidth) + + modal := modalStyle.Render(content.String()) + + return w.centerModal(modal) +} + +// centerModal centers the modal on the screen. +func (w *WorktreeSpinner) centerModal(modal string) string { + lines := strings.Split(modal, "\n") + modalHeight := len(lines) + modalWidth := 0 + for _, line := range lines { + if lipgloss.Width(line) > modalWidth { + modalWidth = lipgloss.Width(line) + } + } + + topPadding := (w.height - modalHeight) / 2 + leftPadding := (w.width - modalWidth) / 2 + + if topPadding < 0 { + topPadding = 0 + } + if leftPadding < 0 { + leftPadding = 0 + } + + var result strings.Builder + for i := 0; i < topPadding; i++ { + result.WriteString("\n") + } + + leftPad := strings.Repeat(" ", leftPadding) + for _, line := range lines { + result.WriteString(leftPad) + result.WriteString(line) + result.WriteString("\n") + } + + return result.String() +} diff --git a/internal/tui/worktree_spinner_test.go b/internal/tui/worktree_spinner_test.go new file mode 100644 index 0000000..f1822a7 --- /dev/null +++ b/internal/tui/worktree_spinner_test.go @@ -0,0 +1,218 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestWorktreeSpinnerConfigure(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "") + + if s.prdName != "auth" { + t.Errorf("expected prdName 'auth', got %q", s.prdName) + } + if s.branchName != "chief/auth" { + t.Errorf("expected branchName 'chief/auth', got %q", s.branchName) + } + if s.defaultBranch != "main" { + t.Errorf("expected defaultBranch 'main', got %q", s.defaultBranch) + } + + // Without setup command, should have 2 steps + if len(s.steps) != 2 { + t.Errorf("expected 2 steps without setup, got %d", len(s.steps)) + } + if !s.steps[0].active { + t.Error("expected first step to be active") + } +} + +func TestWorktreeSpinnerConfigureWithSetup(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "npm install") + + // With setup command, should have 3 steps + if len(s.steps) != 3 { + t.Errorf("expected 3 steps with setup, got %d", len(s.steps)) + } + if !s.HasSetupCommand() { + t.Error("expected HasSetupCommand to be true") + } +} + +func TestWorktreeSpinnerAdvanceStep(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "npm install") + + // Initially at step 0 + if s.GetCurrentStep() != SpinnerStepCreateBranch { + t.Errorf("expected step CreateBranch, got %d", s.GetCurrentStep()) + } + if s.IsDone() { + t.Error("should not be done at start") + } + + // Advance to worktree step + s.AdvanceStep() + if s.GetCurrentStep() != SpinnerStepCreateWorktree { + t.Errorf("expected step CreateWorktree, got %d", s.GetCurrentStep()) + } + if !s.steps[0].complete { + t.Error("first step should be complete") + } + if !s.steps[1].active { + t.Error("second step should be active") + } + + // Advance to setup step + s.AdvanceStep() + if s.GetCurrentStep() != SpinnerStepRunSetup { + t.Errorf("expected step RunSetup, got %d", s.GetCurrentStep()) + } + + // Advance to done + s.AdvanceStep() + if !s.IsDone() { + t.Error("should be done after all steps") + } +} + +func TestWorktreeSpinnerAdvanceStepSkipsSetup(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "") + + // Advance past branch + s.AdvanceStep() + // Advance past worktree - should skip setup since no command + s.AdvanceStep() + + if !s.IsDone() { + t.Error("should be done after skipping setup step") + } +} + +func TestWorktreeSpinnerSetError(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "") + + s.SetError("branch already exists") + + if !s.HasError() { + t.Error("should have error") + } + if s.steps[0].errMsg != "branch already exists" { + t.Errorf("expected error on step 0, got %q", s.steps[0].errMsg) + } + if s.steps[0].active { + t.Error("step with error should not be active") + } +} + +func TestWorktreeSpinnerCancel(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "") + + if s.IsCancelled() { + t.Error("should not be cancelled initially") + } + + s.Cancel() + if !s.IsCancelled() { + t.Error("should be cancelled after Cancel()") + } +} + +func TestWorktreeSpinnerTick(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "") + + if s.spinnerFrame != 0 { + t.Errorf("expected initial frame 0, got %d", s.spinnerFrame) + } + + s.Tick() + if s.spinnerFrame != 1 { + t.Errorf("expected frame 1 after tick, got %d", s.spinnerFrame) + } +} + +func TestWorktreeSpinnerRender(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "npm install") + s.SetSize(80, 24) + + rendered := s.Render() + + // Should contain the title + if !strings.Contains(rendered, "Setting up worktree") { + t.Error("rendered output should contain title") + } + + // Should contain branch name + if !strings.Contains(rendered, "chief/auth") { + t.Error("rendered output should contain branch name") + } + + // Should contain worktree path + if !strings.Contains(rendered, ".chief/worktrees/auth/") { + t.Error("rendered output should contain worktree path") + } + + // Should contain setup command + if !strings.Contains(rendered, "npm install") { + t.Error("rendered output should contain setup command") + } + + // Should contain Esc hint + if !strings.Contains(rendered, "Esc") { + t.Error("rendered output should contain Esc hint") + } +} + +func TestWorktreeSpinnerRenderComplete(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "") + s.SetSize(80, 24) + + // Complete all steps + s.AdvanceStep() // branch + s.AdvanceStep() // worktree (skips setup) + + rendered := s.Render() + + // Should show "Starting loop..." + if !strings.Contains(rendered, "Starting loop...") { + t.Error("rendered done state should contain 'Starting loop...'") + } + + // Should show checkmarks + if !strings.Contains(rendered, "✓") { + t.Error("rendered done state should contain checkmarks") + } +} + +func TestWorktreeSpinnerRenderError(t *testing.T) { + s := NewWorktreeSpinner() + s.Configure("auth", "chief/auth", "main", ".chief/worktrees/auth/", "") + s.SetSize(80, 24) + + s.SetError("branch already exists") + + rendered := s.Render() + + // Should show error marker + if !strings.Contains(rendered, "✗") { + t.Error("rendered error state should contain error marker") + } + + // Should show error message + if !strings.Contains(rendered, "branch already exists") { + t.Error("rendered error state should contain error message") + } + + // Should show cleanup hint + if !strings.Contains(rendered, "clean up") { + t.Error("rendered error state should contain cleanup hint") + } +}