Skip to main content

gwm/
cli.rs

1use crate::bootstrap::{self, BootstrapCtx};
2use crate::clean;
3use crate::config::Config;
4use crate::config_cli;
5use crate::doctor::{self, CheckStatus, DoctorCtx};
6use crate::error::{GwmError, LinkKind, Result};
7use crate::exec;
8use crate::forge;
9use crate::github::{self, BranchLink, IssueState, IssueStatus, LinkSource, PrState, PrStatus};
10use crate::gitmoji;
11use crate::history::{self, OpEntry};
12use crate::hooks;
13use crate::issue_templates;
14use crate::json_api;
15use crate::labels::{self, LabelDiff};
16use crate::lifecycle::{self, HookContext, HookPhase, HookSkips};
17use crate::milestones::{self, MilestoneDiff};
18use crate::multiplexer::{
19  build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, Multiplexer, SpawnMode,
20};
21use crate::naming::{BranchSpec, WorktreeName};
22use crate::pr_templates::{self, PrTemplateContext};
23use crate::presets;
24use crate::review;
25use crate::sync::{self, SyncAction, SyncReport, SyncStrategy};
26use crate::trust::{self, TrustLedger, TrustMode, TrustOutcome};
27use crate::workspace;
28use crate::worktree;
29use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
30use clap_complete::{generate, Shell};
31use git2::Repository;
32use std::io;
33use std::path::{Path, PathBuf};
34
35#[derive(Debug, Parser)]
36#[command(name = "gwm", version, about = "git worktree manager (TUI + CLI)")]
37pub struct Cli {
38  /// Skip the TOFU trust prompt on `.gwm.toml` (issue #95).
39  ///
40  /// Equivalent to `GWM_ALLOW_BOOTSTRAP=1`. Use in non-interactive
41  /// environments (CI runners, scripted workflows) where there is no
42  /// human to answer the prompt. Off by default — the threat model is
43  /// arbitrary RCE via `[[bootstrap.command]]` lines from an untrusted
44  /// remote, so the safe default is "prompt".
45  #[arg(long, global = true)]
46  pub allow_bootstrap: bool,
47
48  /// Refuse to run `.gwm.toml` bootstrap regardless of trust state
49  /// (issue #95). Useful for forensic inspection of an unfamiliar
50  /// repo: `gwm bootstrap --deny-bootstrap` short-circuits the
51  /// execution path even if the ledger says trusted.
52  #[arg(long, global = true, conflicts_with = "allow_bootstrap")]
53  pub deny_bootstrap: bool,
54
55  /// Operate across every git repo one level below <DIR> (issue #36).
56  ///
57  /// Workspace mode is an orthogonal dimension on top of single-repo
58  /// mode: `gwm --workspace ~/Projects` opens the TUI over every
59  /// direct-child repo, and `gwm list --workspace ~/Projects` prints
60  /// the merged worktree table with a leading `REPO` column.
61  /// `.gwm.toml` stays per-repo — there is no workspace-level config.
62  /// `global = true` so the flag is accepted before or after the
63  /// subcommand.
64  #[arg(long, global = true, value_name = "DIR")]
65  pub workspace: Option<PathBuf>,
66
67  #[command(subcommand)]
68  pub command: Option<Command>,
69}
70
71/// Sub-actions of `gwm agents` (issue #408 US4). Bare `gwm agents` lists.
72#[derive(Debug, Clone, clap::Subcommand)]
73pub enum AgentsAction {
74  /// Pin session SESSION_ID to the worktree matching PATTERN. The pin
75  /// overlays auto-detection and ACCUMULATES — several sessions can be
76  /// pinned to one worktree.
77  Attach {
78    /// Worktree name (substring match), or `.` for the enclosing worktree.
79    pattern: String,
80    /// Session id as shown by `gwm agents`.
81    session_id: String,
82  },
83  /// Remove pin(s) from the worktree matching PATTERN: the one named by
84  /// SESSION_ID, or every pin when omitted.
85  Detach {
86    /// Worktree name (substring match), or `.` for the enclosing worktree.
87    pattern: String,
88    /// Specific pinned session id to remove (all pins when omitted).
89    session_id: Option<String>,
90  },
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
94pub enum AgentsFormat {
95  /// Human-readable listing (default).
96  Table,
97  /// Machine-readable JSON — the same worktree rows as
98  /// `gwm list --format=json` (experimental `agents` field included).
99  Json,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
103pub enum ListFormat {
104  /// Human-readable table (default).
105  Table,
106  /// One worktree name per line — suitable for shell completion.
107  Names,
108  /// Machine-readable JSON array of worktrees (issue #38). Stable schema
109  /// documented under `docs/schema/worktree-list.schema.json`.
110  Json,
111}
112
113/// Output format for commands that have only a human-readable text form
114/// and a machine-readable JSON form (`gwm path`, `gwm doctor` — issue #38).
115/// Distinct from [`ListFormat`], which also carries the `names` variant.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
117pub enum OutputFormat {
118  /// Human-readable text (default).
119  Text,
120  /// Machine-readable JSON. Stable schema documented under `docs/schema/`.
121  Json,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
125pub enum InitShell {
126  Bash,
127  Zsh,
128  Fish,
129  Powershell,
130}
131
132/// Target of `gwm link / unlink / open` — issue or pull request.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
134pub enum LinkTarget {
135  /// GitHub issue.
136  Issue,
137  /// GitHub pull request.
138  Pr,
139}
140
141#[derive(Debug, Subcommand)]
142pub enum Command {
143  /// Write a .gwm.toml to the current repo, optionally from a stack preset.
144  Init {
145    /// Seed an opinionated .gwm.toml for a known stack (e.g. `laravel`,
146    /// `node`/`nuxt`, `rust`, `go`, `python-uv`). Omit for the generic
147    /// documented template. Run `gwm init --list-presets` to see them all.
148    #[arg(long, value_name = "NAME")]
149    preset: Option<String>,
150    /// List the built-in presets with one-line descriptions and exit
151    /// (writes nothing, needs no git repo).
152    #[arg(long)]
153    list_presets: bool,
154    /// Print the resolved preset to stdout instead of writing .gwm.toml —
155    /// handy for diffing a preset against an existing config.
156    #[arg(long)]
157    show: bool,
158  },
159  /// List worktrees in the current repo.
160  List {
161    /// Output format. `names` prints one worktree name per line (for shell completion).
162    #[arg(long, value_enum, default_value_t = ListFormat::Table)]
163    format: ListFormat,
164    /// Add a PR column, auto-detecting each worktree's pull request via
165    /// `gh pr list --head <branch>` (issue #181). Off by default: it
166    /// makes one `gh` call per worktree, so the plain listing stays
167    /// network-free. Ignored with `--format names`.
168    #[arg(long)]
169    detect_pr: bool,
170  },
171  /// Agent sessions per worktree (issue #408): list what detection found,
172  /// or pin/unpin a session manually. Detection reads each agent's on-disk
173  /// session artefacts (Claude Code, Codex, opencode, Mistral Vibe) — a pin
174  /// overlays it for the cases the recorded directory cannot cover.
175  Agents {
176    #[command(subcommand)]
177    action: Option<AgentsAction>,
178    /// Output format for the listing (ignored by attach/detach).
179    #[arg(long, value_enum, default_value_t = AgentsFormat::Table)]
180    format: AgentsFormat,
181  },
182  /// Create a new worktree (and matching branch).
183  Create {
184    /// Branch type (feat, fix, hotfix, docs, test, refactor, chore, perf, ci, build).
185    #[arg(required_unless_present = "name", conflicts_with = "name")]
186    branch_type: Option<String>,
187    /// Issue number (digits only).
188    #[arg(required_unless_present = "name", conflicts_with = "name")]
189    issue: Option<String>,
190    /// Short description (kebab-case, will be normalized).
191    #[arg(required_unless_present = "name", conflicts_with = "name")]
192    desc: Option<String>,
193    /// Name the worktree freely instead of using the <TYPE> <ISSUE> <DESC>
194    /// triple (issue #416), e.g. `gwm create --name spike-redis`. The name
195    /// becomes the branch verbatim; `branch_pattern` / `path_pattern` do not
196    /// apply because it has no `{type}` / `{issue}` / `{desc}` to expand.
197    /// Features that read the branch name back (issue auto-linking, gitmoji)
198    /// stay inactive on it — `gwm link` remains available.
199    ///
200    /// Exclusive with the positional triple: the mode is chosen explicitly,
201    /// never inferred from how many arguments were supplied.
202    #[arg(long, value_name = "NAME", conflicts_with_all = ["branch_type", "issue", "desc"])]
203    name: Option<String>,
204    /// Skip bootstrap after creation.
205    #[arg(long)]
206    no_bootstrap: bool,
207    /// Attach the new worktree to an already-existing local branch of the
208    /// same name instead of refusing (issue #99). Off by default — a
209    /// pre-existing branch ends `gwm create` with an error naming the
210    /// stale tip so the user can audit it.
211    #[arg(long)]
212    reuse_branch: bool,
213    /// Skip lifecycle hooks for comma-separated phases (e.g. pre_create,post_create).
214    #[arg(long, value_name = "PHASES")]
215    skip_hooks: Option<String>,
216    /// In workspace mode (`--workspace <dir>`), which child repo gets the
217    /// new worktree (issue #36). Required there to disambiguate; ignored
218    /// in single-repo mode, where the worktree always lands in the
219    /// discovered repo.
220    #[arg(long, value_name = "NAME")]
221    repo: Option<String>,
222  },
223  /// Render the PR body from `[pr_template]` (issue #84), then
224  /// `gh pr create` unless `--render` is passed.
225  ///
226  /// Without `--render`, the rendered Markdown is written to a temp
227  /// file and shelled out to `gh pr create --title <subject> --body-file
228  /// <tmp> --head <branch>` (plus `--draft` and `--base` when
229  /// specified). The body resolution honours
230  /// `[pr_template.by_type.<type>]` (inline `body` wins over per-type
231  /// `path`), then `[pr_template].default` as a fallback.
232  ///
233  /// Placeholders substituted by the template engine:
234  ///   `{type}` `{issue}` `{desc}` `{base}` `{head}` `{repo}`
235  ///   `{commits}`        — `git log --pretty='- %s' base..head`
236  ///   `{files_changed}`  — `git diff --stat base..head`, capped 30 lines
237  Pr {
238    /// Render the body to stdout instead of creating the PR. The output
239    /// is suitable for piping into `gh pr create --body-file -`.
240    #[arg(long)]
241    render: bool,
242    /// Create the PR as a draft (shells out to `gh pr create --draft`).
243    /// Ignored when `--render` is set.
244    #[arg(long, conflicts_with = "render")]
245    draft: bool,
246    /// Override the base ref to compare against (defaults to the
247    /// resolved trunk from `[doctor].trunks`, then `main`).
248    #[arg(long, value_name = "REF")]
249    base: Option<String>,
250  },
251  /// Materialise an existing GitHub PR into an isolated worktree (issue #308).
252  ///
253  /// Resolves the PR head via `gh` and fetches origin's universal
254  /// `refs/pull/<N>/head` ref — cross-fork aware, and valid for PRs in any
255  /// state (open / draft / closed / merged) — into a local
256  /// `review/pr-<N>-<author>-<slug>` branch, attaches a worktree, and links
257  /// the PR so the sidebar / CI indicator light up immediately. Tear down
258  /// with `gwm remove <dir> --delete-branch` like any worktree.
259  ///
260  /// Safe-by-default: bootstrap and lifecycle hooks are NOT run, because a
261  /// review worktree holds a contributor's (possibly fork) code and those
262  /// steps execute commands against it (`npm install`, `composer install`,
263  /// `direnv allow`, `post_create` hooks …) — i.e. arbitrary code. Pass
264  /// `--bootstrap` to opt in once you trust the PR enough to set it up.
265  Review {
266    /// PR number to review (digits only).
267    #[arg()]
268    number: u64,
269    /// Override the local review branch name (defaults to
270    /// `review/pr-<N>-<author>-<slug>`). The worktree directory is derived
271    /// from this name (slashes become dashes).
272    #[arg(long, value_name = "BRANCH")]
273    name: Option<String>,
274    /// Run bootstrap + lifecycle hooks against the PR's code after creation.
275    /// Off by default — these execute commands the PR can influence, so it's
276    /// opt-in (see the command help for the security rationale).
277    #[arg(long)]
278    bootstrap: bool,
279    /// Skip lifecycle hooks for comma-separated phases (e.g. pre_create,post_create).
280    #[arg(long, value_name = "PHASES")]
281    skip_hooks: Option<String>,
282  },
283  /// Create a GitHub issue from templates, then create its worktree.
284  New {
285    /// Branch type (feat, fix, hotfix, docs, test, refactor, chore, perf, ci, build).
286    #[arg()]
287    branch_type: String,
288    /// Short description (kebab-case, will be normalized).
289    #[arg()]
290    desc: String,
291    /// Skip bootstrap after creation.
292    #[arg(long)]
293    no_bootstrap: bool,
294    /// Attach the new worktree to an already-existing local branch of the same name.
295    #[arg(long)]
296    reuse_branch: bool,
297    /// Skip lifecycle hooks for comma-separated phases (e.g. pre_create,post_create).
298    #[arg(long, value_name = "PHASES")]
299    skip_hooks: Option<String>,
300  },
301  /// Remove a worktree by fuzzy name match.
302  Remove {
303    pattern: String,
304    /// Also delete the branch.
305    #[arg(long)]
306    delete_branch: bool,
307    /// Print the resolved worktree (name + path + branch + would-delete-branch
308    /// flag) without touching anything. Exit code 0. If the pattern is
309    /// ambiguous, the same non-zero candidate-list error fires as in the
310    /// destructive form — `--dry-run` only suppresses *destruction*, not
311    /// resolution failures. Issue #31.
312    #[arg(long)]
313    dry_run: bool,
314    /// Emergency removal mode: skip pre_remove and post_remove hooks.
315    #[arg(long)]
316    force: bool,
317    /// Skip lifecycle hooks for comma-separated phases.
318    #[arg(long, value_name = "PHASES")]
319    skip_hooks: Option<String>,
320  },
321  /// Print the on-disk path of a worktree (use `$(gwm path …)` to cd into it).
322  ///
323  /// Also available as `gwm cd <pattern>` — same semantics, framed for the
324  /// cd flow. Pair with `gwm shell-init <shell>` for a one-line wrapper.
325  #[command(visible_alias = "cd")]
326  Path {
327    pattern: String,
328    /// Output format. `json` emits `{ name, path, branch }` (issue #38);
329    /// the default `text` prints the bare path for shell consumption.
330    #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
331    format: OutputFormat,
332  },
333  /// Re-run bootstrap on an existing worktree.
334  Bootstrap {
335    /// Worktree path or name; defaults to CWD.
336    target: Option<String>,
337    /// Skip lifecycle hooks for comma-separated phases.
338    #[arg(long, value_name = "PHASES")]
339    skip_hooks: Option<String>,
340  },
341  /// Fetch + rebase (or merge) a worktree's branch onto its upstream.
342  ///
343  /// Resolves the target worktree (defaults to the CWD worktree when
344  /// no pattern is given), runs `git fetch` for its upstream's remote,
345  /// then rebases the branch onto the upstream — or merges with
346  /// `--merge`. Reports the outcome with the same ✓ / ! / ✗ sigils as
347  /// the rest of gwm.
348  ///
349  /// Refuses up front on a dirty working tree (commit or stash first)
350  /// and on a branch with no upstream configured. A conflicting
351  /// rebase/merge is aborted so the worktree stays usable, and the
352  /// user is told to reconcile by hand. Issue #24.
353  Sync {
354    /// Worktree name/pattern; defaults to the worktree containing the CWD.
355    pattern: Option<String>,
356    /// Merge the upstream instead of rebasing onto it.
357    #[arg(long)]
358    merge: bool,
359  },
360  /// Prune stale worktree references (admin files without a working dir).
361  Prune {
362    /// List the prunable worktrees (name + path + reason) without
363    /// touching the admin entries. Exit code 0. Useful for piping into
364    /// a confirmation script before running the destructive form.
365    /// Issue #31.
366    #[arg(long)]
367    dry_run: bool,
368  },
369  /// Diagnose the gwm setup (config, env, worktree state).
370  ///
371  /// Exit code 0 if all green, 1 if any warning, 2 if any failure —
372  /// suitable for CI / pre-commit hooks.
373  Doctor {
374    /// Output format. `json` emits the checks array plus aggregate
375    /// `severity` / `exit_code` (issue #38); the default `text` prints
376    /// the sigil-prefixed report. The process exit code is identical
377    /// either way.
378    #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
379    format: OutputFormat,
380  },
381  /// Run a long-running JSON-RPC 2.0 daemon over a local transport.
382  ///
383  /// Editors, statusbars, and tooling connect once and call `list` /
384  /// `doctor` / `path`, or `subscribe` for pushed `worktrees.changed`
385  /// notifications — instead of spawning `gwm` per query (issue #38).
386  /// Newline-delimited JSON, one request and one response per line.
387  ///
388  /// The transport is a unix domain socket on unix and a named pipe under
389  /// `\\.\pipe\` on Windows (issue #439). Built behind the default-on
390  /// `daemon` feature; a `--no-default-features` build exits with an
391  /// explanatory error.
392  Daemon {
393    /// Where to bind. On unix: a socket path, defaulting to
394    /// `$XDG_RUNTIME_DIR/gwm.sock`, falling back to `$TMPDIR`, then
395    /// `/tmp` — isolated in a per-user `<base>/gwm-<uid>/gwm.sock` when
396    /// the base dir is not owner-only. On Windows: the pipe NAME under
397    /// `\\.\pipe\` (not a filesystem path), defaulting to
398    /// `gwm-<user>.sock`, restricted to the owner by its security
399    /// descriptor.
400    #[arg(long, value_name = "PATH")]
401    socket: Option<PathBuf>,
402    /// Worktree-state poll interval in milliseconds for `subscribe` push
403    /// notifications. Lower = faster updates, more git scans. This MVP
404    /// polls rather than watching the filesystem (no `notify` dep).
405    /// Must be ≥ 1: `0` would spin a `subscribe` loop with no wait,
406    /// re-scanning git as fast as the CPU allows (issue #38 review).
407    #[arg(long, value_name = "MS", default_value_t = 1000, value_parser = clap::value_parser!(u64).range(1..))]
408    poll_ms: u64,
409  },
410  /// Print a compact one-line worktree summary for shell prompts (issue #309).
411  ///
412  /// The first real consumer of `gwm daemon`: it connects to the daemon's
413  /// transport (unix socket, or a named pipe on Windows, issue #439), asks
414  /// for the worktree set, and renders a single line —
415  /// active branch, worktree count, dirty / ahead / behind, linked issue /
416  /// PR — suitable for a tmux / starship / zsh statusline. With `--watch`
417  /// it subscribes to the daemon's `worktrees.changed` stream and reprints
418  /// on every change (one line per update).
419  ///
420  /// Needs a running `gwm daemon` (one per repo). When none is reachable it
421  /// prints an empty line and exits 0, so a prompt substitution degrades to
422  /// nothing instead of erroring. A CI rollup is intentionally not shown —
423  /// it is not part of the daemon's stable schema.
424  Statusline {
425    /// Daemon socket path (unix) or pipe name (Windows). Defaults to the
426    /// same resolution as `gwm daemon`: `$XDG_RUNTIME_DIR/gwm.sock`, then
427    /// `$TMPDIR`, then `/tmp` — isolated in a per-user
428    /// `<base>/gwm-<uid>/gwm.sock` when the base dir isn't owner-only —
429    /// and `gwm-<user>.sock` under `\\.\pipe\` on Windows.
430    #[arg(long, value_name = "PATH")]
431    socket: Option<PathBuf>,
432    /// Stream live updates: subscribe to `worktrees.changed` and reprint
433    /// the line on every change instead of printing once and exiting.
434    #[arg(long)]
435    watch: bool,
436  },
437  /// List the supported branch types.
438  ///
439  /// Pass `--gitmoji` to extend the output with two more columns: the
440  /// resolved emoji (unicode) and its `:shortcode:` form (issue #85).
441  /// The shortcode mapping is the built-in default plus any per-repo
442  /// overrides under `[gitmoji]` in `.gwm.toml`.
443  Types {
444    /// Show the resolved emoji + shortcode for each branch type
445    /// (issue #85). Without the flag, only `name` + `description`
446    /// are printed — matches the pre-#85 surface.
447    #[arg(long)]
448    gitmoji: bool,
449  },
450  /// Print the Gitmoji + Conventional Commits prefix for the current
451  /// (or named) branch (issue #85).
452  ///
453  /// Output shape: `:sparkles: feat(#41):` — the canonical commit
454  /// prefix used across this repo (see CONTRIBUTING.md §Commits).
455  /// `--unicode` substitutes the shortcode for the real emoji
456  /// character (e.g. `✨` instead of `:sparkles:`); useful for shell
457  /// prompts and the bundled `commit-msg` hook.
458  ///
459  /// Without `--branch`, reads the current branch from HEAD via
460  /// libgit2 — requires the CWD to be inside a git repo.
461  CommitPrefix {
462    /// Branch name to resolve (e.g. `feat/#41-tui-search`). When
463    /// omitted, defaults to HEAD of the current repo.
464    #[arg(long)]
465    branch: Option<String>,
466    /// Emit the real emoji character (`✨`) instead of the
467    /// shortcode form (`:sparkles:`).
468    #[arg(long)]
469    unicode: bool,
470  },
471  /// Manage git hooks installed by `gwm` (issue #85).
472  ///
473  /// Currently exposes a single hook: `commit-msg`, which
474  /// auto-prepends the resolved Gitmoji + Conventional Commits
475  /// prefix when the commit message doesn't already start with one.
476  /// Hooks are **opt-in** — `gwm` never installs them implicitly.
477  Hooks {
478    #[command(subcommand)]
479    action: HooksAction,
480  },
481  /// Generate a shell completion script on stdout.
482  ///
483  /// Install (zsh):  `gwm completions zsh > $fpath[1]/_gwm`
484  /// Install (bash): `gwm completions bash > /etc/bash_completion.d/gwm`
485  /// Install (fish): `gwm completions fish > ~/.config/fish/completions/gwm.fish`
486  Completions {
487    /// Target shell.
488    #[arg(value_enum)]
489    shell: Shell,
490  },
491  /// Print a shell wrapper exposing `gcd <pattern>` (one-line cd into a worktree).
492  ///
493  /// Install (zsh):        `echo 'eval "$(gwm shell-init zsh)"' >> ~/.zshrc`
494  /// Install (bash):       `echo 'eval "$(gwm shell-init bash)"' >> ~/.bashrc`
495  /// Install (fish):       `gwm shell-init fish | source` (also add to config.fish)
496  /// Install (powershell): `Invoke-Expression (& gwm shell-init powershell | Out-String)`
497  ShellInit {
498    /// Target shell.
499    #[arg(value_enum)]
500    shell: InitShell,
501  },
502  /// Open an interactive picker; print the chosen worktree's path on stdout.
503  ///
504  /// Same TUI as `gwm` itself, minus the create / delete / bootstrap actions.
505  /// The fuzzy filter bar opens immediately so typing narrows the list right
506  /// away. Press Enter to commit the highlighted pick; Esc / Ctrl-C / `q`
507  /// quits without printing anything (exit code 1).
508  ///
509  /// Typically invoked via `gcd` (no arg) from the bundled `gwm shell-init`
510  /// wrapper, which cd's into the picked worktree in one keystroke. The raw
511  /// form is `cd "$(gwm switch)"` (or `gwm s`, the alias).
512  #[command(visible_alias = "s")]
513  Switch,
514  /// Open the matched worktree in a new tmux window (current session).
515  ///
516  /// Requires `$TMUX` to be set — i.e. gwm must be invoked from inside an
517  /// existing tmux session. Outside a tmux session the command exits
518  /// non-zero with a clear error rather than spawning a stray server.
519  /// Use `--split` to open in a horizontal split of the current pane
520  /// instead of a new window.
521  Tmux {
522    /// Fuzzy worktree name pattern (same matcher as `gwm path / remove`).
523    pattern: String,
524    /// Split the current pane instead of opening a new window.
525    #[arg(short = 'p', long = "split")]
526    split: bool,
527  },
528  /// Open the matched worktree in a new zellij tab (current session).
529  ///
530  /// Requires `$ZELLIJ` to be set. `--cwd` on `zellij action new-tab`
531  /// needs zellij ≥ 0.40. Use `--split` to open in a new pane of the
532  /// current tab instead of a new tab.
533  Zellij {
534    /// Fuzzy worktree name pattern (same matcher as `gwm path / remove`).
535    pattern: String,
536    /// Split the current tab into a new pane instead of opening a new tab.
537    #[arg(short = 'p', long = "split")]
538    split: bool,
539  },
540  /// Link the current (or named) worktree to a GitHub issue or pull request.
541  ///
542  /// The link is stored in `git config branch.<name>.gwm-issue` (or
543  /// `gwm-pr`) — local, per-branch, survives worktree moves. Issue
544  /// numbers are auto-detected from the `<type>/#<N>-<slug>` convention
545  /// when no explicit override is set; `gwm link issue <N>` overrides
546  /// that. PR numbers are not auto-detected; link them explicitly with
547  /// `gwm link pr <N>`.
548  Link {
549    /// What to link: `issue` or `pr`.
550    #[arg(value_enum)]
551    target: LinkTarget,
552    /// Number to link (digits only).
553    number: u64,
554    /// Optional worktree pattern; defaults to the current worktree (CWD).
555    #[arg(long)]
556    worktree: Option<String>,
557  },
558  /// Remove the explicit issue / PR link on the current (or named) worktree.
559  ///
560  /// After `gwm unlink issue`, auto-detection from the branch name
561  /// resurfaces if the branch follows `<type>/#<N>-<slug>`. Idempotent —
562  /// safe to run when nothing is linked.
563  Unlink {
564    /// What to unlink: `issue` or `pr`.
565    #[arg(value_enum)]
566    target: LinkTarget,
567    /// Optional worktree pattern; defaults to the current worktree (CWD).
568    #[arg(long)]
569    worktree: Option<String>,
570  },
571  /// Open the linked issue or PR in the browser.
572  ///
573  /// Uses the OS opener (`open` on macOS, `xdg-open` on Linux,
574  /// `explorer` on Windows). Pass `--print-url` to emit the URL on
575  /// stdout instead — useful for piping, testing, and headless shells.
576  Open {
577    /// What to open: `issue` or `pr`.
578    #[arg(value_enum)]
579    target: LinkTarget,
580    /// Optional worktree pattern; defaults to the current worktree (CWD).
581    #[arg(long)]
582    worktree: Option<String>,
583    /// Print the URL on stdout instead of spawning the browser.
584    #[arg(long)]
585    print_url: bool,
586  },
587  /// Show the issue / PR link and (when `gh` is available) live GitHub status.
588  ///
589  /// Shells out to `gh issue view` and `gh pr view` to fetch state, title,
590  /// labels, and CI rollup. Without `gh` (or outside a GitHub repo), prints
591  /// only the local link. `--json` emits a stable schema for scripting.
592  Status {
593    /// Optional worktree pattern; defaults to the current worktree (CWD).
594    #[arg(long)]
595    worktree: Option<String>,
596    /// Emit JSON instead of the human-readable summary.
597    #[arg(long)]
598    json: bool,
599  },
600  /// Manage the declarative GitHub label set from `.gwm.toml` (issue #81).
601  ///
602  /// Declares the desired label set under `[[labels]]` in `.gwm.toml`,
603  /// then pushes it to the upstream `origin` remote via `gh label
604  /// create --force`. Without a `[[labels]]` block, both subcommands
605  /// are no-ops (`0 labels declared, nothing to push`).
606  Labels {
607    #[command(subcommand)]
608    action: LabelsAction,
609  },
610  /// Manage the declarative GitHub milestone set from `.gwm.toml` (issue #82).
611  ///
612  /// Declares the desired milestone set under `[[milestones]]` in
613  /// `.gwm.toml`, then pushes it to the upstream `origin` remote via
614  /// `gh api repos/:owner/:repo/milestones` (no native `gh milestone`
615  /// subcommand exists). Without a `[[milestones]]` block, both
616  /// subcommands are no-ops (`0 milestones declared, nothing to push`).
617  Milestones {
618    #[command(subcommand)]
619    action: MilestonesAction,
620  },
621  /// Manage the TOFU trust ledger for `.gwm.toml` files (issue #95).
622  ///
623  /// `gwm` runs `[[bootstrap.command]]` lines from `.gwm.toml` under
624  /// the user's privileges — equivalent to `curl … | sh` against the
625  /// repo author. The trust ledger at `~/.config/gwm/trust.toml`
626  /// (override via `$GWM_TRUST_LEDGER`) records the `(origin URL,
627  /// sha256 of .gwm.toml)` tuples the user has approved, so
628  /// subsequent runs skip the prompt. Hash drift (any byte changes
629  /// in `.gwm.toml`) re-prompts — see the module-level comment in
630  /// `src/trust.rs` for the threat model.
631  Trust {
632    #[command(subcommand)]
633    action: TrustAction,
634  },
635  /// List the resolved CLI aliases (built-in + repo + user). Issue #86.
636  ///
637  /// `gwm aliases list` surfaces every alias reachable from `gwm
638  /// <name>`, grouped by source: `built-in` (clap `visible_alias`
639  /// set), `repo (.gwm.toml)`, `user (~/.config/gwm/aliases.toml)`.
640  /// The resolution chain favours repo aliases over user aliases when
641  /// both declare the same name, but both rows are still printed so
642  /// the user can see what's being shadowed.
643  Aliases {
644    #[command(subcommand)]
645    action: AliasesAction,
646  },
647  /// Read, edit, and validate `.gwm.toml` values (issue #89).
648  Config {
649    #[command(subcommand)]
650    action: ConfigAction,
651  },
652  /// List the recent destructive operations recorded by `gwm`
653  /// (issue #29). One line per op, newest first, with timestamp,
654  /// kind, and worktree name.
655  ///
656  /// Defaults to the current repo only — pass `--all` to list ops
657  /// across every repo in the journal. The journal file lives at
658  /// `$GWM_HISTORY_FILE` if set, otherwise
659  /// `$XDG_DATA_HOME/gwm/history.toml`.
660  History {
661    /// Maximum number of entries to print (newest first). Default 20.
662    #[arg(long, default_value_t = 20)]
663    limit: usize,
664    /// Show ops across every repo, not just the current one. Useful
665    /// for power users grepping the journal for forensic purposes.
666    #[arg(long)]
667    all: bool,
668  },
669  /// Undo the most recent destructive operation recorded for the
670  /// current repo (issue #29). Recreates the branch at the saved
671  /// OID, re-adds the worktree at the saved path, then drops the
672  /// entry from the journal.
673  ///
674  /// Pass `--bootstrap` to re-run the per-worktree bootstrap after
675  /// the resurrection (off by default — bootstrap can be expensive
676  /// and the user often just wants the directory back).
677  Undo {
678    /// Re-run bootstrap after the worktree is re-added. Off by
679    /// default to keep undo cheap.
680    #[arg(long)]
681    bootstrap: bool,
682  },
683  /// TUI introspection / debugging subcommands (issue #87).
684  ///
685  /// Today exposes a single child — `keys` — which prints the
686  /// resolved keymap (built-in defaults layered with `[tui.keys]`
687  /// overrides from `.gwm.toml`). Reserved as a sub-tree so future
688  /// TUI knobs (`gwm tui themes`, `gwm tui dump-state`, …) have a
689  /// stable home without further crowding the top-level surface.
690  Tui {
691    #[command(subcommand)]
692    action: TuiAction,
693  },
694  /// TUI theme subcommands (issue #33).
695  ///
696  /// `gwm theme list` prints the names of every built-in preset.
697  /// `gwm theme show <name>` dumps the preset as a `[theme]` TOML
698  /// block the user can paste into `.gwm.toml`.
699  Theme {
700    #[command(subcommand)]
701    action: ThemeAction,
702  },
703  /// Run a shell command in each worktree, sequentially (issue #313).
704  ///
705  /// `gwm exec -- git fetch` runs in every non-main worktree; pass slugs
706  /// before `--` to scope it: `gwm exec feat-1 fix-2 -- cargo check`.
707  /// Prints a per-worktree ✓ / ✗ rollup and exits non-zero if any
708  /// worktree's command failed. Everything after `--` is forwarded
709  /// verbatim (flags and all). This is the user's own command against
710  /// their own worktrees — no bootstrap trust gate applies (#95).
711  Exec {
712    /// Worktree slugs to target (fuzzy match, before `--`). Empty = all
713    /// non-main worktrees.
714    #[arg(value_name = "SLUG")]
715    slugs: Vec<String>,
716    /// Run a saved `[exec.profiles.<name>]` command instead of an inline
717    /// `-- <cmd>` (issue #324). Mutually exclusive with an inline command;
718    /// an unknown name exits 1.
719    #[arg(long, value_name = "NAME")]
720    profile: Option<String>,
721    /// Bounded parallelism (issue #324). `1` (default) runs sequentially with
722    /// live, inherited output; `> 1` runs up to N worktrees at once, capturing
723    /// each one's output and printing it as a block at the end. Wins over a
724    /// profile's / `[exec]`'s `jobs`.
725    #[arg(long, value_name = "N")]
726    jobs: Option<u32>,
727    /// Command to run, after `--`. Everything past `--` is forwarded
728    /// verbatim, e.g. `gwm exec -- git log --oneline`. Provide either this
729    /// or `--profile`, never both, and at least one.
730    #[arg(last = true, allow_hyphen_values = true, value_name = "CMD")]
731    command: Vec<String>,
732  },
733  /// Report (and optionally reclaim) heavy build artifacts across worktrees (issue #313).
734  ///
735  /// Scans each worktree for `target/`, `node_modules/`, `dist/`, `build/`
736  /// and prints the reclaimable size per worktree. Report-only by default;
737  /// pass `--yes` to actually delete. Scope to a subset with slug
738  /// positionals: `gwm clean feat-1`. Deliberately not journaled into
739  /// `gwm history` (#29) — the artifacts are regenerable.
740  ///
741  /// Safety: `--yes` only deletes directories git treats as ignored. A
742  /// non-ignored `dist/` / `build/` (tracked or hand-authored, hence
743  /// non-regenerable) is reported as skipped, never removed.
744  Clean {
745    /// Worktree slugs to target (fuzzy match). Empty = all non-main worktrees.
746    #[arg(value_name = "SLUG")]
747    slugs: Vec<String>,
748    /// Reclaim a saved `[clean.profiles.<name>]` directory set (a COMPLETE
749    /// set that replaces the built-ins) instead of `target`/`node_modules`/
750    /// `dist`/`build` (issue #324). An unknown name exits 1. Without it,
751    /// `[clean.profiles.default]` is used when present, else the built-ins.
752    #[arg(long, value_name = "NAME")]
753    profile: Option<String>,
754    /// Delete the listed artifacts instead of only reporting them.
755    #[arg(long)]
756    yes: bool,
757  },
758}
759
760/// Subcommands of `gwm theme` (issue #33).
761#[derive(Debug, Subcommand)]
762pub enum ThemeAction {
763  /// List the names of every built-in preset.
764  List,
765  /// Print a preset as a copy-pasteable `[theme]` TOML block.
766  Show {
767    /// Preset name (`catppuccin`, `gruvbox`, `tokyo-night`, `claude-dark`, …).
768    name: String,
769  },
770}
771
772/// Subcommands of `gwm tui` (issue #87).
773#[derive(Debug, Subcommand)]
774pub enum TuiAction {
775  /// Print the resolved TUI keymap (built-in defaults + `[tui.keys]`
776  /// overrides, with the source per row).
777  ///
778  /// Output shape:
779  /// ```text
780  /// action            keys              source
781  /// down              j, Down           default
782  /// up                Ctrl+n            .gwm.toml
783  /// top               g g               default
784  /// …
785  /// ```
786  ///
787  /// The action column lists the slugs accepted in `[tui.keys]`;
788  /// the keys column shows every chord bound to that action
789  /// (comma-separated). Empty keys = action is currently unbound
790  /// (the user explicitly cleared it).
791  Keys,
792}
793
794/// Subcommands of `gwm aliases` (issue #86). Read-only for now —
795/// declarative editing of the alias set stays in TOML files where
796/// users can grep / diff / version-control them. A future `add` /
797/// `remove` could land if real usage justifies it.
798#[derive(Debug, Subcommand)]
799pub enum AliasesAction {
800  /// Print the resolved alias chain (built-in / repo / user).
801  ///
802  /// Reads `.gwm.toml`'s `[aliases]` block (if any) and the
803  /// user-level fallback `~/.config/gwm/aliases.toml` (path resolved
804  /// via `$XDG_CONFIG_HOME` first, then `dirs::config_dir()`). The
805  /// output is grouped by source so users can audit which file
806  /// declares which mapping and where they need to edit to change
807  /// it.
808  List,
809}
810
811/// Subcommands of `gwm config` (issue #89).
812#[derive(Debug, Subcommand)]
813pub enum ConfigAction {
814  /// Print a single value resolved from `.gwm.toml` plus defaults.
815  Get {
816    /// Dot-path key, e.g. `worktree.base` or `labels[0].name`.
817    key: String,
818  },
819  /// Set a value while preserving TOML comments and formatting.
820  Set {
821    /// Dot-path key, e.g. `tui.confirm_countdown_secs`, `labels[+].name`, or `key=value`.
822    key: String,
823    /// TOML scalar value. Bare strings are accepted for convenience.
824    value: Option<String>,
825  },
826  /// Remove a value so the runtime default applies.
827  Unset {
828    /// Dot-path key to remove.
829    key: String,
830  },
831  /// List resolved config values.
832  List {
833    /// Only print keys under this dot-path prefix.
834    #[arg(long)]
835    prefix: Option<String>,
836  },
837  /// Validate `.gwm.toml` syntax and schema.
838  Validate,
839  /// Print the resolved `.gwm.toml` path.
840  Path,
841  /// Open `.gwm.toml` in `$EDITOR`.
842  Edit,
843}
844
845/// Subcommands of `gwm hooks` (issue #85). The split anticipates
846/// future hook variants (`pre-push`, `pre-commit`); for now only
847/// `install commit-msg` is wired up, which is the directly-load-bearing
848/// surface for the auto-prefix workflow.
849#[derive(Debug, Subcommand)]
850pub enum HooksAction {
851  /// Install a hook into `.git/hooks/`. Refuses to overwrite an
852  /// existing hook unless `--force` is passed, so a pre-existing
853  /// husky / commitlint / pre-commit installation is preserved by
854  /// default.
855  Install {
856    /// Which hook to install. Today only `commit-msg` is supported.
857    #[arg(value_enum)]
858    hook: HookKind,
859    /// Replace an existing hook of the same name. Without `--force`,
860    /// the command exits non-zero with the path of the conflicting
861    /// hook so the user can decide.
862    #[arg(long)]
863    force: bool,
864  },
865}
866
867/// Discriminator for `gwm hooks install <kind>`. A `ValueEnum` (rather
868/// than a free-form string) so clap rejects typos at parse time
869/// (`gwm hooks install commit-msge` → "invalid value … expected one
870/// of: commit-msg") rather than letting the installer fail with a
871/// less-actionable error.
872#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
873pub enum HookKind {
874  /// Auto-prepend the Gitmoji + Conventional Commits prefix when
875  /// the user's commit message doesn't already start with one.
876  CommitMsg,
877}
878
879/// Subcommands of `gwm labels`. The split is intentional: `list` is
880/// read-only and safe to run in CI; `push` mutates the remote and
881/// therefore gets `--dry-run` / `--prune` flags of its own.
882#[derive(Debug, Subcommand)]
883pub enum LabelsAction {
884  /// Print the declared label set plus the diff against the upstream remote.
885  ///
886  /// Each line is one of: `+ create`, `~ update (color/desc change)`,
887  /// `= match`, `- extra-on-remote`. Without a `[[labels]]` block in
888  /// `.gwm.toml`, prints `0 labels declared` and exits 0 without
889  /// shelling out to `gh`.
890  List,
891  /// Apply the diff: create new labels and update mismatched ones on
892  /// the upstream remote.
893  ///
894  /// `--dry-run` prints the plan without mutating the remote (it
895  /// still reads the remote via `gh label list` to compute the
896  /// diff; only create / update / delete calls are skipped).
897  /// `--prune` opt-in deletes labels on remote that aren't declared in
898  /// config (off by default — destructive). `--random-colors` picks a
899  /// random pastel for labels with no `color` field instead of the
900  /// default deterministic hash.
901  Push {
902    /// Print the plan without mutating the remote. Still reads remote
903    /// labels via `gh label list` to compute the diff — only the
904    /// create / update / delete calls are skipped.
905    #[arg(long)]
906    dry_run: bool,
907    /// Delete remote labels that aren't declared in `.gwm.toml`.
908    /// Destructive — off by default.
909    #[arg(long)]
910    prune: bool,
911    /// Generate a random pastel for labels with no `color` field
912    /// (overrides the default deterministic-hash colour).
913    #[arg(long)]
914    random_colors: bool,
915  },
916}
917
918/// Subcommands of `gwm trust` (issue #95). All three are read-only or
919/// purely local — no network, no git mutation — so they're safe to
920/// surface in CI as inspection helpers.
921#[derive(Debug, Subcommand)]
922pub enum TrustAction {
923  /// Approve the current repo's `.gwm.toml`, recording `(origin, hash)`
924  /// in the ledger without running anything.
925  ///
926  /// The prompt on `gwm create` / `gwm bootstrap` only fires when the
927  /// file has a bootstrap surface to run, so a `.gwm.toml` that only
928  /// names `forge` could never be approved that way — and since #419
929  /// that key decides which host receives an authenticated call
930  /// (Codex review #458). This is how you answer that question
931  /// deliberately, without executing anything.
932  ///
933  /// Approving covers the whole file as it is now: editing `.gwm.toml`
934  /// changes its hash and revokes the approval.
935  Add,
936  /// List every recorded `(origin, hash)` pair in the active ledger.
937  ///
938  /// Empty ledger prints a single line and exits 0 — the no-op fast
939  /// path for fresh installs. The `trusted_at` timestamp is the
940  /// audit anchor; revoke entries whose age looks suspicious with
941  /// `gwm trust revoke <origin>`.
942  List,
943  /// Remove every entry whose `origin` matches verbatim. After revoke,
944  /// the next `gwm create` / `gwm bootstrap` against that repo
945  /// re-prompts — use this when you change machines, rotate
946  /// credentials, or no longer trust a previously approved repo.
947  Revoke {
948    /// Origin URL to revoke (must match the recorded form verbatim —
949    /// SSH and HTTPS flavours of the same GitHub repo are recorded as
950    /// distinct entries because they ARE distinct trust paths).
951    origin: String,
952  },
953  /// Print the active ledger path and its raw TOML contents.
954  ///
955  /// Honours `$GWM_TRUST_LEDGER` if set, falls back to
956  /// `$XDG_CONFIG_HOME/gwm/trust.toml` (or the platform-specific
957  /// equivalent). Useful when triaging "why is gwm re-prompting?"
958  /// situations — eyeball the recorded hash vs. what `sha256sum
959  /// .gwm.toml` produces.
960  Show,
961}
962
963/// Subcommands of `gwm milestones`. Mirrors `LabelsAction`: `list` is
964/// read-only and safe to run in CI; `push` mutates the remote and
965/// therefore gets `--dry-run` / `--prune` flags of its own.
966#[derive(Debug, Subcommand)]
967pub enum MilestonesAction {
968  /// Print the declared milestone set plus the diff against the upstream remote.
969  ///
970  /// Each line is one of: `+ create`, `~ update (due/desc/state
971  /// change)`, `= match`, `- extra-on-remote`. Without a
972  /// `[[milestones]]` block in `.gwm.toml`, prints `0 milestones
973  /// declared` and exits 0 without shelling out to `gh`.
974  List,
975  /// Apply the diff: create new milestones and update mismatched ones
976  /// on the upstream remote.
977  ///
978  /// `--dry-run` prints the plan without mutating the remote (it
979  /// still reads the remote via `gh api …/milestones` to compute the
980  /// diff; only create / update / delete calls are skipped).
981  /// `--prune` opt-in deletes milestones on remote that aren't
982  /// declared in config (off by default — destructive).
983  Push {
984    /// Print the plan without mutating the remote. Still reads remote
985    /// milestones via `gh api` to compute the diff — only the
986    /// create / update / delete calls are skipped.
987    #[arg(long)]
988    dry_run: bool,
989    /// Delete remote milestones that aren't declared in `.gwm.toml`.
990    /// Destructive — off by default.
991    #[arg(long)]
992    prune: bool,
993  },
994}
995
996pub fn run(cli: Cli) -> Result<()> {
997  // Resolve the trust mode once at dispatch time so every handler
998  // that gates bootstrap sees the same value — CLI subcommands AND
999  // the TUI alike, both honour the same flags. `--deny-bootstrap`
1000  // wins over `--allow-bootstrap` if both are passed (clap's
1001  // `conflicts_with` already rejects this combination at parse time
1002  // — the explicit ordering inside `trust::resolve_mode` is defence
1003  // in depth).
1004  let mode = trust::resolve_mode(cli.allow_bootstrap, cli.deny_bootstrap);
1005
1006  // Without a subcommand, we hand off to the TUI — but with the
1007  // resolved mode threaded through so the TUI's bootstrap call
1008  // sites (`submit_create`, `bootstrap_selected`) take the same
1009  // trust decision as `gwm create` / `gwm bootstrap`.
1010  let Some(cmd) = cli.command else {
1011    // Explicit workspace mode (issue #36): `gwm --workspace <root>` opens the
1012    // TUI across every child repo.
1013    if let Some(root) = cli.workspace {
1014      return crate::tui::run_workspace(&root, mode);
1015    }
1016    // Auto-detect: bare `gwm` in a repo-free directory that holds child repos
1017    // offers to open it as a workspace.
1018    if let Some(root) = autodetect_workspace_prompt()? {
1019      return crate::tui::run_workspace(&root, mode);
1020    }
1021    return crate::tui::run(mode);
1022  };
1023
1024  // `--workspace` is global (clap accepts it everywhere) but only `list`,
1025  // `create`, `exec`, `clean` and the bare TUI implement it. Reject it on any
1026  // other subcommand rather than silently ignoring it and acting on the current
1027  // single repo — a wrong-target footgun for destructive commands (Codex review
1028  // #303 P2). `exec` / `clean` fan out across child repos (issue #326).
1029  if cli.workspace.is_some()
1030    && !matches!(
1031      cmd,
1032      Command::List { .. } | Command::Create { .. } | Command::Exec { .. } | Command::Clean { .. }
1033    )
1034  {
1035    return Err(GwmError::WorkspaceUnsupportedCommand);
1036  }
1037
1038  match cmd {
1039    Command::Init {
1040      preset,
1041      list_presets,
1042      show,
1043    } => cmd_init(preset, list_presets, show),
1044    Command::List { format, detect_pr } => match cli.workspace {
1045      Some(root) => cmd_list_workspace(&root, format, detect_pr),
1046      None => cmd_list(format, detect_pr),
1047    },
1048    Command::Create {
1049      branch_type,
1050      issue,
1051      desc,
1052      name,
1053      no_bootstrap,
1054      reuse_branch,
1055      skip_hooks,
1056      repo,
1057    } => {
1058      let start = match &cli.workspace {
1059        Some(root) => Some(resolve_workspace_create_repo(root, repo)?),
1060        None => None,
1061      };
1062      cmd_create(
1063        branch_type,
1064        issue,
1065        desc,
1066        name,
1067        no_bootstrap,
1068        reuse_branch,
1069        skip_hooks,
1070        mode,
1071        start.as_deref(),
1072      )
1073    }
1074    Command::New {
1075      branch_type,
1076      desc,
1077      no_bootstrap,
1078      reuse_branch,
1079      skip_hooks,
1080    } => cmd_new(branch_type, desc, no_bootstrap, reuse_branch, skip_hooks, mode),
1081    Command::Pr { render, draft, base } => cmd_pr(render, draft, base),
1082    Command::Review {
1083      number,
1084      name,
1085      bootstrap,
1086      skip_hooks,
1087    } => cmd_review(number, name, bootstrap, skip_hooks, mode),
1088    Command::Remove {
1089      pattern,
1090      delete_branch,
1091      dry_run,
1092      force,
1093      skip_hooks,
1094    } => cmd_remove(pattern, delete_branch, dry_run, force, skip_hooks, mode),
1095    Command::Path { pattern, format } => cmd_path(pattern, format),
1096    Command::Bootstrap { target, skip_hooks } => cmd_bootstrap(target, skip_hooks, mode),
1097    Command::Sync { pattern, merge } => cmd_sync(pattern, merge),
1098    Command::Prune { dry_run } => cmd_prune(dry_run),
1099    Command::Agents { action, format } => cmd_agents(action, format),
1100    Command::Doctor { format } => cmd_doctor(format),
1101    Command::Daemon { socket, poll_ms } => cmd_daemon(socket, poll_ms),
1102    Command::Statusline { socket, watch } => cmd_statusline(socket, watch),
1103    Command::Types { gitmoji } => cmd_types(gitmoji),
1104    Command::CommitPrefix { branch, unicode } => cmd_commit_prefix(branch, unicode),
1105    Command::Hooks { action } => cmd_hooks(action),
1106    Command::Completions { shell } => cmd_completions(shell),
1107    Command::ShellInit { shell } => cmd_shell_init(shell),
1108    Command::Switch => cmd_switch(),
1109    Command::Tmux { pattern, split } => cmd_multiplexer(Multiplexer::Tmux, pattern, split),
1110    Command::Zellij { pattern, split } => cmd_multiplexer(Multiplexer::Zellij, pattern, split),
1111    Command::Link {
1112      target,
1113      number,
1114      worktree,
1115    } => cmd_link(target, number, worktree),
1116    Command::Unlink { target, worktree } => cmd_unlink(target, worktree),
1117    Command::Open {
1118      target,
1119      worktree,
1120      print_url,
1121    } => cmd_open(target, worktree, print_url),
1122    Command::Status { worktree, json } => cmd_status(worktree, json),
1123    Command::Labels { action } => cmd_labels(action),
1124    Command::Milestones { action } => cmd_milestones(action),
1125    Command::Trust { action } => cmd_trust(action),
1126    Command::Aliases { action } => cmd_aliases(action),
1127    Command::Config { action } => cmd_config(action),
1128    Command::History { limit, all } => cmd_history(limit, all),
1129    Command::Undo { bootstrap } => cmd_undo(bootstrap, mode),
1130    Command::Tui { action } => cmd_tui(action),
1131    Command::Theme { action } => cmd_theme(action),
1132    Command::Exec {
1133      slugs,
1134      profile,
1135      jobs,
1136      command,
1137    } => match cli.workspace {
1138      Some(root) => cmd_exec_workspace(&root, slugs, profile, jobs, command),
1139      None => cmd_exec(slugs, profile, jobs, command),
1140    },
1141    Command::Clean { slugs, profile, yes } => match cli.workspace {
1142      Some(root) => cmd_clean_workspace(&root, slugs, profile, yes),
1143      None => cmd_clean(slugs, profile, yes),
1144    },
1145  }
1146}
1147
1148/// Resolve which worktrees `gwm exec` / `gwm clean` act on. With no slugs,
1149/// the target set is every non-main worktree (the main checkout is excluded
1150/// — running a fan-out command or deleting its `target/` is rarely intended
1151/// and matches `gwm list --format names` / `find_fuzzy`). With slugs, each is
1152/// fuzzy-resolved, surfacing the same ambiguity error as `path` / `remove`.
1153fn resolve_targets(repo: &Repository, slugs: &[String]) -> Result<Vec<worktree::WorktreeInfo>> {
1154  if slugs.is_empty() {
1155    Ok(worktree::list(repo)?.into_iter().filter(|w| !w.is_main).collect())
1156  } else {
1157    slugs.iter().map(|s| worktree::find_fuzzy(repo, s)).collect()
1158  }
1159}
1160
1161/// `gwm exec [<slug>...] -- <cmd>` (issue #313). Runs the command in each
1162/// target worktree sequentially, prints a ✓ / ✗ rollup, and exits with the
1163/// aggregate code (non-zero if any worktree failed).
1164fn cmd_exec(slugs: Vec<String>, profile: Option<String>, jobs: Option<u32>, command: Vec<String>) -> Result<()> {
1165  let repo = worktree::discover_repo(None)?;
1166  let (argv, job_count) = exec_plan(&repo, profile.as_deref(), jobs, &command)?;
1167  let targets = resolve_targets(&repo, &slugs)?;
1168  if targets.is_empty() {
1169    println!("no worktrees to run in");
1170    return Ok(());
1171  }
1172  let outcomes = exec_run(&targets, &argv, job_count, None)?;
1173  print_exec_rollup_and_exit(&outcomes)
1174}
1175
1176/// `gwm exec --workspace <root> ...` — fan out exec across the workspace's
1177/// child repos (issue #326). Every repo's argv + parallelism + targets are
1178/// resolved UPFRONT, so a missing `--profile` (or a config error) in any repo
1179/// surfaces before a single command runs. Repos then run SEQUENTIALLY
1180/// (parallelism stays bounded WITHIN a repo to avoid cross-repo output
1181/// interleaving), under a `══ <repo>` header, with a `<repo>/<worktree>`
1182/// repo-tagged rollup and an aggregated exit code.
1183fn cmd_exec_workspace(
1184  root: &Path,
1185  slugs: Vec<String>,
1186  profile: Option<String>,
1187  jobs: Option<u32>,
1188  command: Vec<String>,
1189) -> Result<()> {
1190  let opened = open_workspace_repos(root)?;
1191  let repos: Vec<&Repository> = opened.iter().map(|(_, r)| r).collect();
1192  // Resolve targets (ambiguity/typo errors surface here) AND each repo's argv +
1193  // jobs UPFRONT — before a single command runs.
1194  let targets_per_repo = resolve_workspace_targets(&repos, &slugs)?;
1195  // Resolve config/argv ONLY for repos that have targets. A repo a scoped slug
1196  // doesn't touch contributes nothing, so its `[exec]` / `--profile` must not
1197  // be resolved — an unrelated repo lacking the profile or with a bad `[exec]`
1198  // can't break a run scoped elsewhere (#326 review).
1199  let mut plans: Vec<(&str, &Vec<worktree::WorktreeInfo>, Vec<String>, usize)> = Vec::new();
1200  for ((name, repo), targets) in opened.iter().zip(&targets_per_repo) {
1201    if targets.is_empty() {
1202      continue;
1203    }
1204    let (argv, job_count) = exec_plan(repo, profile.as_deref(), jobs, &command)?;
1205    plans.push((name, targets, argv, job_count));
1206  }
1207
1208  if plans.is_empty() {
1209    // Nothing participates (every repo is main-only, or the slug scoped them all
1210    // out). No run follows, so it's safe to validate the command/profile against
1211    // the repos — a usage error (no command) or a typo'd `--profile` must still
1212    // surface instead of a silent exit 0. Accept if ANY repo resolves.
1213    let opened_repos = opened.iter().map(|(_, r)| r);
1214    if let Some(err) = first_exec_plan_error(opened_repos, profile.as_deref(), jobs, &command) {
1215      return Err(err);
1216    }
1217    println!("no worktrees to run in");
1218    return Ok(());
1219  }
1220
1221  // Run sequentially per repo, aggregating the repo-tagged outcomes.
1222  let mut all = Vec::new();
1223  for (name, targets, argv, job_count) in &plans {
1224    println!("\n══ {}", name);
1225    all.extend(exec_run(targets, argv, *job_count, Some(name))?);
1226  }
1227  print_exec_rollup_and_exit(&all)
1228}
1229
1230/// Validate the exec command/profile when NO workspace repo has targets:
1231/// returns `None` if [`exec_plan`] resolves against any repo (the source is
1232/// usable — there's just nothing to run), or the last error if it fails for
1233/// every repo (a usage error / unknown profile that must surface).
1234fn first_exec_plan_error<'a>(
1235  repos: impl Iterator<Item = &'a Repository>,
1236  profile: Option<&str>,
1237  jobs: Option<u32>,
1238  command: &[String],
1239) -> Option<GwmError> {
1240  let mut last = None;
1241  for repo in repos {
1242    match exec_plan(repo, profile, jobs, command) {
1243      Ok(_) => return None,
1244      Err(e) => last = Some(e),
1245    }
1246  }
1247  last
1248}
1249
1250/// Discover the workspace under `root` and open every child repo (erroring on
1251/// an empty workspace or an unopenable child — before any command runs).
1252/// Returns `(repo_name, Repository)` pairs in `discover` order.
1253fn open_workspace_repos(root: &Path) -> Result<Vec<(String, Repository)>> {
1254  // `workspace::discover` silently skips a child whose `Repository::open`
1255  // fails (fine for `list` / `create`), but `exec` / `clean` are destructive
1256  // and contract for upfront resolution: a child that LOOKS like a repo (has a
1257  // `.git`) but won't open must fail the whole fan-out before any side effect,
1258  // not be quietly dropped while the valid repos run (#326 review).
1259  //
1260  // Use `try_exists` and propagate read_dir / stat errors (e.g. a `.git` that
1261  // can't be statted because of permissions) rather than masking them as
1262  // "absent" — an UNREADABLE child must surface too, not be skipped (review).
1263  for entry in std::fs::read_dir(root)? {
1264    let path = entry?.path();
1265    if path.is_dir() && path.join(".git").try_exists()? && Repository::open(&path).is_err() {
1266      let name = path
1267        .file_name()
1268        .map(|n| n.to_string_lossy().to_string())
1269        .unwrap_or_default();
1270      return Err(GwmError::Other(format!(
1271        "workspace: child repo `{name}` has a `.git` but cannot be opened (corrupt or unreadable)"
1272      )));
1273    }
1274  }
1275
1276  let ws = workspace::discover(root)?;
1277  if ws.is_empty() {
1278    return Err(GwmError::EmptyWorkspace {
1279      root: root.display().to_string(),
1280    });
1281  }
1282  ws.repos
1283    .iter()
1284    .map(|r| {
1285      Repository::open(&r.path)
1286        .map(|repo| (r.name.clone(), repo))
1287        .map_err(|e| GwmError::Other(format!("workspace: cannot open repo `{}`: {e}", r.name)))
1288    })
1289    .collect()
1290}
1291
1292/// Resolve the argv to run and the parallelism for `gwm exec` against `repo`
1293/// (config load + profile/inline resolution + jobs precedence). No side
1294/// effects — shared by the single-repo and workspace paths so every repo can
1295/// be resolved upfront. See the precedence/config-loading notes inline.
1296fn exec_plan(
1297  repo: &Repository,
1298  profile: Option<&str>,
1299  jobs: Option<u32>,
1300  command: &[String],
1301) -> Result<(Vec<String>, usize)> {
1302  // Read `[exec]` only as strictly as the invocation needs (issue #324):
1303  //   - `--profile` → full `load_exec_config` (resolve + validate every
1304  //     profile); needs a workdir to locate `.gwm.toml`.
1305  //   - inline + no `--jobs` → only the `[exec] jobs` default; a bare repo (no
1306  //     workdir) skips the repo file but still honours the GLOBAL default.
1307  //   - inline + `--jobs` → the flag wins and the command is inline → no config.
1308  let exec_cfg = if profile.is_some() {
1309    let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
1310    Config::load_exec_config(workdir)?
1311  } else if jobs.is_none() {
1312    crate::config::ExecConfig {
1313      jobs: Config::load_exec_jobs_default(repo.workdir())?,
1314      ..Default::default()
1315    }
1316  } else {
1317    crate::config::ExecConfig::default()
1318  };
1319  let argv = exec::resolve_exec_command(profile, command, &exec_cfg)?;
1320  let job_count = exec::resolve_jobs(jobs, profile, &exec_cfg);
1321  Ok((argv, job_count))
1322}
1323
1324/// Run `argv` across one repo's `targets`: sequential (live inherited stdio)
1325/// when `job_count <= 1`, else bounded-parallel with per-worktree captured
1326/// blocks. `tag` (the workspace repo name) prefixes each outcome's display
1327/// name with `<repo>/` for the aggregated rollup; the per-worktree header
1328/// stays plain (it sits under the `══ <repo>` header). Returns the outcomes.
1329fn exec_run(
1330  targets: &[worktree::WorktreeInfo],
1331  argv: &[String],
1332  job_count: usize,
1333  tag: Option<&str>,
1334) -> Result<Vec<exec::ExecOutcome>> {
1335  // `exec_plan` (via `resolve_exec_command`) guarantees a non-empty argv, but
1336  // split defensively rather than indexing — a panic would be user-facing.
1337  let (program, args) = argv
1338    .split_first()
1339    .ok_or_else(|| GwmError::Other("exec: no command resolved".into()))?;
1340  let args = args.to_vec();
1341  let display = |name: &str| match tag {
1342    Some(t) => format!("{t}/{name}"),
1343    None => name.to_string(),
1344  };
1345
1346  let mut outcomes = Vec::with_capacity(targets.len());
1347  if job_count <= 1 {
1348    // Sequential: inherit the parent's stdio so output streams live, in order.
1349    for w in targets {
1350      println!("\n━━ {} ({})", w.name, w.path.display());
1351      let status = exec::exec_in_dir(&w.path, program, &args);
1352      outcomes.push(exec::ExecOutcome {
1353        name: display(&w.name),
1354        status,
1355      });
1356    }
1357  } else {
1358    // Parallel (bounded by `job_count`): capture each worktree's output so
1359    // concurrent runs don't interleave, then print one block per worktree in
1360    // worktree order once the fan-out completes. Write the captured bytes RAW
1361    // (not via `String::from_utf8_lossy`) so binary / non-UTF-8 output is
1362    // re-emitted byte-for-byte, matching the sequential path's inherited stdio.
1363    use std::io::Write;
1364    let items: Vec<(String, std::path::PathBuf)> = targets.iter().map(|w| (w.name.clone(), w.path.clone())).collect();
1365    let results = exec::run_in_dirs_parallel(job_count, &items, program, &args);
1366    let stdout = std::io::stdout();
1367    let mut lock = stdout.lock();
1368    for ((name, path), (outcome, output)) in items.iter().zip(results) {
1369      // Ignore write errors: a closed stdout (e.g. `| head`) shouldn't panic
1370      // the whole fan-out, and the rollup/exit code still report the result.
1371      let _ = writeln!(lock, "\n━━ {} ({})", name, path.display());
1372      let _ = lock.write_all(&output);
1373      outcomes.push(exec::ExecOutcome {
1374        name: display(name),
1375        status: outcome.status,
1376      });
1377    }
1378    let _ = lock.flush();
1379  }
1380  Ok(outcomes)
1381}
1382
1383/// Print the `✓ / ✗` rollup for the collected outcomes and exit with the
1384/// aggregate code (non-zero if any worktree, in any repo, failed). Shared by
1385/// the single-repo and workspace exec paths.
1386fn print_exec_rollup_and_exit(outcomes: &[exec::ExecOutcome]) -> Result<()> {
1387  println!("\nrollup:");
1388  for o in outcomes {
1389    println!("  {}", exec::format_outcome(o));
1390  }
1391  let code = exec::rollup_exit_code(outcomes);
1392  if code != 0 {
1393    std::process::exit(code);
1394  }
1395  Ok(())
1396}
1397
1398/// Resolve `slugs` against the workspace's opened `repos` for a fan-out,
1399/// returning the per-repo target lists in `repos` order.
1400///
1401/// Empty slugs ⇒ all non-main worktrees per repo. With slugs, a slug naming a
1402/// worktree in one child repo is naturally absent from the others, so a
1403/// per-repo `WorktreeNotFound` just contributes nothing THERE — but the error
1404/// distinctions the single-repo path makes are preserved: an **ambiguous**
1405/// match in any repo surfaces (propagated), and a slug that matches in **no**
1406/// repo at all is an error (a typo must not silently run/clean nothing).
1407fn resolve_workspace_targets(repos: &[&Repository], slugs: &[String]) -> Result<Vec<Vec<worktree::WorktreeInfo>>> {
1408  if slugs.is_empty() {
1409    // Propagate a per-repo listing failure (corrupt / unreadable worktree
1410    // metadata) rather than silently skipping that repo — the single-repo path
1411    // surfaces it too, and the upfront-resolution contract must not let a
1412    // destructive `clean --yes` proceed in the other repos while one is broken.
1413    return repos
1414      .iter()
1415      .map(|repo| Ok(worktree::list(repo)?.into_iter().filter(|w| !w.is_main).collect()))
1416      .collect();
1417  }
1418
1419  let mut per_repo: Vec<Vec<worktree::WorktreeInfo>> = (0..repos.len()).map(|_| Vec::new()).collect();
1420  let mut matched = vec![false; slugs.len()];
1421  for (ri, repo) in repos.iter().enumerate() {
1422    for (si, slug) in slugs.iter().enumerate() {
1423      match worktree::find_fuzzy(repo, slug) {
1424        Ok(wt) => {
1425          per_repo[ri].push(wt);
1426          matched[si] = true;
1427        }
1428        // Absent from THIS repo is normal in a fan-out — skip it.
1429        Err(GwmError::WorktreeNotFound(_)) => {}
1430        // Ambiguity (or any other resolution failure) must surface.
1431        Err(e) => return Err(e),
1432      }
1433    }
1434  }
1435  if let Some(si) = matched.iter().position(|m| !m) {
1436    return Err(GwmError::WorktreeNotFound(format!(
1437      "{} (no worktree matches it in any workspace repo)",
1438      slugs[si]
1439    )));
1440  }
1441  Ok(per_repo)
1442}
1443
1444/// `gwm clean [<slug>...] [--profile <name>] [--yes]` (issues #313, #324).
1445/// Reports reclaimable build artifacts per worktree; deletes them only when
1446/// `--yes` is passed. The directory set comes from `--profile`, else the
1447/// `default` profile, else the built-ins (see [`clean::resolve_clean_dirs`]).
1448fn cmd_clean(slugs: Vec<String>, profile: Option<String>, yes: bool) -> Result<()> {
1449  let repo = worktree::discover_repo(None)?;
1450  let targets = resolve_targets(&repo, &slugs)?;
1451  // Scan even when empty so an unknown `--profile` / malformed `[clean]` errors
1452  // before the "no worktrees" message (it loads/validates the dir set).
1453  let (reclaims, skipped) = clean_scan_repo(&repo, &targets, profile.as_deref(), None)?;
1454  if targets.is_empty() {
1455    println!("no worktrees to clean");
1456    return Ok(());
1457  }
1458  clean_finish(&reclaims, &skipped, yes)
1459}
1460
1461/// `gwm clean --workspace <root> ...` — fan out the reclaim across the
1462/// workspace's child repos (issue #326). Every repo is opened, its targets
1463/// resolved (ambiguity/typo errors surface), and its worktrees scanned UPFRONT
1464/// (so a missing `--profile` or a malformed `[clean]` in any repo errors before
1465/// a single `remove_dir_all`), then one aggregated `<repo>/<worktree>`-tagged
1466/// report drives a single `--yes` decision; a delete failure in one worktree is
1467/// reported but does not abort the rest (it surfaces in the exit code).
1468fn cmd_clean_workspace(root: &Path, slugs: Vec<String>, profile: Option<String>, yes: bool) -> Result<()> {
1469  let opened = open_workspace_repos(root)?;
1470  let repos: Vec<&Repository> = opened.iter().map(|(_, r)| r).collect();
1471  let targets_per_repo = resolve_workspace_targets(&repos, &slugs)?;
1472
1473  // Scan every repo with targets upfront — resolution (config/profile) errors
1474  // surface here, before any deletion. A repo a scoped slug doesn't touch
1475  // contributes nothing, so its `[clean]` / `--profile` is NOT resolved (an
1476  // unrelated repo's bad config can't break a run scoped elsewhere — #326
1477  // review).
1478  let mut reclaims: Vec<clean::WorktreeReclaim> = Vec::new();
1479  let mut skipped: Vec<(String, String)> = Vec::new();
1480  let mut participated = false;
1481  for ((name, repo), targets) in opened.iter().zip(&targets_per_repo) {
1482    if targets.is_empty() {
1483      continue;
1484    }
1485    participated = true;
1486    let (mut rec, mut skip) = clean_scan_repo(repo, targets, profile.as_deref(), Some(name))?;
1487    reclaims.append(&mut rec);
1488    skipped.append(&mut skip);
1489  }
1490
1491  if !participated {
1492    // Nothing participates — no deletion follows, so validate the `--profile`
1493    // (a typo / malformed `[clean]`) against the repos instead of silently
1494    // reporting "nothing to reclaim". Accept if it resolves against any repo.
1495    let mut last_err = None;
1496    let mut valid = false;
1497    for (_, repo) in &opened {
1498      match clean_scan_repo(repo, &[], profile.as_deref(), None) {
1499        Ok(_) => {
1500          valid = true;
1501          break;
1502        }
1503        Err(e) => last_err = Some(e),
1504      }
1505    }
1506    if !valid {
1507      // `last_err` is `Some` whenever the loop over `opened` ran and no repo
1508      // validated — and `open_workspace_repos` already rejects an empty
1509      // workspace with `EmptyWorkspace`, so `opened` is non-empty and the
1510      // `None` arm is unreachable. Return that same error defensively rather
1511      // than `expect`-panicking, so a future regression that lets an empty
1512      // workspace through fails loud with a `GwmError` instead of a panic
1513      // (issue #344).
1514      return Err(last_err.unwrap_or_else(|| GwmError::EmptyWorkspace {
1515        root: root.display().to_string(),
1516      }));
1517    }
1518  }
1519  clean_finish(&reclaims, &skipped, yes)
1520}
1521
1522/// One repo's clean scan: the per-worktree reclaims plus the skipped
1523/// `(display-name, rel-dir)` pairs (not git-ignored / holds tracked files).
1524type CleanScan = (Vec<clean::WorktreeReclaim>, Vec<(String, String)>);
1525
1526/// Scan `targets` (a repo's worktrees, resolved by the caller) for reclaimable
1527/// artifacts, classifying each through the safety gate. The dir-set resolution
1528/// (config load + `--profile`) happens here, so an error surfaces before the
1529/// caller deletes anything. `tag` (the workspace repo name) prefixes worktree
1530/// display names with `<repo>/` for the aggregated report. Returns the
1531/// per-worktree reclaims and the skipped `(name, rel)`s.
1532fn clean_scan_repo(
1533  repo: &Repository,
1534  targets: &[worktree::WorktreeInfo],
1535  profile: Option<&str>,
1536  tag: Option<&str>,
1537) -> Result<CleanScan> {
1538  // Load ONLY `[clean]` (tolerant of unrelated config errors, strict on
1539  // `[clean]` itself — issue #324); `None` workdir (bare repo) reads the global
1540  // section and falls back to the built-in set.
1541  let clean_cfg = Config::load_clean_config(repo.workdir())?;
1542  let patterns = clean::resolve_clean_dirs(profile, &clean_cfg)?;
1543
1544  let display = |name: &str| match tag {
1545    Some(t) => format!("{t}/{name}"),
1546    None => name.to_string(),
1547  };
1548
1549  // Classify every found artifact through the SAME safety gate the deletion
1550  // uses, BEFORE reporting — so the dry-run preview's total and promise match
1551  // what `--yes` would actually remove. A name that is not git-ignored or holds
1552  // tracked files is unrecoverable (clean is not journaled), so it is reported
1553  // as skipped rather than counted. The gate lives in `clean::scan_worktree_safe`
1554  // so the TUI clean overlay (#325) reuses the identical contract.
1555  let mut reclaims = Vec::with_capacity(targets.len());
1556  let mut skipped = Vec::new();
1557  for w in targets {
1558    let (mut reclaim, skips) = clean::scan_worktree_safe(&w.name, &w.path, &patterns);
1559    reclaim.name = display(&w.name);
1560    for rel in skips {
1561      skipped.push((display(&w.name), rel));
1562    }
1563    reclaims.push(reclaim);
1564  }
1565  Ok((reclaims, skipped))
1566}
1567
1568/// Render the aggregated reclaim report and, when `yes`, delete the artifacts.
1569/// Shared by the single-repo and workspace clean paths. A delete failure in
1570/// one worktree is reported and counted but does not abort the rest; if any
1571/// failed, the process exits non-zero.
1572fn clean_finish(reclaims: &[clean::WorktreeReclaim], skipped: &[(String, String)], yes: bool) -> Result<()> {
1573  print!("{}", clean::format_report(reclaims));
1574  for (name, rel) in skipped {
1575    println!("skipped {}/{}: not git-ignored, or holds tracked files", name, rel);
1576  }
1577
1578  let grand: u64 = reclaims.iter().map(|r| r.total_bytes).sum();
1579  if grand == 0 {
1580    println!("nothing to reclaim");
1581    return Ok(());
1582  }
1583  if !yes {
1584    println!("re-run with --yes to delete the listed artifacts");
1585    return Ok(());
1586  }
1587
1588  let mut freed = 0u64;
1589  let mut failures = 0usize;
1590  for r in reclaims {
1591    match clean::delete_reclaim(r) {
1592      Ok(b) => freed = freed.saturating_add(b),
1593      Err(e) => {
1594        eprintln!("failed to reclaim {}: {e}", r.name);
1595        failures += 1;
1596      }
1597    }
1598  }
1599  println!("reclaimed {}", clean::human_size(freed));
1600  if failures > 0 {
1601    std::process::exit(1);
1602  }
1603  Ok(())
1604}
1605
1606/// Auto-detect prompt for bare `gwm` (issue #36): when the cwd is not inside a
1607/// git repo but holds direct-child repos, ask whether to open it as a
1608/// workspace. Returns the chosen root on a yes (`Enter` / `y`), else `None` so
1609/// the caller falls through to single-repo discovery (which then surfaces
1610/// `NotInGitRepo`). Declines silently when stdin is not a terminal (pipes / CI)
1611/// so non-interactive `gwm` behaves exactly as before — never blocking on a
1612/// prompt nobody can answer.
1613fn autodetect_workspace_prompt() -> Result<Option<PathBuf>> {
1614  use std::io::{IsTerminal, Write};
1615
1616  let cwd = std::env::current_dir()?;
1617  let Some(ws) = workspace::autodetect(&cwd) else {
1618    return Ok(None);
1619  };
1620  if !io::stdin().is_terminal() {
1621    return Ok(None);
1622  }
1623  eprint!(
1624    "No git repo here. Open {} as a workspace ({} repos)? [Y/n] ",
1625    cwd.display(),
1626    ws.repos.len()
1627  );
1628  io::stderr().flush().ok();
1629  let mut answer = String::new();
1630  io::stdin().read_line(&mut answer)?;
1631  let a = answer.trim().to_ascii_lowercase();
1632  if a.is_empty() || a == "y" || a == "yes" {
1633    Ok(Some(ws.root))
1634  } else {
1635    Ok(None)
1636  }
1637}
1638
1639fn cmd_tui(action: TuiAction) -> Result<()> {
1640  match action {
1641    TuiAction::Keys => cmd_tui_keys(),
1642  }
1643}
1644
1645fn cmd_theme(action: ThemeAction) -> Result<()> {
1646  match action {
1647    ThemeAction::List => cmd_theme_list(),
1648    ThemeAction::Show { name } => cmd_theme_show(&name),
1649  }
1650}
1651
1652/// Print every built-in TUI preset name (issue #33).
1653///
1654/// Pure read against `crate::tui::theme::preset_names` so the
1655/// command works outside any repository and never needs a config
1656/// load.
1657fn cmd_theme_list() -> Result<()> {
1658  for name in crate::tui::theme::preset_names() {
1659    println!("{}", name);
1660  }
1661  Ok(())
1662}
1663
1664/// Print a preset as a copy-pasteable `[theme]` TOML block (issue #33).
1665///
1666/// Every emitted value is a **quoted TOML string** so the output
1667/// round-trips cleanly through the `[theme]` parser
1668/// (`ThemeConfig.overrides: BTreeMap<String, String>` only accepts
1669/// string values; a bare integer for `Color::Indexed` would fail to
1670/// deserialize at re-parse). `Color::Rgb` renders as `#RRGGBB`,
1671/// named colours as their canonical lowercase slug, and
1672/// `Color::Indexed(n)` as the quoted decimal `"n"` form the
1673/// `parse_color` indexed branch already accepts.
1674fn cmd_theme_show(name: &str) -> Result<()> {
1675  use crate::tui::theme::Theme;
1676  use ratatui::style::Color;
1677
1678  let theme = Theme::preset(name).ok_or_else(|| {
1679    let known = crate::tui::theme::preset_names().join(", ");
1680    GwmError::Other(format!("theme show: unknown preset {:?} (known: {})", name, known))
1681  })?;
1682  let color_str = |c: Color| -> String {
1683    match c {
1684      Color::Rgb(r, g, b) => format!("\"#{:02x}{:02x}{:02x}\"", r, g, b),
1685      Color::Reset => "\"reset\"".to_string(),
1686      Color::Black => "\"black\"".to_string(),
1687      Color::Red => "\"red\"".to_string(),
1688      Color::Green => "\"green\"".to_string(),
1689      Color::Yellow => "\"yellow\"".to_string(),
1690      Color::Blue => "\"blue\"".to_string(),
1691      Color::Magenta => "\"magenta\"".to_string(),
1692      Color::Cyan => "\"cyan\"".to_string(),
1693      Color::Gray => "\"gray\"".to_string(),
1694      Color::DarkGray => "\"dark_gray\"".to_string(),
1695      Color::LightRed => "\"bright_red\"".to_string(),
1696      Color::LightGreen => "\"bright_green\"".to_string(),
1697      Color::LightYellow => "\"bright_yellow\"".to_string(),
1698      Color::LightBlue => "\"bright_blue\"".to_string(),
1699      Color::LightMagenta => "\"bright_magenta\"".to_string(),
1700      Color::LightCyan => "\"bright_cyan\"".to_string(),
1701      Color::White => "\"white\"".to_string(),
1702      // Quote the indexed value so it round-trips through the
1703      // string-only `[theme]` map. `parse_color` accepts bare digit
1704      // strings as `Color::Indexed` (e.g. `"220"` → Color::Indexed(220)).
1705      Color::Indexed(n) => format!("\"{}\"", n),
1706    }
1707  };
1708  println!("[theme]");
1709  println!("preset       = {:?}", name);
1710  println!("focus        = {}", color_str(theme.focus));
1711  println!("accent       = {}", color_str(theme.accent));
1712  println!("branch       = {}", color_str(theme.branch));
1713  println!("clean        = {}", color_str(theme.clean));
1714  println!("dirty        = {}", color_str(theme.dirty));
1715  println!("main         = {}", color_str(theme.main));
1716  println!("locked       = {}", color_str(theme.locked));
1717  println!("prunable     = {}", color_str(theme.prunable));
1718  println!("muted        = {}", color_str(theme.muted));
1719  println!("selection_bg = {}", color_str(theme.selection_bg));
1720  println!("name         = {}", color_str(theme.name));
1721  println!("path         = {}", color_str(theme.path));
1722  println!("staged       = {}", color_str(theme.staged));
1723  println!("modified     = {}", color_str(theme.modified));
1724  println!("untracked    = {}", color_str(theme.untracked));
1725  Ok(())
1726}
1727
1728/// Print the resolved TUI keymap as a 3-column table.
1729///
1730/// Resolves `[tui.keys]` against the current repo's `.gwm.toml`
1731/// (falling back to bare defaults if there is no `.gwm.toml` and to
1732/// repo-less defaults when invoked outside any repo, so the command
1733/// is useful even before a project is set up). The output is the
1734/// human-readable side of the keymap surface — `gwm doctor` (issue
1735/// #87 part 8) consumes the same `Keymap::list()` API for its
1736/// validation pass, so the column contents stay in sync.
1737fn cmd_tui_keys() -> Result<()> {
1738  use crate::tui::keymap::{Keymap, Source};
1739  use crate::tui::modal_keymap::{KeyContext, ModalKeymap};
1740
1741  // Build the resolved keymaps. Outside a repo, OR inside a bare
1742  // repo (no workdir to read `.gwm.toml` from), fall back to
1743  // defaults so the command stays useful for new users discovering
1744  // the binary. Same fallback path either way — surfacing
1745  // `NotInGitRepo` on a bare repo would be misleading because the
1746  // command itself is repo-agnostic.
1747  let (keymap, modal) = match worktree::discover_repo(None) {
1748    Ok(repo) => match repo.workdir() {
1749      Some(workdir) => {
1750        let cfg = Config::load_for_repo(workdir)?;
1751        (cfg.tui.keys.resolved_keymap()?, cfg.tui.keys.resolved_modal_keymap()?)
1752      }
1753      None => (Keymap::defaults(), ModalKeymap::defaults()),
1754    },
1755    Err(_) => (Keymap::defaults(), ModalKeymap::defaults()),
1756  };
1757
1758  let rows = keymap.list();
1759  // Column widths sized to the longest content so the table stays
1760  // aligned even when a chord runs long (`Ctrl+Alt+Shift+F12`).
1761  let action_w = rows
1762    .iter()
1763    .map(|b| b.action.slug().len())
1764    .max()
1765    .unwrap_or(0)
1766    .max("action".len());
1767  let keys_w = rows
1768    .iter()
1769    .map(|b| {
1770      b.chords
1771        .iter()
1772        .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
1773        .collect::<Vec<_>>()
1774        .join(", ")
1775        .len()
1776    })
1777    .max()
1778    .unwrap_or(0)
1779    .max("keys".len());
1780
1781  println!("{:<aw$}  {:<kw$}  source", "action", "keys", aw = action_w, kw = keys_w);
1782  for binding in rows {
1783    let keys = binding
1784      .chords
1785      .iter()
1786      .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
1787      .collect::<Vec<_>>()
1788      .join(", ");
1789    let source = match binding.source {
1790      Source::Default => "default",
1791      Source::UserConfig => ".gwm.toml",
1792    };
1793    println!(
1794      "{:<aw$}  {:<kw$}  {}",
1795      binding.action.slug(),
1796      keys,
1797      source,
1798      aw = action_w,
1799      kw = keys_w
1800    );
1801  }
1802
1803  // Issue #219: contextual modal / overlay bindings, grouped by context.
1804  // Printed under their `[tui.keys.modal.<context>]` heading so the user can copy
1805  // a heading straight into `.gwm.toml` to start an override.
1806  let fmt_keys = |keys: &[crate::tui::keymap::KeyStroke]| -> String {
1807    keys.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(", ")
1808  };
1809  for ctx in KeyContext::all() {
1810    let bindings = modal.bindings_for(*ctx);
1811    if bindings.is_empty() {
1812      continue;
1813    }
1814    println!("\n[tui.keys.modal.{}]", ctx.config_path());
1815    let verb_w = bindings
1816      .iter()
1817      .map(|b| b.action.verb().len())
1818      .max()
1819      .unwrap_or(0)
1820      .max("verb".len());
1821    let keys_w = bindings
1822      .iter()
1823      .map(|b| fmt_keys(&b.keys).len())
1824      .max()
1825      .unwrap_or(0)
1826      .max("keys".len());
1827    println!("{:<vw$}  {:<kw$}  source", "verb", "keys", vw = verb_w, kw = keys_w);
1828    for binding in bindings {
1829      let source = match binding.source {
1830        Source::Default => "default",
1831        Source::UserConfig => ".gwm.toml",
1832      };
1833      println!(
1834        "{:<vw$}  {:<kw$}  {}",
1835        binding.action.verb(),
1836        fmt_keys(&binding.keys),
1837        source,
1838        vw = verb_w,
1839        kw = keys_w
1840      );
1841    }
1842  }
1843  Ok(())
1844}
1845
1846fn cmd_init(preset: Option<String>, list_presets: bool, show: bool) -> Result<()> {
1847  // `--list-presets` is a pure enumeration: it wins over everything and
1848  // returns before touching the filesystem or resolving a git repo.
1849  if list_presets {
1850    let name_w = presets::all().iter().map(|p| p.name.len()).max().unwrap_or(0);
1851    for p in presets::all() {
1852      let aliases = if p.aliases.is_empty() {
1853        String::new()
1854      } else {
1855        format!(" (alias: {})", p.aliases.join(", "))
1856      };
1857      println!("  {:<w$}  {}{}", p.name, p.description, aliases, w = name_w);
1858    }
1859    return Ok(());
1860  }
1861
1862  // Resolve the preset (default `generic` = the documented example).
1863  let name = preset.as_deref().unwrap_or("generic");
1864  let resolved = presets::lookup(name).ok_or_else(|| {
1865    GwmError::Config(format!(
1866      "unknown preset {name:?} — run `gwm init --list-presets` to see the built-ins"
1867    ))
1868  })?;
1869
1870  // `--show` prints the body and writes nothing, so it needs no git repo.
1871  if show {
1872    print!("{}", resolved.body);
1873    return Ok(());
1874  }
1875
1876  let repo = worktree::discover_repo(None)?;
1877  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
1878  let path = Config::write_preset(workdir, resolved.body)?;
1879  println!("wrote {} (preset: {})", path.display(), resolved.name);
1880  Ok(())
1881}
1882
1883/// `gwm agents` (issue #408 US4): list detected agent sessions per worktree,
1884/// or pin/unpin one manually. Pins live in git branch config
1885/// (`gwm-agent-pin`) and overlay auto-detection everywhere.
1886fn cmd_agents(action: Option<AgentsAction>, format: AgentsFormat) -> Result<()> {
1887  let repo = worktree::discover_repo(None)?;
1888  let trees = worktree::list(&repo)?;
1889
1890  match action {
1891    None => {
1892      let mut rows: Vec<json_api::JsonWorktree> = trees.iter().map(json_api::JsonWorktree::from).collect();
1893      let pins = json_api::agent_pins_for_rows(&repo, &trees);
1894      let reals: Vec<PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
1895      // JSON mirrors `gwm list --format=json` and has no `unmatched`
1896      // section, so it must not pay the full foreign-dir sweep the pool
1897      // costs (Codex review round U) — only the human table does.
1898      if format == AgentsFormat::Json {
1899        json_api::attach_agents(&mut rows, &reals, &pins);
1900        println!("{}", serde_json::to_string_pretty(&rows)?);
1901        return Ok(());
1902      }
1903      let pool = json_api::attach_agents_with_pool(&mut rows, &reals, &pins);
1904      // The pinned marker is scoped per (worktree, session): a session
1905      // detected on A but pinned on B is flagged only under B (Codex
1906      // review round A).
1907      let pinned_pairs: std::collections::BTreeSet<(&str, &str)> =
1908        pins.iter().map(|(path, sid)| (path.as_str(), sid.as_str())).collect();
1909      let now_epoch = std::time::SystemTime::now()
1910        .duration_since(std::time::SystemTime::UNIX_EPOCH)
1911        .map(|d| d.as_secs())
1912        .unwrap_or(0);
1913      let mut any = false;
1914      for (row, tree) in rows.iter().zip(&trees) {
1915        let Some(agents) = &row.agents else {
1916          continue;
1917        };
1918        any = true;
1919        let row_key = crate::agent_sessions::path_display_key(&tree.path);
1920        println!("{}", row.name);
1921        for s in &agents.sessions {
1922          let pin_mark = if pinned_pairs.contains(&(row_key.as_str(), s.id.as_str())) {
1923            "  pinned"
1924          } else {
1925            ""
1926          };
1927          // Relative last activity (spec US4): the human table must let old
1928          // sessions be told apart at a glance.
1929          let ago = worktree::format_relative_duration(std::time::Duration::from_secs(
1930            now_epoch.saturating_sub(s.last_activity),
1931          ));
1932          let name_part = s.name.as_deref().map(|n| format!("  {n}")).unwrap_or_default();
1933          println!(
1934            "  {:<9} {:<7} {:>4} ago  {}{}{}",
1935            s.kind, s.freshness, ago, s.id, name_part, pin_mark
1936          );
1937        }
1938      }
1939      // Sessions no worktree matched (Codex review round C): the attach
1940      // error points here for ids, so the ones worth attaching — launched
1941      // in another repo, a subdirectory, an old path — must be visible.
1942      let shown: std::collections::BTreeSet<&str> = rows
1943        .iter()
1944        .filter_map(|r| r.agents.as_ref())
1945        .flat_map(|a| a.sessions.iter().map(|s| s.id.as_str()))
1946        .collect();
1947      let mut unmatched: Vec<&crate::agent_sessions::AgentSession> =
1948        pool.iter().filter(|s| !shown.contains(s.id.as_str())).collect();
1949      unmatched.sort_by_key(|s| (s.ended, std::cmp::Reverse(s.last_activity)));
1950      if !unmatched.is_empty() {
1951        any = true;
1952        let now_sys = std::time::SystemTime::now();
1953        println!("unmatched");
1954        for s in &unmatched {
1955          let word = match crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now_sys) {
1956            crate::agent_sessions::Freshness::Active => "active",
1957            crate::agent_sessions::Freshness::Idle => "idle",
1958          };
1959          let ago = worktree::format_relative_duration(now_sys.duration_since(s.last_activity).unwrap_or_default());
1960          let name_part = s.name.as_deref().map(|n| format!("  {n}")).unwrap_or_default();
1961          println!(
1962            "  {:<9} {:<7} {:>4} ago  {}{}",
1963            s.kind.display(),
1964            word,
1965            ago,
1966            s.id,
1967            name_part
1968          );
1969        }
1970      }
1971      if !any {
1972        println!("no agent session found");
1973      }
1974      Ok(())
1975    }
1976    Some(AgentsAction::Attach { pattern, session_id }) => {
1977      let target = resolve_agents_worktree(&trees, &pattern)?;
1978      let Some(branch) = github::pinnable_branch(target.branch.as_deref()).map(str::to_string) else {
1979        return Err(GwmError::Config(format!(
1980          "worktree '{}' has no branch (detached HEAD) — a pin lives in branch config",
1981          target.name
1982        )));
1983      };
1984      // Validate the id resolves from artefacts before persisting, so a
1985      // typo fails now instead of pinning dead weight.
1986      let home = crate::agent_sessions::agents_home()
1987        .ok_or_else(|| GwmError::Config("no home directory to scan for agent sessions".into()))?;
1988      let key = crate::agent_sessions::path_display_key(&target.path);
1989      let keyed: Vec<(String, PathBuf)> = trees
1990        .iter()
1991        .map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
1992        .collect();
1993      let probe = [(key.clone(), session_id.clone())];
1994      let map = crate::agent_sessions::detect_all(&home, &keyed, &probe, std::time::SystemTime::now());
1995      let found = map
1996        .get(&key)
1997        .is_some_and(|a| a.sessions.iter().any(|s| s.id == session_id));
1998      if !found {
1999        return Err(GwmError::Config(format!(
2000          "no agent session with id '{session_id}' — run `gwm agents` to see the detected ids"
2001        )));
2002      }
2003      github::add_agent_pin(&repo, &branch, &session_id)?;
2004      println!("pinned {session_id} to {}", target.name);
2005      Ok(())
2006    }
2007    Some(AgentsAction::Detach { pattern, session_id }) => {
2008      let target = resolve_agents_worktree(&trees, &pattern)?;
2009      let Some(branch) = github::pinnable_branch(target.branch.as_deref()).map(str::to_string) else {
2010        return Err(GwmError::Config(format!(
2011          "worktree '{}' has no branch (detached HEAD) — nothing to detach",
2012          target.name
2013        )));
2014      };
2015      match session_id {
2016        Some(sid) => {
2017          if !github::remove_agent_pin(&repo, &branch, &sid)? {
2018            return Err(GwmError::Config(format!(
2019              "no pin '{sid}' on {} — run `gwm agents` to see the pinned ids",
2020              target.name
2021            )));
2022          }
2023          println!("detached {sid} from {}", target.name);
2024        }
2025        None => {
2026          github::clear_agent_pins(&repo, &branch)?;
2027          println!("detached every agent pin from {}", target.name);
2028        }
2029      }
2030      Ok(())
2031    }
2032  }
2033}
2034
2035/// Resolve `pattern` against the full worktree set with
2036/// [`worktree::find_fuzzy`]'s tiering — exact name, then exact id, then
2037/// case-insensitive substring (Codex review round K) — but unlike it the
2038/// main checkout is included (a pin on it is legitimate) and `.` selects
2039/// the worktree enclosing the cwd.
2040fn resolve_agents_worktree(trees: &[worktree::WorktreeInfo], pattern: &str) -> Result<worktree::WorktreeInfo> {
2041  if pattern == "." {
2042    let cwd = std::env::current_dir()?;
2043    let cwd = cwd.canonicalize().unwrap_or(cwd);
2044    return trees
2045      .iter()
2046      .filter(|w| {
2047        let wp = w.path.canonicalize().unwrap_or_else(|_| w.path.clone());
2048        cwd.starts_with(&wp)
2049      })
2050      .max_by_key(|w| w.path.as_os_str().len())
2051      .cloned()
2052      .ok_or_else(|| GwmError::WorktreeNotFound(".".into()));
2053  }
2054  let exact: Vec<&worktree::WorktreeInfo> = trees.iter().filter(|w| w.name == pattern).collect();
2055  match exact.as_slice() {
2056    [one] => {
2057      // find_fuzzy: a token that is one worktree's display name and
2058      // another's stable id must not silently pick the name match.
2059      if let Some(by_id) = trees.iter().find(|w| w.id == pattern && w.id != one.id) {
2060        return Err(GwmError::Other(format!(
2061          "'{}' is ambiguous: the display name of '{}' and the id of '{}'; target one by its unique id",
2062          pattern, one.id, by_id.id
2063        )));
2064      }
2065      return Ok((*one).clone());
2066    }
2067    [] => {
2068      if let Some(by_id) = trees.iter().find(|w| w.id == pattern) {
2069        return Ok(by_id.clone());
2070      }
2071    }
2072    many => {
2073      let ids = many.iter().map(|w| w.id.as_str()).collect::<Vec<_>>().join(", ");
2074      return Err(GwmError::Other(format!(
2075        "name '{pattern}' is ambiguous ({} worktrees share it); target one by id: {ids}",
2076        many.len()
2077      )));
2078    }
2079  }
2080  let pat = pattern.to_lowercase();
2081  let matches: Vec<&worktree::WorktreeInfo> = trees.iter().filter(|w| w.name.to_lowercase().contains(&pat)).collect();
2082  match matches.as_slice() {
2083    [one] => Ok((*one).clone()),
2084    [] => Err(GwmError::WorktreeNotFound(pattern.into())),
2085    many => Err(GwmError::Config(format!(
2086      "pattern '{pattern}' is ambiguous: {}",
2087      many.iter().map(|w| w.name.as_str()).collect::<Vec<_>>().join(", ")
2088    ))),
2089  }
2090}
2091
2092fn cmd_list(format: ListFormat, detect_pr: bool) -> Result<()> {
2093  let repo = worktree::discover_repo(None)?;
2094  let trees = worktree::list(&repo)?;
2095
2096  if format == ListFormat::Names {
2097    // Mirror `worktree::find_fuzzy`, which excludes the main workdir:
2098    // emitting its name here would suggest a completion candidate that
2099    // `path` / `remove` / `bootstrap` can never accept.
2100    for w in trees.iter().filter(|w| !w.is_main) {
2101      println!("{}", w.name);
2102    }
2103    return Ok(());
2104  }
2105
2106  // PR auto-detection (issue #181): off by default to keep the listing
2107  // network-free. When `--detect-pr` is set and a GitHub remote resolves,
2108  // detect each branch's PR via `gh pr list --head <branch>` — one `gh`
2109  // call per worktree. The detected number is rendered in an extra
2110  // column; an explicit `gwm link --pr` still wins via `read_link`.
2111  // Computed before the JSON branch so `--format=json --detect-pr` agrees
2112  // with the table (issue #38 review): the JSON `pr` field uses the same
2113  // detected number rather than only the persisted link.
2114  // `None` = detection did not run for that row (no GitHub slug / no
2115  // branch); `Some(inner)` = it ran and `inner` is the authoritative
2116  // result (`None` meaning "no PR", which clears a stale persisted one).
2117  // The distinction lets the JSON keep an explicit link when detection
2118  // can't run, yet clear a stale PR when it ran and found none (issue #38
2119  // review — resolves the round-4/round-5 tension on a plain `Option`).
2120  let detected_prs: Vec<Option<Option<u64>>> = if detect_pr {
2121    // `.gwm.toml` selects the forge (issue #419), so a malformed file is
2122    // surfaced rather than swallowed (Codex review #458): silently falling
2123    // back to host inference would drop a `forge = "gitlab"` a self-hosted
2124    // instance depends on, and detection could then persist a number read
2125    // from an entirely different repo.
2126    let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
2127    match forge::resolve(&repo, &config).ok() {
2128      None => vec![None; trees.len()],
2129      Some(forge) => trees
2130        .iter()
2131        .map(|w| {
2132          w.branch.as_deref().map(|branch| {
2133            github::read_link_with_pr_detection(&repo, branch, forge.as_ref())
2134              .ok()
2135              .and_then(|l| l.pr)
2136          })
2137        })
2138        .collect(),
2139    }
2140  } else {
2141    Vec::new()
2142  };
2143
2144  if format == ListFormat::Json {
2145    // Stable machine-readable array (issue #38). Includes the main
2146    // worktree — unlike `names`, a JSON consumer wants the full picture
2147    // (an editor statusbar resolves the active worktree from the set).
2148    let mut dto: Vec<json_api::JsonWorktree> = trees.iter().map(json_api::JsonWorktree::from).collect();
2149    // Agent sessions (issue #408): the shared `attach_agents` pass keeps
2150    // this surface byte-identical to the daemon's `list`.
2151    let pins = json_api::agent_pins_for_rows(&repo, &trees);
2152    let reals: Vec<PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
2153    json_api::attach_agents(&mut dto, &reals, &pins);
2154    if detect_pr {
2155      // When detection RAN for a row its result is authoritative — apply
2156      // it even when `None` (clears a stale persisted PR). When it did NOT
2157      // run, keep the explicit/persisted link `JsonWorktree::from` set.
2158      for (d, outcome) in dto.iter_mut().zip(&detected_prs) {
2159        if let Some(pr) = outcome {
2160          d.pr = *pr;
2161        }
2162      }
2163    }
2164    println!("{}", serde_json::to_string_pretty(&dto)?);
2165    return Ok(());
2166  }
2167
2168  // Dynamic widths based on observed content.
2169  let name_w = trees.iter().map(|w| w.name.len()).max().unwrap_or(4).clamp(4, 40);
2170  let branch_w = trees
2171    .iter()
2172    .map(|w| w.branch.as_deref().unwrap_or("-").len())
2173    .max()
2174    .unwrap_or(6)
2175    .clamp(6, 40);
2176  let status_w = 14;
2177  let pr_w = 6;
2178  // AGENT column (issue #408): the same compact indicator as the TUI table —
2179  // the most recently active agent per worktree, or `-`. Sized to the
2180  // longest agent name ("opencode").
2181  let agent_w = 8;
2182  let agent_cells: Vec<String> = {
2183    let mut cells = vec!["-".to_string(); trees.len()];
2184    if let Some(home) = crate::agent_sessions::agents_home() {
2185      let keyed: Vec<(String, PathBuf)> = trees
2186        .iter()
2187        .map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
2188        .collect();
2189      let pins: Vec<(String, String)> = trees
2190        .iter()
2191        .flat_map(|w| {
2192          let pins = github::pinnable_branch(w.branch.as_deref())
2193            .map(|b| github::agent_pins(&repo, b).unwrap_or_default())
2194            .unwrap_or_default();
2195          let path = crate::agent_sessions::path_display_key(&w.path);
2196          pins.into_iter().map(move |sid| (path.clone(), sid))
2197        })
2198        .collect();
2199      let map = crate::agent_sessions::detect_all(&home, &keyed, &pins, std::time::SystemTime::now());
2200      for (i, w) in trees.iter().enumerate() {
2201        if let Some(top) = map
2202          .get(&crate::agent_sessions::path_display_key(&w.path))
2203          .and_then(|a| a.top())
2204        {
2205          cells[i] = top.kind.display().to_string();
2206        }
2207      }
2208    }
2209    cells
2210  };
2211  // Column shown only when a session was detected (Codex review round D):
2212  // a no-agent setup keeps the exact pre-#408 table layout. The pre-padded
2213  // fragment (cell + separator, or nothing) keeps one format string per row.
2214  let show_agent = agent_cells.iter().any(|c| c != "-");
2215  let agent_col = |cell: &str| {
2216    if show_agent {
2217      format!("{cell:<agent_w$}  ")
2218    } else {
2219      String::new()
2220    }
2221  };
2222
2223  if detect_pr {
2224    println!(
2225      "  {:<nw$}  {:<bw$}  {:<sw$}  {:<pw$}  {}PATH",
2226      "NAME",
2227      "BRANCH",
2228      "STATUS",
2229      "PR",
2230      agent_col("AGENT"),
2231      nw = name_w,
2232      bw = branch_w,
2233      sw = status_w,
2234      pw = pr_w,
2235    );
2236  } else {
2237    println!(
2238      "  {:<nw$}  {:<bw$}  {:<sw$}  {}PATH",
2239      "NAME",
2240      "BRANCH",
2241      "STATUS",
2242      agent_col("AGENT"),
2243      nw = name_w,
2244      bw = branch_w,
2245      sw = status_w,
2246    );
2247  }
2248  for (i, w) in trees.iter().enumerate() {
2249    let mark = if w.is_main { "*" } else { " " };
2250    let branch = w.branch.clone().unwrap_or_else(|| "-".into());
2251    let status = format_status_text(w);
2252    if detect_pr {
2253      // Outer `Option` = detection ran?; inner = the PR number. Flatten
2254      // both for display (didn't-run and ran-without-PR both render `-`).
2255      let pr = detected_prs.get(i).copied().flatten().flatten();
2256      let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_else(|| "-".into());
2257      println!(
2258        "{} {:<nw$}  {:<bw$}  {:<sw$}  {:<pw$}  {}{}",
2259        mark,
2260        w.name,
2261        branch,
2262        status,
2263        pr_cell,
2264        agent_col(&agent_cells[i]),
2265        w.path.display(),
2266        nw = name_w,
2267        bw = branch_w,
2268        sw = status_w,
2269        pw = pr_w,
2270      );
2271    } else {
2272      println!(
2273        "{} {:<nw$}  {:<bw$}  {:<sw$}  {}{}",
2274        mark,
2275        w.name,
2276        branch,
2277        status,
2278        agent_col(&agent_cells[i]),
2279        w.path.display(),
2280        nw = name_w,
2281        bw = branch_w,
2282        sw = status_w,
2283      );
2284    }
2285  }
2286  Ok(())
2287}
2288
2289/// `gwm list --workspace <root>`: the merged, repo-tagged table across every
2290/// git repo one level below `root` (issue #36). Mirrors [`cmd_list`]'s columns
2291/// but prepends a `REPO` column; `--detect-pr` is honoured per row against the
2292/// owning repo. `--format names` qualifies each worktree as `<repo>/<name>`
2293/// (including the main worktree, which in workspace mode is the primary `cd`
2294/// target) so a completion candidate is unambiguous across repos.
2295fn cmd_list_workspace(root: &Path, format: ListFormat, detect_pr: bool) -> Result<()> {
2296  let ws = workspace::discover(root)?;
2297  if ws.is_empty() {
2298    return Err(GwmError::EmptyWorkspace {
2299      root: root.display().to_string(),
2300    });
2301  }
2302  let rows = workspace::merge_worktrees(&ws)?;
2303
2304  if format == ListFormat::Names {
2305    for row in &rows {
2306      println!("{}/{}", row.repo_name, row.info.name);
2307    }
2308    return Ok(());
2309  }
2310
2311  // PR auto-detection (issue #181) resolved per row against its own repo.
2312  // `None` = detection did not run for that row (repo unopenable / no
2313  // branch / no slug); `Some(inner)` = it ran (`inner` is the result,
2314  // `None` clearing a stale PR). Same ran-vs-not distinction as the
2315  // single-repo path (issue #38 review). Computed before the JSON branch
2316  // so `--format=json --detect-pr` agrees with the table.
2317  let detected_prs: Vec<Option<Option<u64>>> = if detect_pr {
2318    rows
2319      .iter()
2320      .map(|row| {
2321        let repo = Repository::open(&row.repo_path).ok()?;
2322        let branch = row.info.branch.as_deref()?;
2323        // Each repo in a workspace picks its own forge: one may be on
2324        // GitHub and the next on a self-hosted GitLab, so the `forge` key
2325        // is read from that repo's own `.gwm.toml` (issue #419). A
2326        // malformed config makes the row's forge *unknown*, so detection
2327        // is skipped for it (`None` = "did not run") rather than guessed
2328        // from the host — a wrong guess would persist a number from
2329        // another repo (Codex review #458). One bad child config still
2330        // must not abort the whole workspace listing, hence skip-not-fail.
2331        let config = Config::load_for_repo(&row.repo_path).ok()?;
2332        let forge = forge::resolve(&repo, &config).ok()?;
2333        Some(
2334          github::read_link_with_pr_detection(&repo, branch, forge.as_ref())
2335            .ok()
2336            .and_then(|l| l.pr),
2337        )
2338      })
2339      .collect()
2340  } else {
2341    Vec::new()
2342  };
2343
2344  // Pins live in each row's OWNING repo branch config — open it per row
2345  // (`agent_pins_for_rows` is single-repo; a workspace spans several). A
2346  // session pinned in a child repo must survive on every workspace surface
2347  // (Codex review round I).
2348  let agent_pins: Vec<(String, String)> = rows
2349    .iter()
2350    .filter_map(|row| {
2351      let repo = Repository::open(&row.repo_path).ok()?;
2352      let branch = github::pinnable_branch(row.info.branch.as_deref())?;
2353      let pins = github::agent_pins(&repo, branch).ok()?;
2354      let path = crate::agent_sessions::path_display_key(&row.info.path);
2355      Some(pins.into_iter().map(move |sid| (path.clone(), sid)).collect::<Vec<_>>())
2356    })
2357    .flatten()
2358    .collect();
2359
2360  if format == ListFormat::Json {
2361    // Workspace JSON tags each worktree with its owning `repo` so a
2362    // cross-repo consumer can disambiguate (issue #36 + #38).
2363    #[derive(serde::Serialize)]
2364    struct WorkspaceJsonWorktree<'a> {
2365      repo: &'a str,
2366      #[serde(flatten)]
2367      worktree: json_api::JsonWorktree,
2368    }
2369    let mut worktree_rows: Vec<json_api::JsonWorktree> = rows
2370      .iter()
2371      .enumerate()
2372      .map(|(i, row)| {
2373        let mut worktree = json_api::JsonWorktree::from(&row.info);
2374        // When detection ran for this row its result is authoritative
2375        // (applied even when `None`, clearing a stale PR); when it did not
2376        // run, keep the link `JsonWorktree::from` set (issue #38 review).
2377        if let Some(pr) = detected_prs.get(i).copied().flatten() {
2378          worktree.pr = pr;
2379        }
2380        worktree
2381      })
2382      .collect();
2383    // Issue #408: same shared agents pass as single-repo list / daemon,
2384    // with each row's own repo pins overlaid (round I).
2385    let reals: Vec<PathBuf> = rows.iter().map(|r| r.info.path.clone()).collect();
2386    json_api::attach_agents(&mut worktree_rows, &reals, &agent_pins);
2387    let dto: Vec<WorkspaceJsonWorktree> = rows
2388      .iter()
2389      .zip(worktree_rows)
2390      .map(|(row, worktree)| WorkspaceJsonWorktree {
2391        repo: &row.repo_name,
2392        worktree,
2393      })
2394      .collect();
2395    println!("{}", serde_json::to_string_pretty(&dto)?);
2396    return Ok(());
2397  }
2398
2399  let repo_w = rows.iter().map(|r| r.repo_name.len()).max().unwrap_or(4).clamp(4, 30);
2400  // AGENT parity with the single-repo table (Codex review round A): one
2401  // detection pass over the merged rows, each row's own repo pins overlaid
2402  // (round I).
2403  let agent_w = 8;
2404  let agent_cells: Vec<String> = {
2405    let mut cells = vec!["-".to_string(); rows.len()];
2406    if let Some(home) = crate::agent_sessions::agents_home() {
2407      let keyed: Vec<(String, PathBuf)> = rows
2408        .iter()
2409        .map(|r| {
2410          (
2411            crate::agent_sessions::path_display_key(&r.info.path),
2412            r.info.path.clone(),
2413          )
2414        })
2415        .collect();
2416      let map = crate::agent_sessions::detect_all(&home, &keyed, &agent_pins, std::time::SystemTime::now());
2417      for (i, r) in rows.iter().enumerate() {
2418        if let Some(top) = map
2419          .get(&crate::agent_sessions::path_display_key(&r.info.path))
2420          .and_then(|a| a.top())
2421        {
2422          cells[i] = top.kind.display().to_string();
2423        }
2424      }
2425    }
2426    cells
2427  };
2428  // Same conditional column as the single-repo table (round D).
2429  let show_agent = agent_cells.iter().any(|c| c != "-");
2430  let agent_col = |cell: &str| {
2431    if show_agent {
2432      format!("{cell:<agent_w$}  ")
2433    } else {
2434      String::new()
2435    }
2436  };
2437  let name_w = rows.iter().map(|r| r.info.name.len()).max().unwrap_or(4).clamp(4, 40);
2438  let branch_w = rows
2439    .iter()
2440    .map(|r| r.info.branch.as_deref().unwrap_or("-").len())
2441    .max()
2442    .unwrap_or(6)
2443    .clamp(6, 40);
2444  let status_w = 14;
2445  let pr_w = 6;
2446
2447  if detect_pr {
2448    println!(
2449      "  {:<rw$}  {:<nw$}  {:<bw$}  {:<sw$}  {:<pw$}  {}PATH",
2450      "REPO",
2451      "NAME",
2452      "BRANCH",
2453      "STATUS",
2454      "PR",
2455      agent_col("AGENT"),
2456      rw = repo_w,
2457      nw = name_w,
2458      bw = branch_w,
2459      sw = status_w,
2460      pw = pr_w,
2461    );
2462  } else {
2463    println!(
2464      "  {:<rw$}  {:<nw$}  {:<bw$}  {:<sw$}  {}PATH",
2465      "REPO",
2466      "NAME",
2467      "BRANCH",
2468      "STATUS",
2469      agent_col("AGENT"),
2470      rw = repo_w,
2471      nw = name_w,
2472      bw = branch_w,
2473      sw = status_w,
2474    );
2475  }
2476  for (i, row) in rows.iter().enumerate() {
2477    let w = &row.info;
2478    let mark = if w.is_main { "*" } else { " " };
2479    let branch = w.branch.clone().unwrap_or_else(|| "-".into());
2480    let status = format_status_text(w);
2481    if detect_pr {
2482      // Flatten both the ran?-Option and the PR-Option for display.
2483      let pr = detected_prs.get(i).copied().flatten().flatten();
2484      let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_else(|| "-".into());
2485      println!(
2486        "{} {:<rw$}  {:<nw$}  {:<bw$}  {:<sw$}  {:<pw$}  {}{}",
2487        mark,
2488        row.repo_name,
2489        w.name,
2490        branch,
2491        status,
2492        pr_cell,
2493        agent_col(&agent_cells[i]),
2494        w.path.display(),
2495        rw = repo_w,
2496        nw = name_w,
2497        bw = branch_w,
2498        sw = status_w,
2499        pw = pr_w,
2500      );
2501    } else {
2502      println!(
2503        "{} {:<rw$}  {:<nw$}  {:<bw$}  {:<sw$}  {}{}",
2504        mark,
2505        row.repo_name,
2506        w.name,
2507        branch,
2508        status,
2509        agent_col(&agent_cells[i]),
2510        w.path.display(),
2511        rw = repo_w,
2512        nw = name_w,
2513        bw = branch_w,
2514        sw = status_w,
2515      );
2516    }
2517  }
2518  Ok(())
2519}
2520
2521fn format_status_text(w: &worktree::WorktreeInfo) -> String {
2522  if w.is_prunable {
2523    return "prunable".into();
2524  }
2525  if w.is_locked {
2526    return "locked".into();
2527  }
2528  let s = &w.status;
2529  if s.unknown {
2530    return "unknown".into();
2531  }
2532  let mut parts: Vec<String> = Vec::new();
2533  if s.is_dirty {
2534    parts.push("● dirty".into());
2535  }
2536  if s.has_upstream {
2537    if s.ahead > 0 {
2538      parts.push(format!("↑{}", s.ahead));
2539    }
2540    if s.behind > 0 {
2541      parts.push(format!("↓{}", s.behind));
2542    }
2543    if !s.is_dirty && s.synced() {
2544      parts.push("✓ synced".into());
2545    }
2546  } else if !s.is_dirty {
2547    parts.push("clean".into());
2548  }
2549  parts.join(" ")
2550}
2551
2552/// The repo prelude shared by most CLI subcommands: an open
2553/// [`Repository`], its working directory, and the resolved
2554/// [`Config`]. Owned values so call sites keep borrowing `&repo`,
2555/// `&workdir`, `&config` exactly as they did when the triplet was
2556/// inlined.
2557pub struct RepoContext {
2558  pub repo: Repository,
2559  pub workdir: PathBuf,
2560  pub config: Config,
2561}
2562
2563/// Discover the repo, resolve its workdir, and load `.gwm.toml`.
2564///
2565/// `start` mirrors [`worktree::discover_repo`]: `None` discovers from
2566/// the current directory (the behaviour every CLI call site relies
2567/// on), `Some(path)` discovers from an explicit root (used by tests).
2568///
2569/// Surfaces [`GwmError::NotInGitRepo`] outside a repo or in a bare
2570/// repo (no workdir), and propagates any `.gwm.toml` parse error from
2571/// [`Config::load_for_repo`].
2572pub fn repo_context(start: Option<&Path>) -> Result<RepoContext> {
2573  let repo = worktree::discover_repo(start)?;
2574  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2575  let config = Config::load_for_repo(&workdir)?;
2576  Ok(RepoContext { repo, workdir, config })
2577}
2578
2579/// Like [`repo_context`], but tolerates a missing or malformed
2580/// `.gwm.toml` by falling back to [`Config::default`]. The repo and
2581/// workdir gates stay strict — only the config *load* is lenient.
2582/// Used by `gwm doctor`, which must run even when the config it is
2583/// about to diagnose is broken.
2584pub fn repo_context_lenient(start: Option<&Path>) -> Result<RepoContext> {
2585  let repo = worktree::discover_repo(start)?;
2586  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
2587  let config = Config::load_for_repo(&workdir).unwrap_or_default();
2588  Ok(RepoContext { repo, workdir, config })
2589}
2590
2591/// Resolve which child repo a workspace-mode `gwm create` targets (issue #36).
2592/// `--repo` is required there to disambiguate; an absent flag lists the
2593/// candidates, an unknown name lists them too. Returns the chosen repo's path
2594/// so [`cmd_create`] can discover from it instead of the current directory.
2595fn resolve_workspace_create_repo(root: &Path, repo: Option<String>) -> Result<PathBuf> {
2596  let ws = workspace::discover(root)?;
2597  if ws.is_empty() {
2598    return Err(GwmError::EmptyWorkspace {
2599      root: root.display().to_string(),
2600    });
2601  }
2602  let available = ws.repos.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ");
2603  let name = repo.ok_or_else(|| GwmError::WorkspaceRepoRequired {
2604    available: available.clone(),
2605  })?;
2606  ws.repos
2607    .iter()
2608    .find(|r| r.name == name)
2609    .map(|r| r.path.clone())
2610    .ok_or(GwmError::WorkspaceRepoNotFound { name, available })
2611}
2612
2613// `cmd_create` mirrors the `Create` subcommand's independent CLI args 1:1
2614// (three positionals + three flags + the resolved trust mode), and #36 adds
2615// the workspace `start` path. Bundling them into a struct would only add an
2616// indirection that obscures the direct subcommand → handler mapping the rest
2617// of this dispatcher follows, so the arg count is deliberate here.
2618#[allow(clippy::too_many_arguments)]
2619fn cmd_create(
2620  branch_type: Option<String>,
2621  issue: Option<String>,
2622  desc: Option<String>,
2623  name: Option<String>,
2624  no_bootstrap: bool,
2625  reuse_branch: bool,
2626  skip_hooks: Option<String>,
2627  trust_mode: TrustMode,
2628  start: Option<&Path>,
2629) -> Result<()> {
2630  let RepoContext { repo, workdir, config } = repo_context(start)?;
2631  let repo_name = worktree::repo_name(&repo);
2632
2633  // clap guarantees exactly one of the two shapes reaches here:
2634  // `--name` conflicts with all three positionals, and each positional is
2635  // `required_unless_present = "name"`, so a partial triple is rejected
2636  // before dispatch rather than silently read as a free-form request.
2637  let wt_name = match name {
2638    Some(name) => WorktreeName::freeform(&name)?,
2639    None => {
2640      let resolved_types = config.resolved_branch_types();
2641      let (branch_type, issue, desc) = match (branch_type, issue, desc) {
2642        (Some(t), Some(i), Some(d)) => (t, i, d),
2643        _ => {
2644          return Err(GwmError::Other(
2645            "`gwm create` needs <TYPE> <ISSUE> <DESC> or --name".into(),
2646          ))
2647        }
2648      };
2649      WorktreeName::Structured(BranchSpec::new_with_types(
2650        branch_type,
2651        issue,
2652        desc,
2653        &resolved_types.types,
2654      )?)
2655    }
2656  };
2657
2658  let branch = wt_name.branch_name(&config.worktree, &repo_name)?;
2659  let dirname = wt_name.worktree_dirname(&config.worktree, &repo_name)?;
2660  let target = wt_name.worktree_path(&config.worktree, &repo_name, &workdir)?;
2661  let skips = HookSkips::parse(skip_hooks.as_deref())?;
2662
2663  // Gate the bootstrap RCE primitive on the TOFU ledger BEFORE
2664  // creating the worktree — a deny / abort here leaves the user's
2665  // disk state untouched (no orphaned worktree to clean up).
2666  let create_hooks_present = !config.hooks.pre_create.is_empty()
2667    || !config.hooks.post_create.is_empty()
2668    || (!no_bootstrap && !config.bootstrap.command.is_empty());
2669  if !no_bootstrap || create_hooks_present {
2670    trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
2671  }
2672
2673  // `for_worktree` derives the context by re-parsing the branch, exactly as
2674  // a later `gwm remove` on the same worktree would, so the two phases
2675  // agree. Note what that means: the placeholders resolve empty only when
2676  // the name does not match the branch convention. `--name 'feat/#42-x'`
2677  // parses, so its context populates — and that is right, because nothing
2678  // downstream knows how a worktree was named, only what its branch is
2679  // (Codex review on PR #474).
2680  let pre_ctx = match &wt_name {
2681    WorktreeName::Structured(spec) => HookContext::for_create(&repo, &workdir, &workdir, &target, &branch, spec),
2682    WorktreeName::Freeform(_) => HookContext::for_worktree(&repo, &workdir, &workdir, &target, Some(&branch)),
2683  };
2684  let report = lifecycle::run_phase(&config, HookPhase::PreCreate, &pre_ctx, &skips, false)?;
2685  print_lifecycle_report(&report);
2686
2687  println!("creating worktree:");
2688  println!("  branch : {}", branch);
2689  println!("  dir    : {}", dirname);
2690  println!("  path   : {}", target.display());
2691
2692  let created = worktree::add(&repo, &dirname, &target, &branch, reuse_branch)?;
2693  println!("✓ worktree created at {}", created.display());
2694
2695  let post_ctx = pre_ctx.with_cwd(&created);
2696
2697  if no_bootstrap {
2698    println!("(skipped bootstrap)");
2699  } else {
2700    let report = lifecycle::run_phase(&config, HookPhase::PreBootstrap, &post_ctx, &skips, false)?;
2701    print_lifecycle_report(&report);
2702
2703    let ctx = BootstrapCtx {
2704      main_repo: &workdir,
2705      worktree: &created,
2706      config: &config,
2707    };
2708    let report = bootstrap::run_core(&ctx)?;
2709    print_report(&report);
2710
2711    let report = lifecycle::run_phase(&config, HookPhase::PostBootstrap, &post_ctx, &skips, false)?;
2712    print_lifecycle_report(&report);
2713  }
2714
2715  if config.hooks.has_any() && !config.bootstrap.command.is_empty() {
2716    eprintln!("warning: [[bootstrap.command]] is deprecated as a post_create hook when [hooks.*] is present");
2717  }
2718  let report = lifecycle::run_phase(&config, HookPhase::PostCreate, &post_ctx, &skips, !no_bootstrap)?;
2719  print_lifecycle_report(&report);
2720  Ok(())
2721}
2722
2723/// `gwm review <PR#>` (issue #308) — the inbound counterpart to
2724/// `cmd_create`. Resolves the PR head via `gh`, materialises a worktree on
2725/// origin's `refs/pull/<N>/head` ref (see [`crate::review`]), and links the
2726/// PR. Setup (bootstrap + lifecycle hooks) is **opt-in** via `--bootstrap`:
2727/// the worktree holds a contributor's possibly-untrusted code and those
2728/// steps run commands against it, so review is safe-by-default (see
2729/// [`review::run_post_setup`] for the threat model).
2730fn cmd_review(
2731  number: u64,
2732  name: Option<String>,
2733  bootstrap: bool,
2734  skip_hooks: Option<String>,
2735  trust_mode: TrustMode,
2736) -> Result<()> {
2737  let RepoContext { repo, workdir, config } = repo_context(None)?;
2738  let repo_name = worktree::repo_name(&repo);
2739  let forge = forge::resolve(&repo, &config)?;
2740
2741  println!("resolving {} #{number} on {} …", forge.pr_noun(), forge.slug());
2742  let head = forge.fetch_pr_head(number)?;
2743  let slug = review::head_slug(&head.head_ref_name);
2744
2745  let branch = name
2746    .clone()
2747    .unwrap_or_else(|| review::review_branch_name(number, &head.author, &slug));
2748  let dirname = match &name {
2749    Some(n) => review::dirname_from_branch(n),
2750    None => review::review_dirname(number, &head.author, &slug),
2751  };
2752  // Land the review worktree under the same `base` as every other
2753  // worktree so `gwm list` / the TUI pick it up. The synthetic
2754  // type/issue/desc feed any `{type}`/`{issue}`/`{desc}` placeholders a
2755  // custom base might carry.
2756  let base = crate::config::expand_placeholders(
2757    &config.worktree.base,
2758    &repo_name,
2759    Some("review"),
2760    Some(&number.to_string()),
2761    Some(&slug),
2762    Some(&workdir),
2763  )?;
2764  let target = PathBuf::from(base).join(&dirname);
2765  let skips = HookSkips::parse(skip_hooks.as_deref())?;
2766
2767  // A `review/…` branch carries no BranchSpec of its own; synthesize one
2768  // (bypassing the type validation that would reject `review`) purely to
2769  // drive the hook placeholders, so the hooks see the same
2770  // `{type}`/`{issue}`/`{desc}` surface they do under `gwm create`.
2771  let spec = BranchSpec {
2772    type_: "review".to_string(),
2773    issue: number.to_string(),
2774    desc: slug.clone(),
2775  };
2776  let pre_ctx = HookContext::for_create(&repo, &workdir, &workdir, &target, &branch, &spec);
2777
2778  // Setup runs arbitrary commands against the PR's code, so it is opt-in.
2779  // Only when `--bootstrap` is passed do we gate the RCE primitives on the
2780  // TOFU ledger and run `pre_create` before materialising.
2781  if bootstrap {
2782    trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
2783    let report = lifecycle::run_phase(&config, HookPhase::PreCreate, &pre_ctx, &skips, false)?;
2784    print_lifecycle_report(&report);
2785  }
2786
2787  println!("creating review worktree:");
2788  println!(
2789    "  PR     : #{number} by {} ({} → {})",
2790    head.author, head.head_ref_name, head.base_ref_name
2791  );
2792  println!("  branch : {branch}");
2793  println!("  dir    : {dirname}");
2794  println!("  path   : {}", target.display());
2795
2796  // Record `origin/<base>` (a remote-tracking ref) as the diff base, not the
2797  // bare local `<base>` — a review-only checkout may have a stale or absent
2798  // local base branch, and the `R` launcher passes the recorded value
2799  // straight to `git diff`/`git rev-list`, where a missing ref reads as zero
2800  // commits ("no changes" against a stale base). Fetch with an *explicit*,
2801  // *forced* `+refs/heads/<base>:refs/remotes/origin/<base>` refspec: explicit
2802  // so the tracking ref is actually written (a bare `git fetch origin <base>`
2803  // only updates `FETCH_HEAD` unless the remote's configured refspec covers
2804  // it), and `+`-forced so a rebased/force-pushed base still updates instead
2805  // of failing the non-fast-forward — matching git's own default
2806  // `+refs/heads/*:refs/remotes/origin/*` mirror for tracking refs. Best-
2807  // effort, since the head fetch in `materialize` is the load-bearing one.
2808  let base_ref = (!head.base_ref_name.is_empty()).then(|| {
2809    let refspec = format!("+refs/heads/{0}:refs/remotes/origin/{0}", head.base_ref_name);
2810    let _ = worktree::run_git_logged(&workdir, &["fetch", "origin", &refspec]);
2811    format!("origin/{}", head.base_ref_name)
2812  });
2813  let head_ref = forge.pr_head_refspec(number);
2814  let rspec = review::ReviewSpec {
2815    number,
2816    head_ref: &head_ref,
2817    branch: &branch,
2818    dirname: &dirname,
2819    target: &target,
2820    base_ref: base_ref.as_deref(),
2821  };
2822  let created = review::materialize(&repo, &workdir, &rspec)?;
2823  println!("✓ review worktree created at {}", created.display());
2824  println!("✓ linked to {} #{number}", forge.pr_noun());
2825
2826  let post_ctx = pre_ctx.with_cwd(&created);
2827  match review::run_post_setup(&config, &post_ctx, &workdir, &created, &skips, bootstrap)? {
2828    Some(reports) => {
2829      print_lifecycle_report(&reports.pre_bootstrap);
2830      print_report(&reports.bootstrap);
2831      print_lifecycle_report(&reports.post_bootstrap);
2832      if config.hooks.has_any() && !config.bootstrap.command.is_empty() {
2833        eprintln!("warning: [[bootstrap.command]] is deprecated as a post_create hook when [hooks.*] is present");
2834      }
2835      print_lifecycle_report(&reports.post_create);
2836    }
2837    None => {
2838      println!("(skipped bootstrap + hooks — pass --bootstrap to run setup against the PR's code)");
2839    }
2840  }
2841  Ok(())
2842}
2843
2844fn cmd_new(
2845  branch_type: String,
2846  desc: String,
2847  no_bootstrap: bool,
2848  reuse_branch: bool,
2849  skip_hooks: Option<String>,
2850  trust_mode: TrustMode,
2851) -> Result<()> {
2852  let RepoContext { repo, config, .. } = repo_context(None)?;
2853  let repo_name = worktree::repo_name(&repo);
2854  let resolved_types = config.resolved_branch_types();
2855  let spec = BranchSpec::new_with_types(branch_type.clone(), "0", desc, &resolved_types.types)?;
2856  let draft = issue_templates::render_issue_draft(&repo, &config, &spec.type_, &spec.desc)?;
2857  let forge = forge::resolve_or_default(&repo, &config)?;
2858  let created = forge.create_issue(&forge::IssueCreateRequest {
2859    title: &draft.title,
2860    body_file: draft.body_file.path(),
2861    labels: &draft.labels,
2862  })?;
2863
2864  let label_summary = if draft.labels.is_empty() {
2865    String::new()
2866  } else {
2867    format!(" (labels: {})", draft.labels.join(", "))
2868  };
2869  println!("✓ created issue #{} {}{}", created.number, draft.title, label_summary);
2870  let issue = created.number.to_string();
2871  let branch = BranchSpec::new_with_types(
2872    spec.type_.clone(),
2873    issue.clone(),
2874    spec.desc.clone(),
2875    &resolved_types.types,
2876  )?
2877  .branch_name(&config.worktree, &repo_name)?;
2878  println!("  {}", created.url);
2879  println!("creating linked worktree for {}", branch);
2880
2881  cmd_create(
2882    Some(spec.type_),
2883    Some(issue),
2884    Some(spec.desc),
2885    // `gwm new` always produces a structured worktree — it has just created
2886    // the issue whose number the branch carries.
2887    None,
2888    no_bootstrap,
2889    reuse_branch,
2890    skip_hooks,
2891    trust_mode,
2892    None,
2893  )
2894}
2895
2896/// Maximum number of lines kept from `git diff --stat <base>..<head>`
2897/// when rendering the `{files_changed}` placeholder. Hardcoded by issue
2898/// #84 so a sprawling refactor PR doesn't push the body past GitHub's
2899/// 65 535-byte limit; the renderer appends a `… (N more lines trimmed)`
2900/// rider when the cap fires.
2901const PR_FILES_CHANGED_MAX_LINES: usize = 30;
2902
2903fn cmd_pr(render_only: bool, draft: bool, base_override: Option<String>) -> Result<()> {
2904  let RepoContext { repo, workdir, config } = repo_context(None)?;
2905  // Issue #477: from the invoking checkout, not from `repo` — that handle
2906  // has walked back to the main working directory. Everything else below
2907  // keeps using `repo`, which is what it wants.
2908  let head_name = current_branch_at(None)?;
2909  // Issue #417: `[pr_template.by_type]` selection and the body placeholders
2910  // read the branch back, so they read it with this repo's own pattern.
2911  let branch_spec = crate::naming::BranchParser::from_config(&config, &worktree::repo_name(&repo)).parse(&head_name);
2912
2913  // `.filter` and not just `.map`: since #417 a pattern with no `{type}` still
2914  // parses, reporting the segments it *does* carry, so the type comes back
2915  // empty rather than as a failed parse. An empty type selects no
2916  // `[pr_template.by_type]` entry and renders `{type}` blank, which is exactly
2917  // what this fallback exists to prevent.
2918  let branch_type = branch_spec
2919    .as_ref()
2920    .map(|s| s.type_.clone())
2921    .filter(|t| !t.is_empty())
2922    .unwrap_or_else(|| "chore".into());
2923  let issue = branch_spec.as_ref().map(|s| s.issue.clone()).unwrap_or_default();
2924  let desc = branch_spec.as_ref().map(|s| s.desc.clone()).unwrap_or_default();
2925
2926  let base = base_override
2927    .or_else(|| worktree::resolve_trunk(&repo, &config.doctor.trunks))
2928    .unwrap_or_else(|| "main".into());
2929
2930  let commits = worktree::git_log_subject_between(&workdir, &base, &head_name)
2931    .inspect_err(|e| {
2932      eprintln!(
2933        "note: could not collect `{{commits}}` from `git log {}..{}`: {} (placeholder will be empty)",
2934        base, head_name, e
2935      );
2936    })
2937    .unwrap_or_default();
2938  let files_changed = worktree::git_diff_stat_between(&workdir, &base, &head_name, PR_FILES_CHANGED_MAX_LINES)
2939    .inspect_err(|e| {
2940      eprintln!(
2941        "note: could not collect `{{files_changed}}` from `git diff --stat {}..{}`: {} (placeholder will be empty)",
2942        base, head_name, e
2943      );
2944    })
2945    .unwrap_or_default();
2946  // Best-effort: `--render-only` must keep working in a repo with no
2947  // `origin`, where the `{{repo}}` placeholder simply renders empty.
2948  // The forge is resolved *strictly* further down, only on the path that
2949  // actually talks to the network.
2950  let repo_slug = forge::repo_slug(&repo).unwrap_or_default();
2951
2952  let ctx = PrTemplateContext {
2953    branch_type: branch_type.clone(),
2954    issue,
2955    desc,
2956    base: base.clone(),
2957    head: head_name.clone(),
2958    commits,
2959    files_changed,
2960    repo: repo_slug.clone(),
2961  };
2962  let body = pr_templates::render_pr_body(&config.pr_template, &workdir, &ctx)?;
2963
2964  if render_only {
2965    print!("{}", body);
2966    if !body.ends_with('\n') {
2967      println!();
2968    }
2969    return Ok(());
2970  }
2971
2972  let mut body_file = tempfile::NamedTempFile::new()?;
2973  use std::io::Write;
2974  body_file.write_all(body.as_bytes())?;
2975  body_file.flush()?;
2976
2977  let title = pr_title(&ctx);
2978  let forge = forge::resolve_or_default(&repo, &config)?;
2979  let created = forge.create_pr(&forge::PrCreateRequest {
2980    title: &title,
2981    body_file: body_file.path(),
2982    head: &head_name,
2983    base: Some(base.as_str()),
2984    draft,
2985  })?;
2986  println!("✓ created {} #{}", forge.pr_noun(), created.number);
2987  println!("  {}", created.url);
2988  if let Err(e) = github::link_pr(&repo, &head_name, created.number) {
2989    // Linking is a best-effort convenience: surface the failure but
2990    // don't drop the freshly-created PR on the floor.
2991    eprintln!("note: could not record gwm-pr config for {}: {}", head_name, e);
2992  }
2993  Ok(())
2994}
2995
2996fn pr_title(ctx: &PrTemplateContext) -> String {
2997  // Title heuristic mirrors `me:issue-worktree-pr`: take the latest
2998  // commit subject if there is one, else fall back to "<type>: <desc>"
2999  // so the user gets a deterministic, non-empty title.
3000  if let Some(first) = ctx.commits.lines().next() {
3001    let trimmed = first.trim_start_matches("- ").trim();
3002    if !trimmed.is_empty() {
3003      return trimmed.to_string();
3004    }
3005  }
3006  if !ctx.desc.is_empty() {
3007    return format!("{}: {}", ctx.branch_type, ctx.desc);
3008  }
3009  format!("update {}", ctx.head)
3010}
3011
3012/// Render the would-do plan for `gwm remove --dry-run` (issue #31).
3013/// Extracted from `cmd_remove` so the formatter is unit-testable
3014/// without spinning up a real worktree. Pure function: takes the
3015/// resolved name + path + branch, returns a multi-line string
3016/// (trailing newline included).
3017///
3018/// `delete_branch` only adds "(would be deleted)" when there *is* a
3019/// branch to delete — a detached HEAD worktree with
3020/// `--delete-branch` reports "(no branch to delete)" instead, mirror-
3021/// ing `worktree::remove`'s actual behaviour (it only drops a branch
3022/// when one is resolvable).
3023pub fn format_remove_plan(name: &str, path: &Path, branch: Option<&str>, delete_branch: bool) -> String {
3024  use std::fmt::Write;
3025  let mut out = String::new();
3026  let _ = writeln!(out, "would remove:");
3027  let _ = writeln!(out, "  name:   {}", name);
3028  let _ = writeln!(out, "  path:   {}", path.display());
3029  match (branch, delete_branch) {
3030    (Some(b), true) => {
3031      let _ = writeln!(out, "  branch: {} (would be deleted)", b);
3032    }
3033    (Some(b), false) => {
3034      let _ = writeln!(out, "  branch: {}", b);
3035    }
3036    (None, true) => {
3037      // Detached HEAD worktree: `worktree::remove` only drops a
3038      // branch when one is resolvable, so the dry-run must not
3039      // claim a deletion that will never happen. The clarifying
3040      // rider tells the user why `--delete-branch` is a no-op
3041      // here without forcing them to re-read the docs.
3042      let _ = writeln!(out, "  branch: - (no branch to delete)");
3043    }
3044    (None, false) => {
3045      let _ = writeln!(out, "  branch: -");
3046    }
3047  }
3048  out
3049}
3050
3051/// Render the would-do plan for `gwm prune --dry-run` (issue #31).
3052/// Extracted from `cmd_prune` so the formatter is unit-testable on
3053/// arbitrary `PrunableEntry` fixtures (non-ASCII names / paths
3054/// without needing a real repo). Pure function: trailing newline
3055/// included; empty input still emits the canonical
3056/// "0 worktree(s) to prune" line so piped consumers get a stable
3057/// signal instead of empty stdout.
3058///
3059/// Column widths are computed in Unicode characters
3060/// (`.chars().count()`), not bytes (`.len()`), so non-ASCII paths
3061/// stay aligned in a fixed-width terminal.
3062pub fn format_prune_plan(entries: &[worktree::PrunableEntry]) -> String {
3063  use std::fmt::Write;
3064  let mut out = String::new();
3065  if entries.is_empty() {
3066    let _ = writeln!(out, "0 worktree(s) to prune");
3067    return out;
3068  }
3069  // Widths in Unicode characters, not bytes — non-ASCII names or
3070  // paths would otherwise drift the reason column right by the
3071  // (byte_len - char_count) delta. Rust's `{:<width$}` format spec
3072  // pads to a *character* count, so feeding it `.len()` is the bug
3073  // Copilot flagged on PR #154.
3074  let name_w = entries.iter().map(|e| e.name.chars().count()).max().unwrap_or(4);
3075  let path_w = entries
3076    .iter()
3077    .map(|e| e.path.display().to_string().chars().count())
3078    .max()
3079    .unwrap_or(4);
3080  let _ = writeln!(out, "would prune {} worktree(s):", entries.len());
3081  for entry in entries {
3082    let _ = writeln!(
3083      out,
3084      "  {:<nw$}  {:<pw$}  ({})",
3085      entry.name,
3086      entry.path.display(),
3087      entry.reason,
3088      nw = name_w,
3089      pw = path_w,
3090    );
3091  }
3092  out
3093}
3094
3095fn cmd_remove(
3096  pattern: String,
3097  delete_branch: bool,
3098  dry_run: bool,
3099  force: bool,
3100  skip_hooks: Option<String>,
3101  trust_mode: TrustMode,
3102) -> Result<()> {
3103  let repo = worktree::discover_repo(None)?;
3104  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3105  let found = worktree::find_fuzzy(&repo, &pattern)?;
3106  if dry_run {
3107    // Issue #31: print the would-remove plan and exit. Resolution
3108    // already happened above — an ambiguous pattern surfaced via
3109    // `find_fuzzy` returns the same `Other(... ambiguous ...)` error
3110    // the destructive form raises, satisfying the spec's "same error
3111    // contract" requirement. The journal hook MUST NOT fire here —
3112    // a preview that wrote to the journal would let the user "undo"
3113    // something that never happened.
3114    worktree::remove_dry_run(&repo, &found.id)?;
3115    print!(
3116      "{}",
3117      format_remove_plan(&found.name, &found.path, found.branch.as_deref(), delete_branch)
3118    );
3119    return Ok(());
3120  }
3121
3122  let config = Config::load_for_repo(&workdir)?;
3123  let mut skips = HookSkips::parse(skip_hooks.as_deref())?;
3124  if force {
3125    skips = skips.with(HookPhase::PreRemove).with(HookPhase::PostRemove);
3126  }
3127  if config.hooks.has_any() {
3128    trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
3129  }
3130  let pre_ctx = HookContext::for_worktree(&repo, &workdir, &found.path, &found.path, found.branch.as_deref());
3131  let report = lifecycle::run_phase(&config, HookPhase::PreRemove, &pre_ctx, &skips, false)?;
3132  print_lifecycle_report(&report);
3133
3134  // Issue #29: capture the branch OID via libgit2 BEFORE the
3135  // destructive call so we can resurrect the branch on `gwm undo`.
3136  // We swallow any journal IO failure with a stderr warning rather
3137  // than blocking a destruction the user explicitly asked for —
3138  // losing recoverability is unfortunate, but failing the remove
3139  // because we can't write to `~/.local/share/gwm/history.toml` would
3140  // be far more surprising. (Disk full, read-only FS, sandboxed
3141  // CI runner without home dir, …)
3142  let branch_oid = found.branch.as_deref().and_then(|b| {
3143    repo
3144      .find_branch(b, git2::BranchType::Local)
3145      .ok()
3146      .and_then(|br| br.into_reference().target())
3147      .map(|o| o.to_string())
3148  });
3149  let repo_root = repo
3150    .workdir()
3151    .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()))
3152    .unwrap_or_default();
3153  let entry = OpEntry {
3154    ts: chrono::Utc::now(),
3155    kind: crate::history::OpKind::Remove,
3156    worktree: found.name.clone(),
3157    branch: found.branch.clone(),
3158    branch_oid,
3159    path: found.path.clone(),
3160    deleted_branch: delete_branch,
3161    repo_root,
3162    undone: false,
3163  };
3164  if let Err(e) = history::record(entry) {
3165    eprintln!(
3166      "warning: failed to record undo journal entry: {} (continuing with the remove anyway)",
3167      e
3168    );
3169  }
3170
3171  worktree::remove(&repo, &found.id, delete_branch)?;
3172  println!("✓ removed {} ({})", found.name, found.path.display());
3173  if delete_branch {
3174    if let Some(b) = &found.branch {
3175      println!("  branch {} deleted", b);
3176    }
3177  }
3178  let post_ctx = pre_ctx.with_cwd(&workdir);
3179  let report = lifecycle::run_phase(&config, HookPhase::PostRemove, &post_ctx, &skips, false)?;
3180  print_lifecycle_report(&report);
3181  Ok(())
3182}
3183
3184fn cmd_path(pattern: String, format: OutputFormat) -> Result<()> {
3185  let repo = worktree::discover_repo(None)?;
3186  let found = worktree::find_fuzzy(&repo, &pattern)?;
3187  match format {
3188    OutputFormat::Text => println!("{}", found.path.display()),
3189    OutputFormat::Json => {
3190      let dto = json_api::JsonPath::from(&found);
3191      println!("{}", serde_json::to_string_pretty(&dto)?);
3192    }
3193  }
3194  Ok(())
3195}
3196
3197fn cmd_bootstrap(target: Option<String>, skip_hooks: Option<String>, trust_mode: TrustMode) -> Result<()> {
3198  let RepoContext { repo, workdir, config } = repo_context(None)?;
3199
3200  let mut worktree_branch: Option<String> = None;
3201  let worktree_path: PathBuf = match target {
3202    Some(t) => {
3203      let p = PathBuf::from(&t);
3204      if p.is_dir() {
3205        p
3206      } else {
3207        let found = worktree::find_fuzzy(&repo, &t)?;
3208        worktree_branch = found.branch.clone();
3209        found.path
3210      }
3211    }
3212    None => std::env::current_dir()?,
3213  };
3214  // Issue #477: only the fuzzy arm above resolves a branch, off the worktree
3215  // record. The other two left it `None`, so hooks received an empty
3216  // `{branch}` / `{type}` / `{issue}` — the same defect as `pr` and
3217  // `commit-prefix` with a quieter symptom. Read it from the target itself,
3218  // which covers a path that was given outright as well as the CWD.
3219  if worktree_branch.is_none() {
3220    worktree_branch = current_branch_at(Some(&worktree_path)).ok();
3221  }
3222  let skips = HookSkips::parse(skip_hooks.as_deref())?;
3223
3224  trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
3225
3226  let hook_ctx = HookContext::for_worktree(
3227    &repo,
3228    &workdir,
3229    &worktree_path,
3230    &worktree_path,
3231    worktree_branch.as_deref(),
3232  );
3233  let report = lifecycle::run_phase(&config, HookPhase::PreBootstrap, &hook_ctx, &skips, false)?;
3234  print_lifecycle_report(&report);
3235
3236  let ctx = BootstrapCtx {
3237    main_repo: &workdir,
3238    worktree: &worktree_path,
3239    config: &config,
3240  };
3241  let report = bootstrap::run_core(&ctx)?;
3242  print_report(&report);
3243  let report = lifecycle::run_phase(&config, HookPhase::PostBootstrap, &hook_ctx, &skips, false)?;
3244  print_lifecycle_report(&report);
3245  Ok(())
3246}
3247
3248fn cmd_sync(pattern: Option<String>, merge: bool) -> Result<()> {
3249  // Resolve the target worktree. With a pattern, fuzzy-match against the
3250  // main repo's worktree list like the rest of gwm. Without one, default
3251  // to the worktree *containing* the CWD — which, unlike `find_fuzzy`,
3252  // may legitimately be the main worktree (syncing trunk is valid). We
3253  // discover that worktree's own workdir (not the CWD basename) so a
3254  // `gwm sync` from a subdirectory still names and targets the worktree
3255  // root rather than the subdir.
3256  let (target_path, name) = match pattern {
3257    Some(p) => {
3258      let repo = worktree::discover_repo(None)?;
3259      let found = worktree::find_fuzzy(&repo, &p)?;
3260      (found.path, found.name)
3261    }
3262    None => {
3263      let cwd = std::env::current_dir()?;
3264      let repo = Repository::discover(&cwd).map_err(|_| GwmError::NotInGitRepo)?;
3265      let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3266      let name = workdir
3267        .file_name()
3268        .map(|n| n.to_string_lossy().into_owned())
3269        .unwrap_or_else(|| "worktree".into());
3270      (workdir, name)
3271    }
3272  };
3273
3274  let strategy = if merge {
3275    SyncStrategy::Merge
3276  } else {
3277    SyncStrategy::Rebase
3278  };
3279  let report = sync::sync(&target_path, strategy)?;
3280  print!("{}", format_sync_report(&name, &report));
3281  Ok(())
3282}
3283
3284/// Render a successful [`SyncReport`] as a single ✓ status line. The
3285/// error paths (dirty tree, missing upstream, conflicts) surface as
3286/// `GwmError` and are printed by `main`'s top-level handler, so this
3287/// only ever formats the success cases.
3288pub fn format_sync_report(name: &str, report: &SyncReport) -> String {
3289  match report.action {
3290    SyncAction::UpToDate => {
3291      format!("✓ {} already up to date with {}\n", name, report.upstream)
3292    }
3293    SyncAction::Integrated => {
3294      let verb = match report.strategy {
3295        SyncStrategy::Rebase => "rebased",
3296        SyncStrategy::Merge => "merged",
3297      };
3298      let plural = if report.behind_before == 1 { "" } else { "s" };
3299      format!(
3300        "✓ {} {} {} commit{} from {}\n",
3301        name, verb, report.behind_before, plural, report.upstream
3302      )
3303    }
3304  }
3305}
3306
3307fn cmd_prune(dry_run: bool) -> Result<()> {
3308  let repo = worktree::discover_repo(None)?;
3309  if dry_run {
3310    // Issue #31: enumerate prunable worktrees (name + path + reason)
3311    // and render the plan through the shared formatter. Empty input
3312    // still emits "0 worktree(s) to prune" so piped consumers always
3313    // get a stable signal.
3314    let plan = worktree::prunable_worktrees(&repo)?;
3315    print!("{}", format_prune_plan(&plan));
3316    return Ok(());
3317  }
3318  let n = worktree::prune(&repo)?;
3319  println!("pruned {} stale worktree(s)", n);
3320  Ok(())
3321}
3322
3323fn cmd_doctor(format: OutputFormat) -> Result<()> {
3324  let RepoContext { repo, workdir, config } = repo_context_lenient(None)?;
3325
3326  // Thread the real global layer so the keymap check re-reads exactly what the
3327  // TUI loads, while keeping the ambient read out of `doctor::run` itself
3328  // (issue #219 review — injected contexts stay deterministic).
3329  let global = crate::config::global_config_path();
3330  let ctx = DoctorCtx {
3331    repo_workdir: &workdir,
3332    repo: &repo,
3333    config: &config,
3334    global_config_path: global.as_deref(),
3335  };
3336  let report = doctor::run(&ctx)?;
3337  match format {
3338    OutputFormat::Text => print_doctor_report(&report),
3339    OutputFormat::Json => {
3340      let dto = json_api::JsonDoctorReport::from(&report);
3341      println!("{}", serde_json::to_string_pretty(&dto)?);
3342    }
3343  }
3344
3345  // The process exit code is identical in both formats: the JSON payload
3346  // also carries `exit_code`, but a `gwm doctor --format json` in a CI
3347  // `if`-guard must still see the conventional 0/1/2.
3348  let code = report.exit_code();
3349  if code != 0 {
3350    std::process::exit(code);
3351  }
3352  Ok(())
3353}
3354
3355/// `gwm daemon` (issue #38, phase 2). Discovers the repo from the CWD,
3356/// binds the JSON-RPC socket, and serves until killed. The serving path
3357/// needs the `daemon` feature plus a supported transport — a unix domain
3358/// socket, or a named pipe on Windows (#439); elsewhere it returns a clean
3359/// error so the subcommand stays present (and help identical) everywhere.
3360#[cfg(all(any(unix, windows), feature = "daemon"))]
3361fn cmd_daemon(socket: Option<PathBuf>, poll_ms: u64) -> Result<()> {
3362  use std::sync::atomic::AtomicBool;
3363  use std::sync::Arc;
3364
3365  let repo = worktree::discover_repo(None)?;
3366  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3367  // A user `--socket` is taken verbatim (we never touch its parent dir); the
3368  // default resolution may nest the socket in a private `gwm-<uid>/` dir on a
3369  // shared base, in which case `serve` owns and secures that dir (issue #341).
3370  let (socket, manage_socket_dir) = match socket {
3371    Some(s) => (s, false),
3372    None => crate::daemon::default_socket(),
3373  };
3374  let mut opts = crate::daemon::ServeOptions::new(socket, workdir, std::time::Duration::from_millis(poll_ms));
3375  opts.manage_socket_dir = manage_socket_dir;
3376  // `serve` prints the "listening" line itself, but only after the socket
3377  // is actually bound — so the message can't precede a bind failure (issue
3378  // #38 review). `socket` is kept by `opts`; nothing more to do here.
3379  crate::daemon::serve(&opts, Arc::new(AtomicBool::new(false)))
3380}
3381
3382#[cfg(not(all(any(unix, windows), feature = "daemon")))]
3383fn cmd_daemon(socket: Option<PathBuf>, poll_ms: u64) -> Result<()> {
3384  let _ = (socket, poll_ms);
3385  Err(GwmError::Other(
3386    "daemon mode is unavailable in this build (requires the `daemon` feature on a supported platform)".into(),
3387  ))
3388}
3389
3390/// Print one rendered statusline for the current cwd. Flushes immediately
3391/// so a `--watch` consumer (tmux / prompt) sees each update without buffer
3392/// lag. An empty render (no daemon, empty set) still prints a blank line so
3393/// the consumer's line count stays predictable.
3394fn print_statusline(worktrees: &[crate::json_api::JsonWorktree], cwd: &Path) {
3395  use std::io::Write;
3396  // Canonicalise both the cwd and each worktree path so a symlinked path
3397  // (macOS /var ↔ /private/var, or a worktree under a symlink) still
3398  // matches — the daemon hands back raw libgit2 paths (Codex review #311).
3399  let active = crate::statusline::active_index_with(worktrees, cwd, |p| {
3400    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
3401  });
3402  println!("{}", crate::statusline::render(worktrees, active));
3403  let _ = io::stdout().flush();
3404}
3405
3406#[cfg(all(any(unix, windows), feature = "daemon"))]
3407fn cmd_statusline(socket: Option<PathBuf>, watch: bool) -> Result<()> {
3408  let socket = socket.unwrap_or_else(crate::daemon::socket_path);
3409  // `print_statusline` canonicalises both the cwd and each worktree path,
3410  // so the raw cwd is fine here.
3411  let cwd = std::env::current_dir().unwrap_or_default();
3412
3413  if watch {
3414    // Stream until the daemon goes away; the callback never asks to stop, so
3415    // `subscribe` returns only when the stream ends — unreachable, or the
3416    // daemon stopped / restarted after pushing snapshots. `statusline::watch`
3417    // renders each push and then emits a trailing blank so a long-running
3418    // consumer clears the now-stale line instead of freezing on it (#309).
3419    crate::statusline::watch(
3420      |cb| crate::daemon::client::subscribe(&socket, cb),
3421      |worktrees| print_statusline(worktrees, &cwd),
3422    );
3423    return Ok(());
3424  }
3425
3426  match crate::daemon::client::list_once(&socket) {
3427    Ok(worktrees) => print_statusline(&worktrees, &cwd),
3428    // No daemon (or a transport error): graceful blank line, exit 0.
3429    Err(_) => print_statusline(&[], &cwd),
3430  }
3431  Ok(())
3432}
3433
3434#[cfg(not(all(any(unix, windows), feature = "daemon")))]
3435fn cmd_statusline(socket: Option<PathBuf>, watch: bool) -> Result<()> {
3436  // No daemon transport in this build (`--no-default-features`, or an
3437  // unsupported platform): the statusline has no source, so it degrades to
3438  // the documented empty line (exit 0) rather than erroring. The statusline
3439  // is deliberately daemon-fed (#309: a prompt path must never open the
3440  // repo or scan artefact stores itself); unix rides the socket, Windows
3441  // the named pipe (#439).
3442  let _ = (socket, watch);
3443  let cwd = std::env::current_dir().unwrap_or_default();
3444  print_statusline(&[], &cwd);
3445  Ok(())
3446}
3447
3448fn print_doctor_report(report: &doctor::DoctorReport) {
3449  // Issue #473: several checks quote config-supplied strings in their detail
3450  // (`base directory writable` renders `[worktree].base`, the guard checks
3451  // name their entries). Neutralised here, at the single sink, rather than in
3452  // each `Check::ok` / `failed` call, so a check added later is covered
3453  // without anyone having to remember.
3454  //
3455  // A detail can span rows: `check_config_parses` puts `toml`'s whole
3456  // caret-under-the-column diagnostic in it. Flattening that turned the output
3457  // of the recovery command into `?  |?1 | [worktree?`, so line breaks survive
3458  // here too. They are safe for the same reason they are safe in `main`: this
3459  // printer owns the margin, and every row it emits is indented under the
3460  // check that produced it.
3461  let clean = crate::naming::sanitise_block_for_terminal;
3462  let indented = |text: &str, first: &str| {
3463    let cleaned = clean(text);
3464    let mut lines = cleaned.split('\n');
3465    let mut out = format!("{}{}", first, lines.next().unwrap_or_default());
3466    for line in lines {
3467      out.push_str("\n      ");
3468      out.push_str(line);
3469    }
3470    out
3471  };
3472  for c in &report.checks {
3473    let sigil = match c.status {
3474      CheckStatus::Ok => "✓",
3475      CheckStatus::Warning => "!",
3476      CheckStatus::Failed => "✗",
3477    };
3478    // The check name is a fixed label, never config text, but it shares the
3479    // helper so a check that starts naming its subject cannot slip through.
3480    println!("{} {}", sigil, crate::naming::sanitise_for_terminal(&c.name));
3481    if !c.detail.is_empty() {
3482      println!("{}", indented(&c.detail, "    "));
3483    }
3484    if let Some(hint) = &c.fix_hint {
3485      println!("{}", indented(hint, "    → "));
3486    }
3487  }
3488}
3489
3490fn cmd_types(gitmoji_flag: bool) -> Result<()> {
3491  // Resolve the active branch-type list. When invoked inside a repo
3492  // with a workdir we honour any `[[branch_types]]` override in
3493  // `.gwm.toml`; outside of one — or inside a bare repo where
3494  // `repo.workdir()` is `None` and there's no place to look for
3495  // `.gwm.toml` — we silently fall back to the built-in defaults so
3496  // `gwm types` remains useful as a discovery command (used by `gwm`
3497  // newcomers before they've initialised a config, and from CI inspect
3498  // commands that point at bare clones).
3499  let workdir = match worktree::discover_repo(None) {
3500    Ok(repo) => repo.workdir().map(|w| w.to_path_buf()),
3501    Err(_) => None,
3502  };
3503  let resolved = match &workdir {
3504    Some(w) => Config::load_for_repo(w)?.resolved_branch_types(),
3505    None => Config::default().resolved_branch_types(),
3506  };
3507
3508  // Resolve the gitmoji map only when the caller asked for it — the
3509  // default `gwm types` output stays a stable two-column shape every
3510  // scripted parser of the pre-#85 surface depended on.
3511  let gitmoji_map = if gitmoji_flag {
3512    Some(gitmoji::load(workdir.as_deref())?)
3513  } else {
3514    None
3515  };
3516
3517  // Align the description column on the longest name so a custom list
3518  // with a long entry (e.g. `migration`) still renders cleanly.
3519  let width = resolved.types.iter().map(|t| t.name.len()).max().unwrap_or(0).max(8);
3520  // When the gitmoji columns are active, align the shortcode column on
3521  // the widest shortcode (`:white_check_mark:`, currently 18 chars) so
3522  // the description column doesn't drift between rows.
3523  // Issue #473: `description` (from `[[branch_types]]`) and the shortcodes
3524  // (from `[gitmoji]`) are free text out of an unvetted `.gwm.toml`; `name` is
3525  // not, it is already constrained to `^[a-z]+$` by `validate_branch_types`.
3526  // Widths are measured on the neutralised strings so the columns still line
3527  // up: a replaced C1 control character is two bytes narrower than the one
3528  // it replaced.
3529  let clean = crate::naming::sanitise_for_terminal;
3530  let sc_width = match &gitmoji_map {
3531    Some(map) => map.iter().map(|(_, sc)| clean(sc).len()).max().unwrap_or(0).max(10),
3532    None => 0,
3533  };
3534
3535  for t in &resolved.types {
3536    match &gitmoji_map {
3537      Some(map) => {
3538        // Two extra columns: unicode glyph (1 cell wide, padded for
3539        // BMP code points; emoji ZWJ sequences would break alignment
3540        // but our built-in set is all single-glyph) + shortcode.
3541        let shortcode = clean(map.get(&t.name).unwrap_or(":question:"));
3542        let unicode = gitmoji::shortcode_to_unicode(&shortcode);
3543        println!(
3544          "  {:<width$}  {}  {:<sw$}  {}",
3545          t.name,
3546          unicode,
3547          shortcode,
3548          clean(&t.description),
3549          width = width,
3550          sw = sc_width,
3551        );
3552      }
3553      None => {
3554        println!("  {:<width$}  {}", t.name, clean(&t.description), width = width);
3555      }
3556    }
3557  }
3558  println!();
3559  println!("(source: {})", resolved.source.label());
3560  Ok(())
3561}
3562
3563/// `gwm commit-prefix [--branch <name>] [--unicode]` (issue #85).
3564/// Renders `:sparkles: feat(#41):` (or `✨ feat(#41):` with `--unicode`)
3565/// for the supplied branch or HEAD. Useful for shell prompts, AI
3566/// assistants, and the bundled `commit-msg` hook.
3567fn cmd_commit_prefix(branch_override: Option<String>, unicode: bool) -> Result<()> {
3568  // Two resolution paths: an explicit `--branch <name>` (no repo
3569  // *required* — useful for scripted contexts outside a repo) and
3570  // the implicit "use HEAD" branch (requires a repo). Both go through the
3571  // same parser so the prefix shape stays canonical regardless of entry
3572  // point — the repo's own where there is a repo, the built-in pattern
3573  // otherwise (issue #417).
3574  //
3575  // For BOTH paths we still attempt repo discovery so the workdir
3576  // handle is fed into `gitmoji::load` — this is what makes
3577  // per-repo `.gwm.toml` `[gitmoji]` overrides apply uniformly to
3578  // `gwm commit-prefix` (no flag, --branch, or whatever the
3579  // installed commit-msg hook ends up calling). Discovery failures
3580  // are silently downgraded to "no workdir" so the `--branch` form
3581  // still works outside a git checkout — that's the whole point of
3582  // the explicit-branch entry point.
3583  // `repo_name` rides along for issue #417: `{repo}` is a legal token in
3584  // `worktree.branch_pattern`, so compiling the parser needs the same name
3585  // the formatter used.
3586  let (workdir, repo_name, branch_name) = match branch_override {
3587    Some(name) => {
3588      // Best-effort discovery: outside a repo the user passed
3589      // `--branch` precisely because there's no HEAD to read; we
3590      // must not fail here. Inside a repo we want the workdir so
3591      // `.gwm.toml` overrides apply.
3592      let repo = worktree::discover_repo(None).ok();
3593      let workdir = repo.as_ref().and_then(|r| r.workdir().map(|w| w.to_path_buf()));
3594      (workdir, repo.as_ref().map(worktree::repo_name), name)
3595    }
3596    None => {
3597      let repo = worktree::discover_repo(None)?;
3598      let wd = repo.workdir().map(|w| w.to_path_buf());
3599      // Issue #477: the workdir and the repo name come from the main
3600      // checkout, because that is where `.gwm.toml` lives and what `{repo}`
3601      // expands to. The branch does not: the bundled `commit-msg` hook runs
3602      // this with git's working directory inside the worktree, so reading
3603      // `repo`'s HEAD prefixed every commit made from a worktree with
3604      // whatever the main checkout happened to be sitting on.
3605      let name = current_branch_at(None)?;
3606      (wd, Some(worktree::repo_name(&repo)), name)
3607    }
3608  };
3609
3610  // Issue #417: the branch was written by expanding this repo's
3611  // `worktree.branch_pattern`, so it is read back by a parser compiled from
3612  // that same pattern — otherwise a repo that customised it gets no prefix
3613  // for branches gwm itself created. Outside a checkout there is no config to
3614  // consult and the built-in shape is all `--branch` can mean.
3615  let config = workdir.as_deref().and_then(|wd| Config::load_for_repo(wd).ok());
3616  let parser = match (config.as_ref(), repo_name.as_deref()) {
3617    (Some(cfg), Some(repo)) => crate::naming::BranchParser::from_config(cfg, repo),
3618    _ => crate::naming::BranchParser::builtin().clone(),
3619  };
3620  // Neutralised before it is ever quoted: `branch_pattern` is repo-supplied
3621  // and this command does not go through the trust gate, so an unvetted
3622  // `.gwm.toml` must not get a terminal escape channel out of an error message
3623  // (Codex review on PR #476).
3624  let pattern = crate::naming::sanitise_for_terminal(
3625    &config
3626      .as_ref()
3627      .map(|c| c.worktree.branch_pattern.clone())
3628      .unwrap_or_else(crate::config::default_branch_pattern),
3629  );
3630
3631  // Issue #416: a free-form branch reaches here legitimately. This command
3632  // exists solely to derive a prefix from the branch *type* and issue, and a
3633  // name the user chose has neither — there is no honest default to fall back
3634  // to, so it stays an error. The message says the shape is unavailable
3635  // rather than implying the branch is wrong.
3636  let spec = parser.parse(&branch_name).ok_or_else(|| {
3637    GwmError::Other(format!(
3638      "branch '{}' does not match this repo's branch pattern `{}`, so it carries no branch type to \
3639       read — a commit prefix is derived from one, and a free-form branch has none. Pass --branch \
3640       <name written by the pattern>, or write the prefix by hand",
3641      branch_name, pattern
3642    ))
3643  })?;
3644
3645  // Issue #417: a pattern that carries no `{type}` or `{issue}` *and* freezes
3646  // neither as a literal — `{type}/{desc}`, `{issue}-{desc}` — parses
3647  // perfectly and yields an empty segment. Rendering `resolve_prefix` from
3648  // that prints ` (#):`, a broken prefix shipped as a success straight into a
3649  // commit message. A pattern that hardcodes one (`feat/#{issue}-{desc}`) does
3650  // not land here: the literal is recovered, so the prefix is right.
3651  if spec.type_.is_empty() || spec.issue.is_empty() {
3652    let want = match (spec.type_.is_empty(), spec.issue.is_empty()) {
3653      // `and`, not `or`: a prefix needs both, so adding one placeholder on the
3654      // strength of this message would leave the command failing (Codex review
3655      // on PR #476).
3656      (true, true) => "`{type}` and `{issue}`",
3657      (true, false) => "`{type}`",
3658      _ => "`{issue}`",
3659    };
3660    return Err(GwmError::Other(format!(
3661      "this repo's branch pattern `{}` carries no {}, so branch '{}' has none to read — a commit \
3662       prefix is built from the branch type and issue number. Add {} to worktree.branch_pattern, \
3663       or write the prefix by hand",
3664      pattern, want, branch_name, want
3665    )));
3666  }
3667
3668  let map = gitmoji::load(workdir.as_deref())?;
3669  let prefix = gitmoji::resolve_prefix(&map, &spec, unicode);
3670  // Issue #473: the prefix is assembled from `[gitmoji]` shortcodes read out
3671  // of `.gwm.toml`, and this command is ungated by design: shell prompts and
3672  // the bundled commit-msg hook call it on every commit, in whatever repo the
3673  // user happens to be sitting in.
3674  println!("{}", crate::naming::sanitise_for_terminal(&prefix));
3675  Ok(())
3676}
3677
3678/// `gwm hooks <action>` (issue #85). Currently only `install
3679/// commit-msg` is wired up; the subcommand layer is shaped so future
3680/// hooks (`pre-push`, `pre-commit`) drop in without breaking the
3681/// existing CLI surface.
3682fn cmd_hooks(action: HooksAction) -> Result<()> {
3683  match action {
3684    HooksAction::Install { hook, force } => cmd_hooks_install(hook, force),
3685  }
3686}
3687
3688fn cmd_hooks_install(hook: HookKind, force: bool) -> Result<()> {
3689  let repo = worktree::discover_repo(None)?;
3690  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
3691  match hook {
3692    HookKind::CommitMsg => {
3693      let path = hooks::install_commit_msg(&workdir, force)?;
3694      println!("✓ installed {}", path.display());
3695      println!("  (auto-prepends gitmoji+type prefix when missing)");
3696    }
3697  }
3698  Ok(())
3699}
3700
3701fn cmd_completions(shell: Shell) -> Result<()> {
3702  let mut cmd = Cli::command();
3703  let name = cmd.get_name().to_string();
3704  generate(shell, &mut cmd, name, &mut io::stdout());
3705  Ok(())
3706}
3707
3708fn cmd_shell_init(shell: InitShell) -> Result<()> {
3709  print!("{}", shell_init_script(shell));
3710  Ok(())
3711}
3712
3713/// `gwm switch` — open the TUI picker and emit the chosen worktree's path
3714/// on stdout. Returning a non-zero exit code when the user cancels lets the
3715/// shell wrapper (`gcd` in `shell-init`) skip the `cd` instead of cd'ing to
3716/// an empty argument.
3717///
3718/// The git-repo check runs before `tui::run_picker()` to keep the error
3719/// path identical to every other repo-bound subcommand (clean stderr,
3720/// no flicker into the alternate screen).
3721fn cmd_switch() -> Result<()> {
3722  // Probe the repo first; this is also what surfaces "not inside a git
3723  // repository" before we touch the terminal. Discarding the handle is
3724  // fine — `run_picker` re-discovers it via its own `App::new_picker_at`.
3725  let _ = worktree::discover_repo(None)?;
3726  match crate::tui::run_picker()? {
3727    Some(path) => {
3728      println!("{}", path.display());
3729      Ok(())
3730    }
3731    None => std::process::exit(1),
3732  }
3733}
3734
3735/// `gwm tmux <pattern>` / `gwm zellij <pattern>` — open the matched
3736/// worktree in a new window/tab (or split with `--split`). The handler
3737/// is shared between the two multiplexers because the only difference
3738/// is the argv shape, already encoded in `multiplexer::build_*_command`.
3739///
3740/// Error contract (ordered, first match wins):
3741///   1. Not inside a git repo → `NotInGitRepo`.
3742///   2. Multiplexer not running → `Other("<bin> session not running …")`.
3743///   3. Worktree pattern doesn't match → `WorktreeNotFound`.
3744///   4. Spawn or non-zero exit from the multiplexer → `CommandFailed`.
3745///
3746/// Ordering #1 before #2 matches `gwm cd` / `gwm switch`: the repo gate
3747/// is the more fundamental problem, so we surface it first.
3748fn cmd_multiplexer(mux: Multiplexer, pattern: String, split: bool) -> Result<()> {
3749  let repo = worktree::discover_repo(None)?;
3750
3751  let env_name = match mux {
3752    Multiplexer::Tmux => "TMUX",
3753    Multiplexer::Zellij => "ZELLIJ",
3754  };
3755  let env_value = std::env::var(env_name).ok();
3756  let running = match mux {
3757    Multiplexer::Tmux => detect_tmux(env_value),
3758    Multiplexer::Zellij => detect_zellij(env_value),
3759  };
3760  if !running {
3761    // `${env_name}` renders bare in stderr (not shell source, so no
3762    // backslash escaping). Pre-fix this read `\\${env_name}` and
3763    // surfaced `\$TMUX` to the user — caught at PR #65 review.
3764    return Err(GwmError::Other(format!(
3765      "{0} session not running (${1} unset) — run `gwm {0} <pattern>` from inside a {0} session",
3766      mux.binary(),
3767      env_name,
3768    )));
3769  }
3770
3771  let found = worktree::find_fuzzy(&repo, &pattern)?;
3772  let mode = if split { SpawnMode::Split } else { SpawnMode::Window };
3773  let argv = match mux {
3774    Multiplexer::Tmux => build_tmux_command(&found.name, &found.path, mode),
3775    Multiplexer::Zellij => build_zellij_command(&found.name, &found.path, mode),
3776  };
3777  spawn_multiplexer(mux, &argv)
3778}
3779
3780/// Spawn the multiplexer command and surface its exit status. argv[0] is
3781/// the binary; argv[1..] are the args. Matches `tui::mod::run_lazygit`
3782/// in shape — `.status()` so the user sees the child's own stderr live
3783/// instead of swallowing it into a buffered `CommandFailed`.
3784fn spawn_multiplexer(mux: Multiplexer, argv: &[String]) -> Result<()> {
3785  let (bin, rest) = argv.split_first().ok_or_else(|| {
3786    GwmError::Other(format!(
3787      "empty argv for {} spawn (build_*_command returned [])",
3788      mux.binary()
3789    ))
3790  })?;
3791  // The data string already names the binary (`tmux` / `zellij`), so
3792  // the rendered message reads `command failed: tmux exited with
3793  // status Some(1)` — attributable to the verb the user typed.
3794  let status = std::process::Command::new(bin)
3795    .args(rest)
3796    .status()
3797    .map_err(|e| GwmError::CommandFailed(format!("could not spawn {}: {}", bin, e)))?;
3798  if !status.success() {
3799    return Err(GwmError::CommandFailed(format!(
3800      "{} exited with status {:?}",
3801      bin,
3802      status.code()
3803    )));
3804  }
3805  Ok(())
3806}
3807
3808// ---- Issue / PR link commands (issue #67) -------------------------------
3809
3810/// Resolve the repo + branch + repo-relative path to operate on.
3811///
3812/// `--worktree <pattern>` overrides; otherwise we use the current directory.
3813/// The returned Repository is opened *at the target worktree*, so reading
3814/// HEAD gives the branch the user expects, but git config writes still land
3815/// on the main repo's config (git2 propagates branch.* config up).
3816fn resolve_target_repo(worktree: Option<String>) -> Result<(Repository, String, PathBuf)> {
3817  let path: PathBuf = match worktree {
3818    Some(pat) => {
3819      // Allow either a fuzzy worktree pattern or a direct path.
3820      let p = PathBuf::from(&pat);
3821      if p.is_dir() {
3822        p
3823      } else {
3824        let main = worktree::discover_repo(None)?;
3825        worktree::find_fuzzy(&main, &pat)?.path
3826      }
3827    }
3828    None => std::env::current_dir()?,
3829  };
3830  let repo = Repository::discover(&path).map_err(|_| GwmError::NotInGitRepo)?;
3831  let branch = current_branch(&repo)?;
3832  Ok((repo, branch, path))
3833}
3834
3835/// The branch checked out *at* `start`, or at the current directory when
3836/// `start` is `None`.
3837///
3838/// Issue #477. [`worktree::discover_repo`] deliberately walks back to the
3839/// main working directory when it lands inside a linked worktree, which is
3840/// what every command operating on the whole worktree **set** needs: `list`,
3841/// `remove`, `switch`, `prune` all want the one handle that knows about all
3842/// of them. Asking that handle "which branch am I on" answers for the main
3843/// checkout instead, so `gwm commit-prefix` run by the bundled `commit-msg`
3844/// hook — which git invokes with the working directory inside the worktree —
3845/// derived its prefix from whatever the main checkout was sitting on.
3846///
3847/// So this discovers without the walk-back. It is deliberately *not* a
3848/// replacement for `repo_context`: `.gwm.toml`, the workdir and
3849/// [`worktree::repo_name`] all still want the main repo, and `{repo}` is a
3850/// legal token in `branch_pattern`, so widening this to the whole context
3851/// would compile the branch parser with the worktree directory's name and
3852/// stop reading branches gwm itself wrote. Only the branch moves.
3853///
3854/// [`resolve_target_repo`] already had the right shape, which is why
3855/// `gwm status` reported the right branch from the same directory all along.
3856fn current_branch_at(start: Option<&Path>) -> Result<String> {
3857  let from = match start {
3858    Some(p) => p.to_path_buf(),
3859    None => std::env::current_dir()?,
3860  };
3861  let repo = Repository::discover(&from).map_err(|_| GwmError::NotInGitRepo)?;
3862  current_branch(&repo)
3863}
3864
3865fn current_branch(repo: &Repository) -> Result<String> {
3866  let head = repo.head().map_err(|_| GwmError::UnbornHead {
3867    reason: "HEAD is unborn or detached".into(),
3868  })?;
3869  head
3870    .shorthand()
3871    .ok()
3872    .map(|s| s.to_string())
3873    .ok_or_else(|| GwmError::UnbornHead {
3874      reason: "HEAD has no shorthand (detached?)".into(),
3875    })
3876}
3877
3878fn cmd_link(target: LinkTarget, number: u64, worktree: Option<String>) -> Result<()> {
3879  let (repo, branch, _path) = resolve_target_repo(worktree)?;
3880  // Write under the backend marker that will later be checked against
3881  // this line, or the next command that resolves a forge deletes it.
3882  let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
3883  forge::reconcile_links(&repo, &config);
3884  match target {
3885    LinkTarget::Issue => {
3886      github::link_issue(&repo, &branch, number)?;
3887      println!("✓ linked issue #{} to branch {}", number, branch);
3888    }
3889    LinkTarget::Pr => {
3890      github::link_pr(&repo, &branch, number)?;
3891      println!("✓ linked PR #{} to branch {}", number, branch);
3892    }
3893  }
3894  Ok(())
3895}
3896
3897fn cmd_unlink(target: LinkTarget, worktree: Option<String>) -> Result<()> {
3898  let (repo, branch, _path) = resolve_target_repo(worktree)?;
3899  match target {
3900    LinkTarget::Issue => {
3901      github::unlink_issue(&repo, &branch)?;
3902      println!("✓ unlinked issue on branch {}", branch);
3903    }
3904    LinkTarget::Pr => {
3905      github::unlink_pr(&repo, &branch)?;
3906      println!("✓ unlinked PR on branch {}", branch);
3907    }
3908  }
3909  Ok(())
3910}
3911
3912fn cmd_open(target: LinkTarget, worktree: Option<String>, print_url: bool) -> Result<()> {
3913  let (repo, branch, _path) = resolve_target_repo(worktree)?;
3914  // Resolve first: `forge::resolve` reconciles the persisted links
3915  // against the backend about to read them, and `gwm open` is exactly
3916  // the command that would otherwise send the user to the stale
3917  // number's page one last time.
3918  let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
3919  let forge = forge::resolve(&repo, &config)?;
3920  let link = github::read_link(&repo, &branch)?;
3921
3922  let url = match target {
3923    LinkTarget::Issue => {
3924      let n = link.issue.ok_or_else(|| GwmError::LinkMissing {
3925        kind: LinkKind::Issue,
3926        branch: branch.clone(),
3927      })?;
3928      forge.issue_url_confirmed(n)
3929    }
3930    LinkTarget::Pr => {
3931      let n = link.pr.ok_or_else(|| GwmError::LinkMissing {
3932        kind: LinkKind::Pr,
3933        branch: branch.clone(),
3934      })?;
3935      forge.pr_url_confirmed(n)
3936    }
3937  };
3938
3939  if print_url {
3940    println!("{}", url);
3941    return Ok(());
3942  }
3943  spawn_opener(&url)
3944}
3945
3946fn spawn_opener(url: &str) -> Result<()> {
3947  let opener = if cfg!(target_os = "macos") {
3948    "open"
3949  } else if cfg!(target_os = "windows") {
3950    "explorer"
3951  } else {
3952    "xdg-open"
3953  };
3954  let status = std::process::Command::new(opener)
3955    .arg(url)
3956    .status()
3957    .map_err(|e| GwmError::CommandFailed(format!("could not spawn {}: {}", opener, e)))?;
3958  if !status.success() {
3959    return Err(GwmError::CommandFailed(format!(
3960      "{} exited with status {:?}",
3961      opener,
3962      status.code()
3963    )));
3964  }
3965  Ok(())
3966}
3967
3968fn cmd_status(worktree: Option<String>, json: bool) -> Result<()> {
3969  let (repo, branch, _path) = resolve_target_repo(worktree)?;
3970
3971  // Forge + fetched status are best-effort: if there's no remote or the
3972  // forge CLI isn't installed, we still print the local link.
3973  let config = Config::load_for_repo(repo.workdir().unwrap_or_else(|| repo.path()))?;
3974  let forge = forge::resolve(&repo, &config).ok();
3975  let slug = forge.as_ref().map(|f| f.slug().to_string());
3976  // When a remote is present, auto-detect the branch's PR if none is
3977  // explicitly linked (issue #181). Falls back to the plain local read
3978  // with no remote — keeping the "local link only" mode network-free.
3979  let link = match forge.as_ref() {
3980    Some(f) => github::read_link_with_pr_detection(&repo, &branch, f.as_ref())?,
3981    None => github::read_link(&repo, &branch)?,
3982  };
3983  let (issue_status, pr_status) = fetch_link_status(&repo, &branch, &link, forge.as_deref());
3984
3985  if json {
3986    println!(
3987      "{}",
3988      build_status_json(&branch, slug.as_deref(), &link, &issue_status, &pr_status)
3989    );
3990  } else {
3991    let pr_noun = forge.as_ref().map_or("PR", |f| f.pr_noun());
3992    print_status_human(&branch, slug.as_deref(), &link, &issue_status, &pr_status, pr_noun);
3993  }
3994  Ok(())
3995}
3996
3997fn fetch_link_status(
3998  repo: &Repository,
3999  branch: &str,
4000  link: &BranchLink,
4001  forge: Option<&dyn forge::Forge>,
4002) -> (Option<IssueStatus>, Option<PrStatus>) {
4003  let Some(forge) = forge else {
4004    return (None, None);
4005  };
4006  // The forge CLI is optional — if either call fails we degrade gracefully.
4007  let issue = link.issue.and_then(|n| forge.fetch_issue(n).ok());
4008  let pr = link.pr.and_then(|n| forge.fetch_pr(n).ok());
4009  if let Some(issue) = &issue {
4010    let _ = github::persist_issue_title(repo, branch, &issue.title);
4011    let _ = github::persist_issue_state(repo, branch, issue.state);
4012  }
4013  if let Some(pr) = &pr {
4014    let _ = match link.pr_source {
4015      LinkSource::Detected => github::persist_detected_pr_title(repo, branch, &pr.title)
4016        .and_then(|()| github::persist_detected_pr_state(repo, branch, pr.state)),
4017      LinkSource::Explicit => github::persist_pr_title(repo, branch, &pr.title)
4018        .and_then(|()| github::persist_pr_state(repo, branch, pr.state)),
4019      LinkSource::BranchName | LinkSource::None => Ok(()),
4020    };
4021  }
4022  (issue, pr)
4023}
4024
4025fn issue_state_str(s: IssueState) -> &'static str {
4026  match s {
4027    IssueState::Open => "open",
4028    IssueState::Closed => "closed",
4029  }
4030}
4031
4032fn pr_state_str(s: PrState) -> &'static str {
4033  match s {
4034    PrState::Open => "open",
4035    PrState::Draft => "draft",
4036    PrState::Closed => "closed",
4037    PrState::Merged => "merged",
4038  }
4039}
4040
4041fn link_source_str(s: LinkSource) -> &'static str {
4042  match s {
4043    LinkSource::None => "none",
4044    LinkSource::BranchName => "branch-name",
4045    LinkSource::Explicit => "explicit",
4046    LinkSource::Detected => "detected",
4047  }
4048}
4049
4050fn print_status_human(
4051  branch: &str,
4052  slug: Option<&str>,
4053  link: &BranchLink,
4054  issue: &Option<IssueStatus>,
4055  pr: &Option<PrStatus>,
4056  // "PR" / "MR" (issue #419). The `issue:` / `pr:` field labels below stay
4057  // put: they are output *keys* a script greps for, not prose, and the
4058  // `--json` payload freezes the same names.
4059  pr_noun: &str,
4060) {
4061  println!("branch: {}", branch);
4062  if let Some(s) = slug {
4063    println!("repo:   {}", s);
4064  }
4065  println!("link:   {}", link.summary(pr_noun));
4066
4067  if let Some(n) = link.issue {
4068    print!("issue:  #{}", n);
4069    match issue {
4070      Some(s) => println!(" [{}] {}", issue_state_str(s.state), s.title),
4071      None => println!(" (status unavailable)"),
4072    }
4073  }
4074  if let Some(n) = link.pr {
4075    print!("pr:     #{}", n);
4076    match pr {
4077      Some(s) => {
4078        let checks = if s.checks_total > 0 {
4079          format!(" · checks {}/{}", s.checks_passed, s.checks_total)
4080        } else {
4081          String::new()
4082        };
4083        println!(" [{}]{} {}", pr_state_str(s.state), checks, s.title);
4084      }
4085      None => println!(" (status unavailable)"),
4086    }
4087  }
4088}
4089
4090/// Build the `gwm status --json` payload — a stable, hand-built schema for
4091/// scripting (frozen by `tests/contract_tests.rs`, documented in
4092/// `docs/schema/status.schema.json`, issue #317). Pure: returns the value so
4093/// the contract test can pin its shape without spawning the binary or hitting
4094/// GitHub. `print`-ing is the caller's job.
4095pub fn build_status_json(
4096  branch: &str,
4097  slug: Option<&str>,
4098  link: &BranchLink,
4099  issue: &Option<IssueStatus>,
4100  pr: &Option<PrStatus>,
4101) -> serde_json::Value {
4102  let mut obj = serde_json::Map::new();
4103  obj.insert("branch".into(), serde_json::Value::String(branch.into()));
4104  if let Some(s) = slug {
4105    obj.insert("repo".into(), serde_json::Value::String(s.into()));
4106  }
4107  obj.insert(
4108    "issue".into(),
4109    match link.issue {
4110      Some(n) => {
4111        let mut o = serde_json::Map::new();
4112        o.insert("number".into(), serde_json::Value::Number(n.into()));
4113        o.insert(
4114          "source".into(),
4115          serde_json::Value::String(link_source_str(link.issue_source).into()),
4116        );
4117        if let Some(s) = issue {
4118          o.insert(
4119            "state".into(),
4120            serde_json::Value::String(issue_state_str(s.state).into()),
4121          );
4122          o.insert("title".into(), serde_json::Value::String(s.title.clone()));
4123          o.insert(
4124            "labels".into(),
4125            serde_json::Value::Array(s.labels.iter().map(|l| serde_json::Value::String(l.clone())).collect()),
4126          );
4127          o.insert("url".into(), serde_json::Value::String(s.url.clone()));
4128        }
4129        serde_json::Value::Object(o)
4130      }
4131      None => serde_json::Value::Null,
4132    },
4133  );
4134  obj.insert(
4135    "pr".into(),
4136    match link.pr {
4137      Some(n) => {
4138        let mut o = serde_json::Map::new();
4139        o.insert("number".into(), serde_json::Value::Number(n.into()));
4140        o.insert(
4141          "source".into(),
4142          serde_json::Value::String(link_source_str(link.pr_source).into()),
4143        );
4144        if let Some(s) = pr {
4145          o.insert("state".into(), serde_json::Value::String(pr_state_str(s.state).into()));
4146          o.insert("title".into(), serde_json::Value::String(s.title.clone()));
4147          o.insert(
4148            "checks_passed".into(),
4149            serde_json::Value::Number(s.checks_passed.into()),
4150          );
4151          o.insert("checks_total".into(), serde_json::Value::Number(s.checks_total.into()));
4152          o.insert("url".into(), serde_json::Value::String(s.url.clone()));
4153        }
4154        serde_json::Value::Object(o)
4155      }
4156      None => serde_json::Value::Null,
4157    },
4158  );
4159  serde_json::Value::Object(obj)
4160}
4161
4162// ---- Labels commands (issue #81) ----------------------------------------
4163
4164fn cmd_labels(action: LabelsAction) -> Result<()> {
4165  match action {
4166    LabelsAction::List => cmd_labels_list(),
4167    LabelsAction::Push {
4168      dry_run,
4169      prune,
4170      random_colors,
4171    } => cmd_labels_push(dry_run, prune, random_colors),
4172  }
4173}
4174
4175fn cmd_labels_list() -> Result<()> {
4176  let config = load_labels_config()?;
4177  if config.labels.is_empty() {
4178    println!("0 labels declared in .gwm.toml — nothing to push.");
4179    return Ok(());
4180  }
4181  // Resolve (and validate colours) before touching the network, so a
4182  // typo in `.gwm.toml` surfaces "label 'bug' has invalid color: …"
4183  // rather than the unrelated "no origin remote" error.
4184  let declared = labels::resolve_labels(&config.labels, false)?;
4185  let forge = labels_forge(&config)?;
4186  let remote = forge.fetch_remote_labels()?;
4187  let diff = labels::diff_labels(&declared, &remote);
4188  print_labels_diff(forge.slug(), &declared, &diff);
4189  Ok(())
4190}
4191
4192fn cmd_labels_push(dry_run: bool, prune: bool, random_colors: bool) -> Result<()> {
4193  let config = load_labels_config()?;
4194  if config.labels.is_empty() {
4195    println!("0 labels declared in .gwm.toml — nothing to push.");
4196    return Ok(());
4197  }
4198  let declared = labels::resolve_labels(&config.labels, random_colors)?;
4199  let forge = labels_forge(&config)?;
4200  let remote = forge.fetch_remote_labels()?;
4201  let diff = labels::diff_labels(&declared, &remote);
4202  let (n_create, n_update, n_match, n_extra) = diff.counts();
4203
4204  // Before the dry-run branch, mirroring the milestone path: a prune
4205  // that trips over a hostile remote label name must not have deleted
4206  // half the batch first, and a dry-run must not advertise a plan that
4207  // cannot run (Codex review #458).
4208  if prune {
4209    for remote_label in &diff.extra_on_remote {
4210      labels::validate_label_name(&remote_label.name).map_err(|e| {
4211        let inner = match e {
4212          GwmError::Config(msg) => msg,
4213          other => other.to_string(),
4214        };
4215        GwmError::Config(format!("labels (remote): {inner} — refusing to prune"))
4216      })?;
4217    }
4218  }
4219
4220  if dry_run {
4221    print_labels_diff(forge.slug(), &declared, &diff);
4222    let pruned = if prune { n_extra } else { 0 };
4223    println!(
4224      "{}",
4225      labels::diff_dry_run_line(n_create, n_update, n_match, n_extra, pruned)
4226    );
4227    return Ok(());
4228  }
4229
4230  for spec in &diff.to_create {
4231    forge.create_label(spec)?;
4232    println!("✓ created {}", spec.name);
4233  }
4234  for upd in &diff.to_update {
4235    forge.update_label(&upd.spec)?;
4236    println!("✓ updated {}", upd.spec.name);
4237  }
4238  if prune {
4239    for remote_label in &diff.extra_on_remote {
4240      forge.delete_label(&remote_label.name)?;
4241      println!("✗ pruned {}", remote_label.name);
4242    }
4243  } else if !diff.extra_on_remote.is_empty() {
4244    println!(
4245      "{} label(s) on remote not in config — pass --prune to delete",
4246      diff.extra_on_remote.len()
4247    );
4248  }
4249  println!("{} label(s) untouched", n_match);
4250  Ok(())
4251}
4252
4253/// Open the repo and parse `.gwm.toml`. Shared by `labels list /
4254/// push`; both surface a uniform "not inside a git repository" error
4255/// before they touch network or config-resolve logic.
4256fn load_labels_config() -> Result<Config> {
4257  Ok(repo_context(None)?.config)
4258}
4259
4260/// Resolve the `origin` remote slug. Called *after* `resolve_labels`
4261/// in both subcommands so a config typo (bad colour) surfaces with
4262/// the offending label name rather than the unrelated "no origin
4263/// remote" error.
4264/// Resolve the forge for the discovered repo. Called *after*
4265/// `resolve_labels` / `resolve_milestones` in all four subcommands so a
4266/// config typo (bad colour, bad due_on) surfaces with the offending entry
4267/// name rather than the unrelated "no origin remote" error.
4268fn labels_forge(config: &Config) -> Result<std::sync::Arc<dyn forge::Forge>> {
4269  let repo = worktree::discover_repo(None)?;
4270  forge::resolve(&repo, config)
4271}
4272
4273fn print_labels_diff(slug: &str, declared: &[labels::LabelSpec], diff: &LabelDiff) {
4274  for line in labels_diff_lines(slug, declared, diff) {
4275    println!("{}", line);
4276  }
4277}
4278
4279/// The rows `gwm labels list` / `push --dry-run` print, as values (issue
4280/// #473). Same seam and same reason as [`milestones_diff_lines`].
4281///
4282/// A declared `name` is the least exposed field here: `labels::
4283/// validate_label_name` already rejects it at load. But it rejects
4284/// `is_ascii_control` only, which leaves the C1 range (U+0080..U+009F, CSI
4285/// among them) through, and `remote.name` / `slug` come off the forge rather
4286/// than the config and are validated by nobody.
4287pub fn labels_diff_lines(slug: &str, declared: &[labels::LabelSpec], diff: &LabelDiff) -> Vec<String> {
4288  let clean = crate::naming::sanitise_for_terminal;
4289  let (n_create, n_update, n_match, n_extra) = diff.counts();
4290  let mut lines = vec![format!(
4291    "declared in .gwm.toml: {} labels — diff against {}:",
4292    declared.len(),
4293    clean(slug)
4294  )];
4295  for spec in &diff.to_create {
4296    lines.push(format!(
4297      "  + {:<20} (will create, color #{})",
4298      clean(&spec.name),
4299      clean(&spec.color)
4300    ));
4301  }
4302  for upd in &diff.to_update {
4303    let detail = match (&upd.previous_color, &upd.previous_description) {
4304      (Some(old), _) => format!("color #{} → #{}", old, upd.spec.color),
4305      (None, Some(_)) => "description changed".into(),
4306      _ => "diff".into(),
4307    };
4308    lines.push(format!("  ~ {:<20} ({})", clean(&upd.spec.name), clean(&detail)));
4309  }
4310  for spec in &diff.matching {
4311    lines.push(format!("  = {:<20} (match)", clean(&spec.name)));
4312  }
4313  for remote in &diff.extra_on_remote {
4314    lines.push(format!("  - {:<20} (on remote, not in config)", clean(&remote.name)));
4315  }
4316  lines.push(labels::diff_summary_line(n_create, n_update, n_match, n_extra));
4317  lines
4318}
4319
4320// ---- Milestones commands (issue #82) ------------------------------------
4321
4322fn cmd_milestones(action: MilestonesAction) -> Result<()> {
4323  match action {
4324    MilestonesAction::List => cmd_milestones_list(),
4325    MilestonesAction::Push { dry_run, prune } => cmd_milestones_push(dry_run, prune),
4326  }
4327}
4328
4329fn cmd_milestones_list() -> Result<()> {
4330  let config = load_milestones_config()?;
4331  if config.milestones.is_empty() {
4332    println!("0 milestones declared in .gwm.toml — nothing to push.");
4333    return Ok(());
4334  }
4335  // Resolve (and validate due_on / state) before touching the network,
4336  // so a typo in `.gwm.toml` surfaces "milestone 'v0.7.0' has invalid
4337  // …" rather than the unrelated "no origin remote" error.
4338  let declared = milestones::resolve_milestones(&config.milestones)?;
4339  let forge = labels_forge(&config)?;
4340  let remote = forge.fetch_remote_milestones()?;
4341  let diff = milestones::diff_milestones(&declared, &remote);
4342  print_milestones_diff(forge.slug(), &declared, &diff);
4343  Ok(())
4344}
4345
4346fn cmd_milestones_push(dry_run: bool, prune: bool) -> Result<()> {
4347  let config = load_milestones_config()?;
4348  if config.milestones.is_empty() {
4349    println!("0 milestones declared in .gwm.toml — nothing to push.");
4350    return Ok(());
4351  }
4352  let declared = milestones::resolve_milestones(&config.milestones)?;
4353  let forge = labels_forge(&config)?;
4354  let remote = forge.fetch_remote_milestones()?;
4355  let diff = milestones::diff_milestones(&declared, &remote);
4356  let (n_create, n_update, n_match, n_extra) = diff.counts();
4357
4358  // Before the dry-run branch on purpose: a plan the forge will reject
4359  // must not be printed as runnable, and a real push must not apply half
4360  // the batch before hitting the bad entry (Codex review #458).
4361  for spec in &diff.to_create {
4362    forge.validate_milestone(spec)?;
4363  }
4364  for upd in &diff.to_update {
4365    forge.validate_milestone(&upd.spec)?;
4366  }
4367
4368  if dry_run {
4369    print_milestones_diff(forge.slug(), &declared, &diff);
4370    let pruned = if prune { n_extra } else { 0 };
4371    println!(
4372      "{}",
4373      labels::diff_dry_run_line(n_create, n_update, n_match, n_extra, pruned)
4374    );
4375    return Ok(());
4376  }
4377
4378  for spec in &diff.to_create {
4379    forge.create_milestone(spec)?;
4380    println!("✓ created {}", spec.title);
4381  }
4382  for upd in &diff.to_update {
4383    forge.update_milestone(upd.number, &upd.spec)?;
4384    println!("✓ updated {}", upd.spec.title);
4385  }
4386  if prune {
4387    for remote_milestone in &diff.extra_on_remote {
4388      forge.delete_milestone(remote_milestone.number)?;
4389      println!("✗ pruned {}", remote_milestone.title);
4390    }
4391  } else if !diff.extra_on_remote.is_empty() {
4392    println!(
4393      "{} milestone(s) on remote not in config — pass --prune to delete",
4394      diff.extra_on_remote.len()
4395    );
4396  }
4397  println!("{} milestone(s) untouched", n_match);
4398  Ok(())
4399}
4400
4401/// Open the repo and parse `.gwm.toml`. Shared by `milestones list /
4402/// push`; both surface a uniform "not inside a git repository" error
4403/// before they touch network or config-resolve logic.
4404fn load_milestones_config() -> Result<Config> {
4405  Ok(repo_context(None)?.config)
4406}
4407
4408fn print_milestones_diff(slug: &str, declared: &[milestones::MilestoneSpec], diff: &MilestoneDiff) {
4409  for line in milestones_diff_lines(slug, declared, diff) {
4410    println!("{}", line);
4411  }
4412}
4413
4414/// The rows `gwm milestones list` / `push --dry-run` print, as values rather
4415/// than `println!` side effects (issue #473).
4416///
4417/// A value because the printer is only reachable after a live forge round
4418/// trip (`fetch_remote_milestones`), so there is no way to assert on it from a
4419/// test without mocking `gh`. Unlike a label name, a milestone `title` is free
4420/// text that nothing validates on load, and `gwm milestones list` reads
4421/// `.gwm.toml` without the trust gate.
4422pub fn milestones_diff_lines(slug: &str, declared: &[milestones::MilestoneSpec], diff: &MilestoneDiff) -> Vec<String> {
4423  let clean = crate::naming::sanitise_for_terminal;
4424  let (n_create, n_update, n_match, n_extra) = diff.counts();
4425  let mut lines = vec![format!(
4426    "declared in .gwm.toml: {} milestones — diff against {}:",
4427    declared.len(),
4428    clean(slug)
4429  )];
4430  for spec in &diff.to_create {
4431    let due = spec.due_on.as_deref().unwrap_or("no due date");
4432    lines.push(format!(
4433      "  + {:<20} (will create, state {}, due {})",
4434      clean(&spec.title),
4435      spec.state.as_str(),
4436      clean(due)
4437    ));
4438  }
4439  for upd in &diff.to_update {
4440    let detail = match (&upd.previous_due_on, &upd.previous_state, &upd.previous_description) {
4441      (Some(old_due), _, _) => format!("due {} → {}", old_due, upd.spec.due_on.as_deref().unwrap_or("cleared")),
4442      (None, Some(old_state), _) => format!("state {} → {}", old_state.as_str(), upd.spec.state.as_str()),
4443      (None, None, Some(_)) => "description changed".into(),
4444      _ => "diff".into(),
4445    };
4446    lines.push(format!("  ~ {:<20} ({})", clean(&upd.spec.title), clean(&detail)));
4447  }
4448  for spec in &diff.matching {
4449    lines.push(format!("  = {:<20} (match)", clean(&spec.title)));
4450  }
4451  for remote in &diff.extra_on_remote {
4452    lines.push(format!(
4453      "  - {:<20} (#{} on remote, not in config)",
4454      clean(&remote.title),
4455      remote.number
4456    ));
4457  }
4458  lines.push(labels::diff_summary_line(n_create, n_update, n_match, n_extra));
4459  lines
4460}
4461
4462// ---- Trust ledger commands (issue #95) ----------------------------------
4463
4464fn cmd_trust(action: TrustAction) -> Result<()> {
4465  match action {
4466    TrustAction::Add => cmd_trust_add(),
4467    TrustAction::List => cmd_trust_list(),
4468    TrustAction::Revoke { origin } => cmd_trust_revoke(origin),
4469    TrustAction::Show => cmd_trust_show(),
4470  }
4471}
4472
4473fn cmd_trust_list() -> Result<()> {
4474  let path = trust::default_ledger_path()?;
4475  let ledger = TrustLedger::load(&path)?;
4476  if ledger.entries.is_empty() {
4477    println!("0 entries in trust ledger ({}).", path.display());
4478    return Ok(());
4479  }
4480  println!("trust ledger: {}", path.display());
4481  println!(
4482    "  {} entr{} recorded:",
4483    ledger.entries.len(),
4484    if ledger.entries.len() == 1 { "y" } else { "ies" }
4485  );
4486  // Issue #473, Codex pass 2: `trust show` cats the ledger file, where TOML
4487  // has already escaped any control byte in a value, but `load` DECODES it, so
4488  // every command that reads the ledger back through `TrustLedger` handles the
4489  // real character. `origin` is a remote URL that arrived with a clone, and
4490  // this listing is exactly what someone runs to audit what they trusted.
4491  let clean = crate::naming::sanitise_for_terminal;
4492  let origin_w = ledger
4493    .entries
4494    .iter()
4495    .map(|e| clean(&e.origin).len())
4496    .max()
4497    .unwrap_or(6)
4498    .clamp(6, 60);
4499  for e in &ledger.entries {
4500    // First 12 chars of the sha256 is plenty for a visual diff; the
4501    // full digest still ships in the toml file for forensic use.
4502    // Truncate by chars (not bytes) so a hand-edited ledger with a
4503    // multi-byte `config_sha` (corrupt but parseable TOML) renders
4504    // instead of panicking on a UTF-8 boundary.
4505    let short_sha: String = e.config_sha.chars().take(12).collect();
4506    println!(
4507      "  {:<ow$}  {}  trusted_at {}  by {}",
4508      clean(&e.origin),
4509      clean(&short_sha),
4510      e.trusted_at.to_rfc3339(),
4511      clean(&e.trusted_by),
4512      ow = origin_w,
4513    );
4514  }
4515  Ok(())
4516}
4517
4518fn cmd_trust_revoke(origin: String) -> Result<()> {
4519  let path = trust::default_ledger_path()?;
4520  let mut ledger = TrustLedger::load(&path)?;
4521  let removed = ledger.revoke(&origin);
4522  // Echoed back rather than read from the ledger, but it lands in the same
4523  // terminal and costs one call (issue #473).
4524  let shown = crate::naming::sanitise_for_terminal(&origin);
4525  if removed == 0 {
4526    println!("0 entries matched origin {} (nothing to revoke).", shown);
4527    return Ok(());
4528  }
4529  ledger.save(&path)?;
4530  println!(
4531    "✓ revoked {} entr{} for {}",
4532    removed,
4533    if removed == 1 { "y" } else { "ies" },
4534    shown
4535  );
4536  Ok(())
4537}
4538
4539fn cmd_trust_add() -> Result<()> {
4540  let cwd = std::env::current_dir()?;
4541  let repo = Repository::discover(&cwd).map_err(|_| GwmError::NotInGitRepo)?;
4542  let workdir = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf();
4543  // Same key every other gate uses — see `trust::origin_key_for_repo`.
4544  let key = trust::origin_key_for_repo(&repo, &workdir);
4545  match trust::record_config(&workdir, &key, &trust::current_actor())? {
4546    Some(sha) => {
4547      let short: String = sha.chars().take(12).collect();
4548      // `key` is the repo's origin URL (issue #473).
4549      println!(
4550        "✓ trusted {} (.gwm.toml {})",
4551        crate::naming::sanitise_for_terminal(&key),
4552        short
4553      );
4554      Ok(())
4555    }
4556    None => Err(GwmError::Other(format!(
4557      "no .gwm.toml in {} — there is nothing to trust here",
4558      workdir.display()
4559    ))),
4560  }
4561}
4562
4563fn cmd_trust_show() -> Result<()> {
4564  let path = trust::default_ledger_path()?;
4565  println!("ledger path: {}", path.display());
4566  match std::fs::read_to_string(&path) {
4567    Ok(body) => {
4568      // Issue #473: the ledger records one origin key per trusted repo, and
4569      // an origin is a remote URL that arrived with a clone. Block variant,
4570      // the ledger is a file and its rows are its shape.
4571      let body = crate::naming::sanitise_block_for_terminal(&body);
4572      println!("---");
4573      print!("{}", body);
4574      if !body.ends_with('\n') {
4575        println!();
4576      }
4577    }
4578    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4579      println!("(file does not exist yet — nothing has been trusted on this machine)");
4580    }
4581    Err(e) => return Err(e.into()),
4582  }
4583  Ok(())
4584}
4585
4586/// TOFU gate called by `cmd_create` and `cmd_bootstrap` before any
4587/// `bootstrap::run` invocation. The contract:
4588///
4589///   * Returns `Ok(())` when the caller is cleared to proceed.
4590///   * Returns `Err(GwmError::Other(..))` when the user declined,
4591///     `--deny-bootstrap` was passed, or stdin isn't interactive and
4592///     no `--allow-bootstrap` bypass was provided.
4593///   * No-ops silently when there is no `.gwm.toml` in the workdir
4594///     (nothing for bootstrap to execute — no trust decision needed).
4595///
4596/// The `repo` is passed in so we can read `origin` from the existing
4597/// `Repository` handle (already opened by every caller) without
4598/// re-discovering it. Falls back to the canonical workdir path when
4599/// there is no origin remote — local-only repos still benefit from
4600/// the drift-detection half of the feature even when the threat model
4601/// is weaker.
4602fn trust_or_prompt(workdir: &Path, repo: Option<&Repository>, mode: TrustMode) -> Result<()> {
4603  let origin_key = match repo {
4604    Some(r) => trust::origin_key_for_repo(r, workdir),
4605    None => trust::resolve_origin_key(None, workdir),
4606  };
4607
4608  match trust::evaluate(workdir, &origin_key, mode)? {
4609    TrustOutcome::Proceed => Ok(()),
4610    TrustOutcome::Refuse { message } => Err(GwmError::Other(message)),
4611    TrustOutcome::Prompt {
4612      cfg_path,
4613      body,
4614      sha,
4615      origin,
4616      mut ledger,
4617      ledger_path,
4618    } => {
4619      // Refuse cleanly if stdin isn't a tty rather than hanging on a
4620      // read that will never see input — this is the case that makes
4621      // `--allow-bootstrap` actually load-bearing in CI.
4622      use std::io::IsTerminal;
4623      if !std::io::stdin().is_terminal() {
4624        return Err(GwmError::Other(format!(
4625          ".gwm.toml at {} is not in the trust ledger and stdin is not interactive — \
4626           pass --allow-bootstrap (or set GWM_ALLOW_BOOTSTRAP=1) to bypass, \
4627           or run interactively to approve",
4628          cfg_path.display()
4629        )));
4630      }
4631
4632      let granted = prompt_user(&cfg_path, &body, &origin, &sha)?;
4633      if !granted {
4634        return Err(GwmError::Other(format!(
4635          "trust prompt declined for {} — aborting bootstrap",
4636          cfg_path.display()
4637        )));
4638      }
4639
4640      ledger.record(&origin, &sha, &trust::current_actor());
4641      ledger.save(&ledger_path)?;
4642      println!(
4643        "✓ recorded trust for {} in {}",
4644        crate::naming::sanitise_for_terminal(&origin),
4645        ledger_path.display()
4646      );
4647      Ok(())
4648    }
4649  }
4650}
4651
4652/// Pull the `origin` remote URL out of a Repository handle, if there
4653/// is one. Returns `None` for repos with no `origin` remote — caller
4654/// (or `trust::resolve_origin_key`) falls back to the canonical
4655/// workdir path in that case.
4656/// Interactive y/N/show loop. Prints a one-shot summary of the
4657/// bootstrap surface (copy targets, guards, command lines, no-symlink
4658/// declarations) so the user has the relevant signal before answering.
4659/// `show` re-prints the raw `.gwm.toml`.
4660fn prompt_user(cfg_path: &Path, bytes: &[u8], origin: &str, sha: &str) -> Result<bool> {
4661  use std::io::{BufRead, Write};
4662
4663  let body = String::from_utf8_lossy(bytes);
4664  let parsed: Option<Config> = toml::from_str(&body).ok();
4665  let stdin = std::io::stdin();
4666  let mut stdout = std::io::stdout();
4667
4668  println!();
4669  println!("gwm: this repo's .gwm.toml has not been trusted yet.");
4670  println!("     path   : {}", cfg_path.display());
4671  // Issue #473: `origin` is a remote URL out of `.git/config`, which travels
4672  // with a clone just like `.gwm.toml` does. Nothing here has been vetted yet
4673  // That is what the prompt below is asking.
4674  println!("     origin : {}", crate::naming::sanitise_for_terminal(origin));
4675  println!("     hash   : {}", sha);
4676  if let Some(cfg) = parsed.as_ref() {
4677    print_bootstrap_summary(cfg);
4678  } else {
4679    println!("     (could not parse .gwm.toml for summary — see raw via `show` below)");
4680  }
4681  println!();
4682
4683  loop {
4684    print!("Trust this .gwm.toml? [y/N/show]: ");
4685    stdout.flush().ok();
4686    let mut line = String::new();
4687    let n = stdin.lock().read_line(&mut line)?;
4688    if n == 0 {
4689      // EOF without an answer — same conservative default as `N`.
4690      return Ok(false);
4691    }
4692    match line.trim().to_ascii_lowercase().as_str() {
4693      "y" | "yes" => return Ok(true),
4694      "n" | "no" | "" => return Ok(false),
4695      "show" | "s" => {
4696        // Issue #473: the block variant, because this IS the file and its
4697        // line breaks are its shape. Neutralising the rest is not "breaking
4698        // raw": an escape sequence buried in the body defeats the very
4699        // inspection `show` exists to provide.
4700        let body = crate::naming::sanitise_block_for_terminal(&body);
4701        println!("---");
4702        print!("{}", body);
4703        if !body.ends_with('\n') {
4704          println!();
4705        }
4706        println!("---");
4707      }
4708      other => {
4709        println!(
4710          "unrecognised answer '{}': answer y, N, or show",
4711          crate::naming::sanitise_for_terminal(other)
4712        );
4713      }
4714    }
4715  }
4716}
4717
4718fn print_bootstrap_summary(cfg: &Config) {
4719  for line in bootstrap_summary_lines(cfg) {
4720    println!("{}", line);
4721  }
4722}
4723
4724/// The lines the TOFU prompt shows for an **untrusted** `.gwm.toml`, as
4725/// values rather than as `println!` side effects (issue #473).
4726///
4727/// A value so the highest-stakes echo in the binary can be asserted on
4728/// without driving a PTY: this summary is rendered immediately above
4729/// `Trust this .gwm.toml? [y/N/show]:`, from a file the user has by
4730/// definition not vetted, and it is the only thing standing between them
4731/// and a `[[bootstrap.command]]` that runs arbitrary shell.
4732pub fn bootstrap_summary_lines(cfg: &Config) -> Vec<String> {
4733  let bs = &cfg.bootstrap;
4734  if bs.copy.is_empty() && bs.command.is_empty() && bs.guard.is_empty() && bs.no_symlink.is_empty() {
4735    return vec!["     bootstrap surface: (empty, no copies/commands/guards/no_symlinks declared)".to_string()];
4736  }
4737  // Issue #473: every field below is verbatim text from a file the user has
4738  // NOT trusted, which is the whole premise of the prompt this feeds. Left
4739  // raw, `\u{1b}[1A\u{1b}[2K` in a `[[bootstrap.command]]` name walks the
4740  // cursor up and erases the row above, so the malicious `run` line can
4741  // delete the very evidence the summary exists to show.
4742  let clean = crate::naming::sanitise_for_terminal;
4743  let mut lines = vec!["     bootstrap surface:".to_string()];
4744  for c in &bs.copy {
4745    lines.push(format!("       - copy   {} → {}", clean(&c.from), clean(&c.to)));
4746  }
4747  for g in &bs.guard {
4748    lines.push(format!(
4749      "       - guard  {} (on_match={}, deny={} pattern(s))",
4750      clean(&g.name),
4751      g.on_match,
4752      g.deny_patterns.len()
4753    ));
4754  }
4755  for ns in &bs.no_symlink {
4756    lines.push(format!("       - no-symlink {}", clean(&ns.path)));
4757  }
4758  for c in &bs.command {
4759    lines.push(format!("       - run    {} ({})", clean(&c.name), clean(&c.run)));
4760  }
4761  lines
4762}
4763
4764// ---- Aliases commands (issue #86) ---------------------------------------
4765
4766fn cmd_aliases(action: AliasesAction) -> Result<()> {
4767  match action {
4768    AliasesAction::List => cmd_aliases_list(),
4769  }
4770}
4771
4772fn cmd_config(action: ConfigAction) -> Result<()> {
4773  match action {
4774    ConfigAction::Get { key } => config_cli::get(&key),
4775    ConfigAction::Set { key, value } => config_cli::set(&key, value.as_deref()),
4776    ConfigAction::Unset { key } => config_cli::unset(&key),
4777    ConfigAction::List { prefix } => config_cli::list(prefix.as_deref()),
4778    ConfigAction::Validate => config_cli::validate(),
4779    ConfigAction::Path => config_cli::path(),
4780    ConfigAction::Edit => config_cli::edit(),
4781  }
4782}
4783
4784/// `gwm aliases list` — print the resolved alias chain. Reads
4785/// `.gwm.toml` from the current repo workdir when available (gracefully
4786/// degrades to "no repo" when invoked outside a git repo) and the
4787/// user-level fallback `~/.config/gwm/aliases.toml`.
4788///
4789/// Output shape (matches the issue example verbatim):
4790///
4791/// ```text
4792/// built-in:
4793///   s    → switch
4794///   cd   → path
4795/// repo (.gwm.toml):
4796///   wip    → create feat 0 wip
4797///   ll     → list --format names
4798/// user (~/.config/gwm/aliases.toml):
4799///   copy   → path
4800/// ```
4801fn cmd_aliases_list() -> Result<()> {
4802  // Discover the repo workdir if any — outside a repo this is `None`
4803  // and the repo section degrades to "(no .gwm.toml — not inside a
4804  // git repository)". `aliases list` is intentionally tolerant of
4805  // running outside a repo so power users can audit their user-level
4806  // file without cd'ing first.
4807  let repo_workdir: Option<PathBuf> = crate::worktree::discover_repo(None)
4808    .ok()
4809    .and_then(|r| r.workdir().map(|w| w.to_path_buf()));
4810
4811  let resolved = crate::aliases::load(repo_workdir.as_deref(), None)?;
4812
4813  // Issue #473: repo and user alias tables are arbitrary key/value text from
4814  // a `.gwm.toml` this command reads WITHOUT the trust gate: auditing an
4815  // unfamiliar repo's aliases before running anything is exactly what it is
4816  // for. Built-ins are compiled in and need no cleaning, but they share the
4817  // helper so a future built-in read from a file cannot slip through.
4818  let clean = crate::naming::sanitise_for_terminal;
4819
4820  // built-in section ---------------------------------------------------
4821  println!("built-in:");
4822  if resolved.built_in.is_empty() {
4823    println!("  (none)");
4824  } else {
4825    let width = resolved.built_in.iter().map(|e| e.name.len()).max().unwrap_or(2).max(2);
4826    for e in &resolved.built_in {
4827      println!("  {:<width$} → {}", clean(e.name), clean(e.expansion), width = width);
4828    }
4829  }
4830
4831  // repo section -------------------------------------------------------
4832  println!("repo (.gwm.toml):");
4833  if repo_workdir.is_none() {
4834    println!("  (not inside a git repository — repo aliases are read from <repo>/.gwm.toml)");
4835  } else if resolved.repo.is_empty() {
4836    println!("  (none declared)");
4837  } else {
4838    let width = resolved.repo.keys().map(|k| clean(k).len()).max().unwrap_or(2).max(2);
4839    for (name, expansion) in &resolved.repo {
4840      println!("  {:<width$} → {}", clean(name), clean(expansion), width = width);
4841    }
4842  }
4843
4844  // user section -------------------------------------------------------
4845  // The display path mirrors the issue example. We don't call into
4846  // `default_user_path()` for the label because that function is
4847  // private to the `aliases` module; the rendered string is purely
4848  // informational here.
4849  println!("user (~/.config/gwm/aliases.toml):");
4850  if resolved.user.is_empty() {
4851    println!("  (none declared)");
4852  } else {
4853    let width = resolved.user.keys().map(|k| clean(k).len()).max().unwrap_or(2).max(2);
4854    for (name, expansion) in &resolved.user {
4855      // Mark entries shadowed by a repo declaration so the user can
4856      // see why a `gwm <name>` does not pick up the user expansion.
4857      // Shadowing is probed on the raw name, which is the identity the
4858      // resolver matches on, and two distinct names must not look shadowed
4859      // just because they neutralise to the same string.
4860      let shadowed = resolved.repo.contains_key(name);
4861      let suffix = if shadowed { "  (shadowed by repo)" } else { "" };
4862      println!(
4863        "  {:<width$} → {}{}",
4864        clean(name),
4865        clean(expansion),
4866        suffix,
4867        width = width
4868      );
4869    }
4870  }
4871
4872  Ok(())
4873}
4874
4875pub fn shell_init_script(shell: InitShell) -> &'static str {
4876  match shell {
4877    InitShell::Bash | InitShell::Zsh => POSIX_SHELL_INIT,
4878    InitShell::Fish => FISH_SHELL_INIT,
4879    InitShell::Powershell => POWERSHELL_SHELL_INIT,
4880  }
4881}
4882
4883const POSIX_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4884# Install: eval "$(gwm shell-init bash)"   # or zsh
4885#
4886# Two paths:
4887#   gcd <pattern>        # fuzzy resolve via `gwm cd <pattern>`, then cd
4888#   gcd                  # no arg → opens the interactive picker via `gwm switch`, then cd
4889#
4890# Note: the `function name { ... }` form (zsh/bash-extended) is used instead
4891# of the parenthesised POSIX form so the parser does not error out with
4892# `defining function based on alias 'gcd'` when zsh already has a `gcd`
4893# alias (e.g. oh-my-zsh's `gcd=git checkout`). The `unalias` after the
4894# definition is what makes the function reachable at call time, since zsh
4895# still resolves the alias first when both exist.
4896function gcd {
4897  local target
4898  if [ "$#" -eq 0 ]; then
4899    # No arg → open the interactive picker. `gwm switch` exits non-zero on
4900    # cancel, in which case `gcd` must NOT attempt the `cd` (would land in $HOME).
4901    target="$(command gwm switch)" || return $?
4902  else
4903    target="$(command gwm cd "$@")" || return $?
4904  fi
4905  cd "$target" || return $?
4906}
4907unalias gcd 2>/dev/null || true
4908"#;
4909
4910const FISH_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4911# Install: gwm shell-init fish | source   # then persist in ~/.config/fish/config.fish
4912#
4913# Two paths:
4914#   gcd <pattern>        # fuzzy resolve via `gwm cd <pattern>`, then cd
4915#   gcd                  # no arg → opens the interactive picker via `gwm switch`, then cd
4916function gcd --description 'cd into a gwm worktree (no arg = interactive picker)'
4917  set -l target
4918  if test (count $argv) -eq 0
4919    # No arg → open the interactive picker; cancel exits non-zero, in which
4920    # case we must NOT attempt the cd (would land in $HOME).
4921    set target (command gwm switch)
4922    or return $status
4923  else
4924    set target (command gwm cd $argv)
4925    or return $status
4926  end
4927  # `--` stops option parsing, "$target" prevents wildcard expansion on
4928  # paths containing `[`, `]`, or `*`.
4929  cd -- "$target"
4930end
4931"#;
4932
4933const POWERSHELL_SHELL_INIT: &str = r#"# gwm shell helper — wraps `gwm cd` / `gwm switch` so the parent shell can cd.
4934# Install: Invoke-Expression (& gwm shell-init powershell | Out-String)
4935#
4936# Two paths:
4937#   gcd <pattern>        # fuzzy resolve via `gwm cd <pattern>`, then Set-Location
4938#   gcd                  # no arg → opens the interactive picker via `gwm switch`, then Set-Location
4939#
4940# Note: this clears any prior `gcd` alias so the function takes effect.
4941Remove-Alias -Name gcd -Force -ErrorAction SilentlyContinue
4942function gcd {
4943  param([string]$Pattern)
4944  if ([string]::IsNullOrEmpty($Pattern)) {
4945    # No arg → open the interactive picker. The binary exits non-zero on
4946    # cancel; bail out before attempting Set-Location so we don't land in $HOME.
4947    $target = & gwm switch
4948  } else {
4949    $target = & gwm cd $Pattern
4950  }
4951  if ($LASTEXITCODE -ne 0) { return }
4952  Set-Location $target
4953}
4954"#;
4955
4956fn print_report(report: &bootstrap::BootstrapReport) {
4957  if report.steps.is_empty() {
4958    return;
4959  }
4960  println!();
4961  println!("bootstrap report:");
4962  for s in &report.steps {
4963    let sigil = s.status.sigil();
4964    println!("  {} {}", sigil, s.label);
4965    if !s.detail.is_empty() {
4966      for line in s.detail.lines() {
4967        println!("      {}", line);
4968      }
4969    }
4970  }
4971}
4972
4973fn print_lifecycle_report(report: &bootstrap::BootstrapReport) {
4974  if report.steps.is_empty() {
4975    return;
4976  }
4977  lifecycle::print_report(report);
4978}
4979
4980// ---------------------------------------------------------------------------
4981// Issue #29 — `gwm history` + `gwm undo`
4982// ---------------------------------------------------------------------------
4983
4984/// Resolve the canonicalised main repo workdir for journal lookups.
4985/// `gwm undo` / `gwm history` filter on this path verbatim, so the
4986/// canonicalisation step matters: `/var` vs `/private/var` on macOS
4987/// would otherwise cross-pollute repos that happen to live on
4988/// different symlink chains.
4989fn current_repo_root() -> Result<PathBuf> {
4990  let repo = worktree::discover_repo(None)?;
4991  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
4992  Ok(std::fs::canonicalize(&workdir).unwrap_or(workdir))
4993}
4994
4995/// Render one journal entry as a single line for `gwm history`. Shape:
4996/// `<ago>  <kind>  <worktree>  [(undone)]`. Extracted as a pure
4997/// function so the formatter is unit-testable without spinning up a
4998/// real journal (see `cli_format_tests.rs`).
4999pub fn format_history_row(entry: &OpEntry, now: chrono::DateTime<chrono::Utc>) -> String {
5000  let delta = (now - entry.ts).to_std().unwrap_or(std::time::Duration::from_secs(0));
5001  let ago = worktree::format_relative_duration(delta);
5002  let suffix = if entry.undone { "  (undone)" } else { "" };
5003  format!("{:<5}  {:<7}  {}{}", ago, entry.kind.as_str(), entry.worktree, suffix)
5004}
5005
5006fn cmd_history(limit: usize, all: bool) -> Result<()> {
5007  let path = history::default_journal_path()?;
5008  let journal = history::Journal::load(&path)?;
5009
5010  // Build the filtered+sorted view. With `--all`, surface every entry
5011  // regardless of `repo_root`. Without it, restrict to the current
5012  // repo's canonicalised workdir. Resolving the root outside the
5013  // `if` so its lifetime spans the whole function — the `else` arm
5014  // returns an iterator that borrows from it.
5015  let root: Option<PathBuf> = if all { None } else { Some(current_repo_root()?) };
5016  let mut rows: Vec<&OpEntry> = match &root {
5017    Some(r) => journal.entries_for_repo(r).collect(),
5018    None => journal.entries().iter().collect(),
5019  };
5020
5021  // Distinguish "the journal is empty for this view" from "the user
5022  // asked for zero rows" — `--limit 0` is an explicit no-op that
5023  // should print nothing and exit 0, not falsely claim the journal
5024  // is empty (PR #155 Copilot review).
5025  if rows.is_empty() {
5026    println!("no operations recorded");
5027    return Ok(());
5028  }
5029  if limit == 0 {
5030    return Ok(());
5031  }
5032
5033  // Newest first — the user just ran an op, they expect it on top.
5034  rows.sort_by_key(|e| std::cmp::Reverse(e.ts));
5035  rows.truncate(limit);
5036
5037  let now = chrono::Utc::now();
5038  for entry in rows {
5039    println!("{}", format_history_row(entry, now));
5040  }
5041  Ok(())
5042}
5043
5044fn cmd_undo(run_bootstrap: bool, trust_mode: TrustMode) -> Result<()> {
5045  let path = history::default_journal_path()?;
5046  let mut journal = history::Journal::load(&path)?;
5047  let root = current_repo_root()?;
5048
5049  let Some(entry) = journal.pop_last_for_repo(&root) else {
5050    return Err(GwmError::Other(format!(
5051      "nothing to undo for {} — the journal is empty for this repo",
5052      root.display()
5053    )));
5054  };
5055
5056  let repo = worktree::discover_repo(None)?;
5057
5058  // (0) Issue #338: if the caller opted into re-running bootstrap, gate
5059  //     it through the SAME TOFU trust prompt as create / review /
5060  //     bootstrap — a repo's `[[bootstrap.command]]` shell must never run
5061  //     unprompted on undo. Do it BEFORE any resurrection so a denied
5062  //     gate (untrusted config in a non-tty, `--deny-bootstrap`, or a
5063  //     declined prompt) leaves the journal entry and worktree untouched:
5064  //     the undo stays retryable instead of half-applying then exiting
5065  //     non-zero. Honours --allow-bootstrap / GWM_ALLOW_BOOTSTRAP /
5066  //     --deny-bootstrap.
5067  if run_bootstrap {
5068    let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
5069    trust_or_prompt(&workdir, Some(&repo), trust_mode)?;
5070  }
5071
5072  // (1) Resurrect the branch at the saved OID — only if a branch was
5073  //     recorded AND the user opted into deletion (or the branch is
5074  //     missing for any other reason). Skipping the branch create
5075  //     when the ref already exists keeps `gwm undo` idempotent
5076  //     against partial recoveries.
5077  if let (Some(branch_name), Some(oid_hex)) = (&entry.branch, &entry.branch_oid) {
5078    let oid = git2::Oid::from_str(oid_hex)
5079      .map_err(|e| GwmError::Other(format!("journal entry has invalid branch_oid '{}': {}", oid_hex, e)))?;
5080    if repo.find_branch(branch_name, git2::BranchType::Local).is_err() {
5081      repo
5082        .reference(
5083          &format!("refs/heads/{}", branch_name),
5084          oid,
5085          false,
5086          "gwm undo: resurrect branch",
5087        )
5088        .map_err(|e| {
5089          GwmError::Other(format!(
5090            "failed to recreate branch {} at {}: {}",
5091            branch_name, oid_hex, e
5092          ))
5093        })?;
5094      println!(
5095        "✓ recreated branch {} at {}",
5096        branch_name,
5097        &oid_hex[..oid_hex.len().min(8)]
5098      );
5099    } else {
5100      println!("· branch {} already exists — skipping resurrection", branch_name);
5101    }
5102  }
5103
5104  // (2) Re-add the worktree at the saved path. `worktree::add` refuses
5105  //     to clobber an existing directory, so a leftover dir from a
5106  //     half-failed remove will surface as an error here — the user
5107  //     can clean up manually before retrying undo.
5108  //
5109  //     `OpEntry.branch == None` flags a worktree that was checked out
5110  //     in detached-HEAD state. We don't support resurrecting those
5111  //     yet — the original sin is that `worktree::add` only knows how
5112  //     to attach a worktree to a named branch. Falling back to a
5113  //     literal `"HEAD"` (the pre-fix behaviour) would either fail at
5114  //     the libgit2 level (invalid refname) or create a real branch
5115  //     named "HEAD" which is a disaster all of its own. Surface a
5116  //     clear error so the user knows what's happening and can file
5117  //     a follow-up issue if detached-HEAD support matters to them
5118  //     (PR #155 Copilot review).
5119  let branch_name = entry.branch.as_deref().ok_or_else(|| {
5120    GwmError::Other(format!(
5121      "cannot undo remove of detached-HEAD worktree {} — only branch-attached worktrees are supported today",
5122      entry.worktree
5123    ))
5124  })?;
5125  // `reuse_branch: true` because the branch already exists (we just
5126  // created it above, or it was never deleted).
5127  worktree::add(&repo, &entry.worktree, &entry.path, branch_name, true)?;
5128  println!("✓ re-added worktree at {}", entry.path.display());
5129
5130  // (3) Persist the journal AFTER the resurrection succeeds — if we
5131  //     dropped the entry first and then the resurrection failed, the
5132  //     user would lose the recovery anchor entirely.
5133  journal.save(&path)?;
5134
5135  // (4) Optionally re-run bootstrap. Trust was already gated at step
5136  //     (0) before any resurrection, so by here we're cleared to run.
5137  if run_bootstrap {
5138    let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
5139    let config = Config::load_for_repo(&workdir)?;
5140    let ctx = BootstrapCtx {
5141      main_repo: &workdir,
5142      worktree: &entry.path,
5143      config: &config,
5144    };
5145    let report = bootstrap::run(&ctx)?;
5146    print_report(&report);
5147  } else {
5148    println!("(skipped re-bootstrap; pass --bootstrap to run it)");
5149  }
5150
5151  Ok(())
5152}