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