Skip to main content

gwm/
doctor.rs

1//! Environment + worktree diagnostics. Aggregates a series of cheap checks
2//! into a single report so users (and CI) can answer "is my setup sane?"
3//! without running a dozen ad-hoc commands.
4
5use crate::config::{expand_placeholders, Config, CONFIG_FILE};
6use crate::error::Result;
7use crate::naming::branch_pattern_warning;
8use crate::worktree;
9use git2::BranchType;
10use std::collections::BTreeSet;
11use std::path::Path;
12
13#[derive(Debug, Clone, Default)]
14pub struct DoctorReport {
15  pub checks: Vec<Check>,
16}
17
18impl DoctorReport {
19  pub fn new() -> Self {
20    Self::default()
21  }
22
23  /// Highest severity present in the report — `Failed` wins over `Warning`
24  /// wins over `Ok`. Returned as a `CheckStatus` (a previous `Severity`
25  /// enum was a verbatim duplicate; collapsing into one type avoids the
26  /// translation match and keeps the public surface minimal).
27  pub fn severity(&self) -> CheckStatus {
28    let mut s = CheckStatus::Ok;
29    for c in &self.checks {
30      match c.status {
31        CheckStatus::Failed => return CheckStatus::Failed,
32        CheckStatus::Warning if s == CheckStatus::Ok => s = CheckStatus::Warning,
33        _ => {}
34      }
35    }
36    s
37  }
38
39  /// Process exit code derived from `severity()`:
40  /// `0` = all green, `1` = at least one warning, `2` = at least one failure.
41  /// Suitable for wiring into CI / pre-commit.
42  pub fn exit_code(&self) -> i32 {
43    match self.severity() {
44      CheckStatus::Ok => 0,
45      CheckStatus::Warning => 1,
46      CheckStatus::Failed => 2,
47    }
48  }
49}
50
51#[derive(Debug, Clone)]
52pub struct Check {
53  pub name: String,
54  pub status: CheckStatus,
55  pub detail: String,
56  /// One-line user-facing remediation, displayed under the check when set.
57  pub fix_hint: Option<String>,
58}
59
60impl Check {
61  pub fn ok(name: impl Into<String>, detail: impl Into<String>) -> Self {
62    Self {
63      name: name.into(),
64      status: CheckStatus::Ok,
65      detail: detail.into(),
66      fix_hint: None,
67    }
68  }
69
70  pub fn warning(name: impl Into<String>, detail: impl Into<String>) -> Self {
71    Self {
72      name: name.into(),
73      status: CheckStatus::Warning,
74      detail: detail.into(),
75      fix_hint: None,
76    }
77  }
78
79  pub fn failed(name: impl Into<String>, detail: impl Into<String>) -> Self {
80    Self {
81      name: name.into(),
82      status: CheckStatus::Failed,
83      detail: detail.into(),
84      fix_hint: None,
85    }
86  }
87
88  pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
89    self.fix_hint = Some(hint.into());
90    self
91  }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum CheckStatus {
96  Ok,
97  Warning,
98  Failed,
99}
100
101/// Backwards-compatibility alias. `Severity` was a verbatim duplicate of
102/// `CheckStatus` introduced before they were unified; keep the name so
103/// callers from 0.3.0 keep compiling while we converge on `CheckStatus`.
104pub type Severity = CheckStatus;
105
106pub struct DoctorCtx<'a> {
107  pub repo_workdir: &'a Path,
108  pub repo: &'a git2::Repository,
109  pub config: &'a Config,
110  /// Global config layer to merge under the repo `.gwm.toml` when a check
111  /// re-reads the on-disk config (the `[tui.keys]` keymap check). Threaded
112  /// explicitly — rather than read ambiently via `global_config_path()` — so
113  /// `doctor::run` stays deterministic for embedders and unit tests that
114  /// inject an isolated context (issue #219 review). `None` skips the global
115  /// layer entirely.
116  pub global_config_path: Option<&'a Path>,
117}
118
119pub fn run(ctx: &DoctorCtx<'_>) -> Result<DoctorReport> {
120  let mut report = DoctorReport::new();
121  report.checks.push(check_config_parses(ctx));
122  report.checks.push(check_guard_references(ctx));
123  report.checks.push(check_when_predicates(ctx));
124  report.checks.push(check_binaries_on_path(ctx));
125
126  // The next two checks both need the worktree list. Hoist the libgit2
127  // call here so it runs once per `gwm doctor` invocation and so each
128  // check carries the same view of the world.
129  match worktree::list(ctx.repo) {
130    Ok(trees) => {
131      report.checks.push(check_prunable_worktrees(&trees));
132      report.checks.push(check_orphan_branches(ctx, &trees));
133    }
134    Err(e) => {
135      let detail = format!("could not list worktrees: {}", e);
136      report.checks.push(Check::failed("no prunable worktrees", &detail));
137      report.checks.push(Check::failed("no orphan gwm branches", &detail));
138    }
139  }
140
141  report.checks.push(check_base_dir_writable(ctx));
142  report.checks.push(check_tui_keymap(ctx));
143  report.checks.push(check_branch_pattern(ctx));
144  Ok(report)
145}
146
147/// TUI keymap diagnostic (issue #87). Re-runs the same
148/// [`crate::tui::keymap::Keymap`] resolution path the TUI itself uses
149/// at startup, so any user-facing `[tui.keys]` mistake surfaces here
150/// before the TUI actually fails to dispatch.
151///
152/// Three outcomes:
153///
154/// 1. **Failed** — the keymap fails to resolve (parse error, unknown
155///    action slug, chord conflict, prefix collision). The detail
156///    repeats the underlying [`crate::error::GwmError::Config`]
157///    message verbatim so the user can paste it into a search.
158/// 2. **Warning** — the keymap resolves, but `quit` has been
159///    unbound entirely. The hard-coded `Ctrl+C` branch in `run_app`
160///    keeps the TUI exitable; we warn anyway because losing the
161///    discoverable quit key is a hostile UX choice users usually
162///    don't realise they made.
163/// 3. **Ok** — keymap is valid and `quit` has at least one
164///    user-visible binding.
165fn check_tui_keymap(ctx: &DoctorCtx<'_>) -> Check {
166  let name = "[tui.keys] keymap resolves";
167
168  // Re-derive `[tui.keys]` from the on-disk config (#219 review): the lenient
169  // `repo_context_lenient` returns `Config::default()` when `load_for_repo`
170  // rejects the user's file, so validating `ctx.config` here would silently
171  // OK a config that actually refuses to start the TUI. The global layer is
172  // the one threaded into the context (not an ambient `global_config_path()`
173  // read) so the check stays deterministic for injected contexts. Fall back to
174  // `ctx.config` only when the *merge* fails — a parse / shape error already
175  // surfaced by `check_config_parses`.
176  let keys = match Config::merge_layered(ctx.repo_workdir, ctx.global_config_path) {
177    Ok(cfg) => cfg.tui.keys,
178    Err(_) => ctx.config.tui.keys.clone(),
179  };
180
181  let keymap = match keys.resolved_keymap() {
182    Ok(km) => km,
183    Err(e) => {
184      return Check::failed(name, format!("{}", e))
185        .with_hint("fix the `[tui.keys]` entry called out above; the full list of action slugs is `gwm tui keys`");
186    }
187  };
188
189  // Issue #219: the contextual modal keymap (`[tui.keys.modal.<context>]`) is
190  // validated the same way — an unknown context / verb, a multi-stroke
191  // chord, or a per-context conflict surfaces here with the offending
192  // coordinate so `gwm doctor` flags it before the user hits it live.
193  // #219 review (P2): resolve it *before* the quit warning so a hard modal
194  // error (Failed) is never downgraded to the `quit` Warning when a config
195  // carries both an unbound `quit` and an invalid modal binding.
196  let modal = match keys.resolved_modal_keymap() {
197    Ok(mk) => mk,
198    Err(e) => {
199      return Check::failed(name, format!("{}", e)).with_hint(
200        "fix the `[tui.keys.modal.<context>]` entry called out above; `gwm tui keys` lists every context and verb",
201      );
202    }
203  };
204
205  // Snapshot once. The pre-review version called `keymap.list()`
206  // twice — both `quit_has_user_binding` and the success count
207  // cloned the bindings vector. One snapshot reused below.
208  let bindings = keymap.list();
209
210  // Quit is special: the only hard-coded escape hatch is `Ctrl+C` in
211  // `run_app`. We don't refuse an empty `quit` binding (per the design
212  // note in `src/tui/keymap.rs`), but we do flag it so the user knows
213  // the discoverable key is gone.
214  let quit_has_user_binding = bindings
215    .iter()
216    .any(|b| b.action == crate::tui::keymap::Action::Quit && !b.chords.is_empty());
217  if !quit_has_user_binding {
218    return Check::warning(
219      name,
220      "`quit` has no binding — Ctrl+C still exits the TUI as a hard-coded fallback, but no discoverable key remains",
221    )
222    .with_hint("add `quit = [\"q\", \"Esc\"]` (or any other key) to `[tui.keys]`");
223  }
224
225  // Count only actions with at least one chord. The pre-review
226  // version used `bindings.len()` which includes unbound entries
227  // (`action = []` in `[tui.keys]` leaves the action in the list
228  // with an empty chord vec), inflating the count visible in
229  // `gwm doctor` output and misleading the user about how many
230  // actions are actually reachable.
231  let bound_count = bindings.iter().filter(|b| !b.chords.is_empty()).count();
232  let modal_bound = modal.list().iter().filter(|b| !b.keys.is_empty()).count();
233  Check::ok(
234    name,
235    format!("{} global + {} modal binding(s) bound", bound_count, modal_bound),
236  )
237}
238
239/// Check #1: `.gwm.toml` parses cleanly. Missing config is fine — defaults
240/// are documented and identical to what `gwm init` writes out. Invalid TOML
241/// is a hard failure since it would crash every other subcommand.
242fn check_config_parses(ctx: &DoctorCtx<'_>) -> Check {
243  let path = ctx.repo_workdir.join(CONFIG_FILE);
244  let name = ".gwm.toml parses";
245
246  if !path.exists() {
247    return Check::ok(name, "no .gwm.toml present — defaults assumed");
248  }
249
250  let raw = match std::fs::read_to_string(&path) {
251    Ok(s) => s,
252    Err(e) => {
253      return Check::failed(name, format!("could not read {}: {}", path.display(), e));
254    }
255  };
256
257  let cfg = match toml::from_str::<Config>(&raw) {
258    Ok(cfg) => cfg,
259    Err(e) => {
260      return Check::failed(name, format!("invalid TOML in {}: {}", path.display(), e))
261        .with_hint("fix the syntax or back it up and re-run `gwm init`");
262    }
263  };
264  // `[exec.profiles]` / `[clean.profiles]` semantics (non-empty command, a
265  // worktree-relative single-name `dirs`) parse cleanly, so check them here
266  // too — otherwise doctor reports green on a profile the loader and the
267  // `gwm exec`/`gwm clean` commands reject (issue #324 review).
268  match cfg.validate_profiles() {
269    Ok(()) => Check::ok(name, format!("{} parses cleanly", path.display())),
270    Err(e) => Check::failed(name, format!("invalid profile in {}: {}", path.display(), e))
271      .with_hint("fix the `[exec.profiles]` / `[clean.profiles]` entry it names"),
272  }
273}
274
275/// Check #2: every `[[bootstrap.copy]].guards = [...]` entry references a
276/// `[[bootstrap.guard]].name` that actually exists. Dangling references are
277/// silent footguns — the copy step would proceed unchecked and the guard
278/// would never trip.
279fn check_guard_references(ctx: &DoctorCtx<'_>) -> Check {
280  let name = "guard references resolve";
281  let bs = &ctx.config.bootstrap;
282
283  let mut dangling: Vec<String> = Vec::new();
284  for copy in &bs.copy {
285    for guard_name in &copy.guards {
286      if ctx.config.guard_by_name(guard_name).is_none() {
287        dangling.push(format!(
288          "{} (referenced from copy {} -> {})",
289          guard_name, copy.from, copy.to
290        ));
291      }
292    }
293  }
294
295  if dangling.is_empty() {
296    let count: usize = bs.copy.iter().map(|c| c.guards.len()).sum();
297    return Check::ok(name, format!("{} guard reference(s) resolve", count));
298  }
299
300  Check::failed(name, format!("dangling guard reference(s): {}", dangling.join("; ")))
301    .with_hint("declare the missing `[[bootstrap.guard]]` block(s) or drop the reference")
302}
303
304/// Recognised `when:` predicate keywords. Update this list when a new
305/// keyword lands in `bootstrap.rs::evaluate_when`.
306const SUPPORTED_WHEN_PREFIXES: &[&str] = &["file_exists:", "cmd_exists:", "env_set:", "env_eq:", "glob_exists:"];
307
308/// Check #3: every `[[bootstrap.command]].when` predicate uses one of the
309/// supported keywords. Unknown predicates default to `true` in
310/// `bootstrap::evaluate_when`, so the command runs anyway and the user's
311/// intended gating condition is silently ignored — that's still a footgun
312/// worth flagging, just not "command never runs".
313///
314/// Walks every atom in the expression (via `bootstrap::when_atoms`) so
315/// negated atoms (`!env_set:CI`) and compound expressions
316/// (`file_exists:a && bogus:1`) are validated as a whole instead of
317/// being green-lit by their first keyword.
318fn check_when_predicates(ctx: &DoctorCtx<'_>) -> Check {
319  let name = "`when` predicates supported";
320  let bs = &ctx.config.bootstrap;
321
322  let mut unknown: Vec<String> = Vec::new();
323  let mut recognised: usize = 0;
324  for cmd in &bs.command {
325    let Some(w) = &cmd.when else { continue };
326    // Walk every atom in the expression (via `bootstrap::when_atoms`) so
327    // negated atoms (`!env_set:CI`) and compound expressions (`file_exists:a
328    // && bogus:1`) are validated as a whole rather than green-lit by their
329    // first keyword. A command is `recognised` only when all its atoms
330    // pass — a single unknown atom kicks it into `unknown`.
331    let mut had_unknown = false;
332    for atom in crate::bootstrap::when_atoms(w) {
333      if !SUPPORTED_WHEN_PREFIXES.iter().any(|p| atom.starts_with(p)) {
334        unknown.push(format!("{} (on command `{}`)", atom, cmd.name));
335        had_unknown = true;
336      }
337    }
338    if !had_unknown {
339      recognised += 1;
340    }
341  }
342
343  if unknown.is_empty() {
344    let detail = if recognised == 0 {
345      "no `when:` predicates configured".to_string()
346    } else {
347      format!("{} predicate(s) recognised", recognised)
348    };
349    return Check::ok(name, detail);
350  }
351
352  Check::failed(name, format!("unknown `when` predicate(s): {}", unknown.join("; ")))
353    .with_hint(format!("supported keywords: {}", SUPPORTED_WHEN_PREFIXES.join(", ")))
354}
355
356/// Common shell wrappers that introduce the real binary after their
357/// own switches / env assignments. Caught by Copilot's review on
358/// PR #76: pre-fix, `env FOO=bar lumen diff` made the doctor check
359/// `env` against `$PATH` (which is always present) and miss the real
360/// launcher `lumen`. Keep this list narrow on purpose — exotic
361/// wrappers (`nice`, `time`, `nohup`) take positional args, which we
362/// would risk consuming and ending up with the wrong binary.
363const COMMAND_WRAPPERS: &[&str] = &["env", "command"];
364
365/// Extract the executable name from a shell command string. Tokenises
366/// via `shell_words` so quoted args (`"my tool" --flag`) and escaped
367/// whitespace are handled the way the shell would, then skips leading
368/// `FOO=bar` env assignments and recognised `env`/`command` wrappers
369/// (and the wrapper's own `KEY=VAL` / `-flag` tokens) before returning
370/// the first token that looks like a real binary name. Returns `None`
371/// for empty strings or strings that fail to parse (unbalanced quotes
372/// — better to surface nothing than a garbage binary name that would
373/// produce a confusing PATH warning).
374fn extract_binary(run: &str) -> Option<String> {
375  let tokens = shell_words::split(run).ok()?;
376  let mut iter = tokens.into_iter().peekable();
377
378  // Skip leading `KEY=VAL` env assignments (POSIX `FOO=bar tool` form).
379  while iter.peek().is_some_and(|t| !t.starts_with('=') && t.contains('=')) {
380    iter.next();
381  }
382
383  // Recognise a wrapper (`env`, `command`) and skip its own `-flag` /
384  // `KEY=VAL` arguments before reaching the real binary. Stops on the
385  // first positional non-flag, non-assignment token.
386  if iter.peek().is_some_and(|t| COMMAND_WRAPPERS.contains(&t.as_str())) {
387    iter.next(); // consume the wrapper itself
388    while let Some(t) = iter.peek() {
389      if t.starts_with('-') || (!t.starts_with('=') && t.contains('=')) {
390        iter.next();
391      } else {
392        break;
393      }
394    }
395  }
396
397  iter.next()
398}
399
400/// Same as [`extract_binary`] but pre-strips the launcher placeholders
401/// so a template like `lumen diff {base}..{head}` reduces to `lumen`
402/// before tokenisation. Used for the issue #75 [`crate::config::GitTuiConfig`] /
403/// [`crate::config::ReviewConfig`] entries so the doctor warning
404/// names the actual binary, not a placeholder fragment.
405fn extract_launcher_binary(command: &str) -> Option<String> {
406  let cleaned = command
407    .replace("{base}", "BASE")
408    .replace("{head}", "HEAD")
409    .replace("{path}", "PATH")
410    .replace("{diff}", "/tmp/diff");
411  extract_binary(&cleaned)
412}
413
414/// Check #4: every binary referenced by the bootstrap commands resolves on
415/// `$PATH`. `lazygit` (the TUI's `l` keybinding's default) and `direnv`
416/// (only if the repo has an `.envrc`) are also checked because they're
417/// the two "ambient" dependencies whose absence routinely confuses new
418/// users. Configured launchers ([git_tui], [review] — issue #75) are
419/// added to the same set so the user gets one consolidated warning.
420///
421/// Issue #415: `worktree.branch_pattern` is honoured when a branch name is
422/// *written* and ignored when one is *read back*, so a pattern the parser
423/// cannot follow quietly turns off issue/PR auto-linking, gitmoji selection
424/// and the branch-convention check above. [`branch_pattern_warning`] probes
425/// the round-trip and names whichever segments actually break — a custom
426/// pattern is not automatically a broken one.
427///
428/// Warning rather than Failed: the config is valid and the worktrees it
429/// produces are perfectly usable — only the structured extras go silent.
430/// This check does not fix the divergence, it states it; the parser is
431/// derived from the pattern in #417.
432fn check_branch_pattern(ctx: &DoctorCtx<'_>) -> Check {
433  let name = "worktree.branch_pattern round-trips through the parser";
434
435  // Re-derive from disk for the same reason `check_tui_keymap` does:
436  // `repo_context_lenient` substitutes `Config::default()` when the user's
437  // file fails to load for an unrelated semantic reason, and reading
438  // `ctx.config` there would report the default pattern as fine while the
439  // file on disk carries a broken one — a false `✓` from the one check
440  // whose whole job is catching a silent failure. Fall back to `ctx.config`
441  // only when the *merge* fails, which `check_config_parses` already flags.
442  let effective = match Config::merge_layered(ctx.repo_workdir, ctx.global_config_path) {
443    Ok(cfg) => cfg,
444    Err(_) => ctx.config.clone(),
445  };
446  let types = effective.resolved_branch_types().types;
447
448  match branch_pattern_warning(
449    &effective.worktree.branch_pattern,
450    &worktree::repo_name(ctx.repo),
451    &types,
452  ) {
453    // The hint stays neutral on purpose: which workaround applies depends
454    // on which segment broke, and the detail above already names it.
455    // Recommending `gwm link` unconditionally was wrong for a pattern
456    // whose `issue` survives — auto-linking works there, and `gwm link`
457    // fixes neither the hook placeholders nor the TUI rename.
458    Some(detail) => Check::warning(name, detail).with_hint(
459      "restore the default `{type}/#{issue}-{desc}`, or keep the pattern and accept exactly the loss named above",
460    ),
461    None => Check::ok(
462      name,
463      "the parser compiled from this pattern reads back the segments it writes",
464    ),
465  }
466}
467
468/// Missing binaries are surfaced as Warning, not Failed — the user may not
469/// rely on that step at all, but the visibility matters.
470fn check_binaries_on_path(ctx: &DoctorCtx<'_>) -> Check {
471  let name = "external binaries on PATH";
472  let mut needed: BTreeSet<String> = BTreeSet::new();
473
474  // Ambient deps the rest of the CLI uses. `[git_tui]` may override the
475  // lazygit default; we extract whatever binary the resolved launcher
476  // names so a `gitui` / `tig` user gets the right warning.
477  let git_tui = ctx.config.git_tui.resolved();
478  if let Some(bin) = extract_launcher_binary(&git_tui.command) {
479    needed.insert(bin);
480  }
481  if ctx.repo_workdir.join(".envrc").exists() {
482    needed.insert("direnv".into());
483  }
484  // The forge CLI (`gh` / `glab`) is probed only when the user opted in
485  // (issue #419). An opt-in makes the warning actionable; probing
486  // unconditionally would fire a new warning at every user who never
487  // touches issue/PR linking and has no `gh` installed, which is not a
488  // regression worth shipping for a feature they don't use.
489  //
490  // Two forms say it, and both count. `forge` names the backend. A
491  // `[forge_hosts]` entry matching this repo's own origin names it *and*
492  // the host — a stronger signal, not a weaker one — so probing on `forge`
493  // alone left a user who authorises purely through the global table with
494  // a clean report and no CLI installed. An entry for some *other* host is
495  // not an opt-in here, which is what keeps one global entry from warning
496  // in every unrelated repo.
497  let opted_in = ctx.config.forge.or_else(|| {
498    let host = crate::forge::origin_ref(ctx.repo).ok()?.host;
499    Config::forge_host_in(ctx.global_config_path?, &host)
500  });
501  if let Some(kind) = opted_in {
502    // The RESOLVED program, not the bare name: `$GWM_GH` / `$GWM_GLAB`
503    // may point at an alternative binary, and probing `gh` regardless
504    // warned about a setup that works and pushed the exit code to 1
505    // (Codex review #458). `which` resolves an explicit path too, so the
506    // same lookup covers both forms.
507    let program = match kind {
508      crate::forge::ForgeKind::GitHub => crate::github::gh_program(),
509      crate::forge::ForgeKind::GitLab => crate::gitlab::glab_program(),
510    };
511    needed.insert(program.to_string_lossy().into_owned());
512  }
513  // Review launcher is opt-in; only probe when the user actually
514  // configured one (`command` or `tool`).
515  if let Some(review) = ctx.config.review.resolved() {
516    if let Some(bin) = extract_launcher_binary(&review.command) {
517      needed.insert(bin);
518    }
519  }
520
521  // Whatever the user's own bootstrap commands invoke.
522  for cmd in &ctx.config.bootstrap.command {
523    if let Some(bin) = extract_binary(&cmd.run) {
524      needed.insert(bin);
525    }
526  }
527
528  let mut missing: Vec<String> = Vec::new();
529  let mut found: usize = 0;
530  for bin in &needed {
531    if which::which(bin).is_ok() {
532      found += 1;
533    } else {
534      missing.push(bin.clone());
535    }
536  }
537
538  if missing.is_empty() {
539    return Check::ok(name, format!("{}/{} binaries found", found, needed.len()));
540  }
541
542  Check::warning(name, format!("not on PATH: {}", missing.join(", ")))
543    .with_hint("install the missing binaries or remove the steps that need them")
544}
545
546/// Check #7: the configured worktree `base` directory exists and is
547/// writable. Absence is fine when the parent is writable (gwm creates the
548/// base lazily on `gwm create`); a non-writable base is a Failed because
549/// every future `create` would error out.
550fn check_base_dir_writable(ctx: &DoctorCtx<'_>) -> Check {
551  let name = "base directory writable";
552  let repo_name = worktree::repo_name(ctx.repo);
553  let repo_path = ctx.repo.workdir();
554  let base_expanded = match expand_placeholders(&ctx.config.worktree.base, &repo_name, None, None, None, repo_path) {
555    Ok(s) => s,
556    Err(e) => return Check::failed(name, format!("could not expand base placeholders: {}", e)),
557  };
558  let base = Path::new(&base_expanded);
559
560  if base.exists() {
561    return if is_writable_dir(base) {
562      Check::ok(name, format!("{} is writable", base.display()))
563    } else {
564      Check::failed(name, format!("{} exists but is not writable", base.display()))
565        .with_hint("fix the permissions, or set `[worktree].base` to a writable path")
566    };
567  }
568
569  // Base doesn't exist yet — gwm will create it. Check the parent instead.
570  let parent = match base.parent() {
571    Some(p) if !p.as_os_str().is_empty() => p,
572    _ => {
573      return Check::ok(
574        name,
575        format!("{} will be created on first `gwm create`", base.display()),
576      )
577    }
578  };
579  if !parent.exists() {
580    return Check::warning(
581      name,
582      format!(
583        "neither {} nor its parent {} exists yet",
584        base.display(),
585        parent.display()
586      ),
587    )
588    .with_hint("create the parent directory, or pick a different `[worktree].base`");
589  }
590  if is_writable_dir(parent) {
591    Check::ok(
592      name,
593      format!(
594        "{} will be created on first `gwm create` (parent writable)",
595        base.display()
596      ),
597    )
598  } else {
599    Check::failed(name, format!("parent {} is not writable", parent.display()))
600      .with_hint("fix the permissions, or set `[worktree].base` to a writable path")
601  }
602}
603
604/// Check #5: no prunable worktree entries left in `.git/worktrees/`. These
605/// happen when a worktree's working directory is deleted manually without
606/// going through `gwm remove` — the admin record stays and confuses future
607/// `gwm list` invocations.
608fn check_prunable_worktrees(trees: &[worktree::WorktreeInfo]) -> Check {
609  let name = "no prunable worktrees";
610
611  let prunable: Vec<String> = trees.iter().filter(|w| w.is_prunable).map(|w| w.name.clone()).collect();
612  if prunable.is_empty() {
613    return Check::ok(name, format!("{} worktree(s) tracked, none prunable", trees.len()));
614  }
615
616  let noun = if prunable.len() == 1 { "entry" } else { "entries" };
617  Check::warning(
618    name,
619    format!("{} prunable {}: {}", prunable.len(), noun, prunable.join(", ")),
620  )
621  .with_hint("run `gwm prune` to clear them")
622}
623
624/// Check #6: every local branch matching the `<type>/#<issue>-<desc>`
625/// shape has a worktree pointing at it. A branch without a worktree was
626/// likely created by `gwm create` and lost its worktree without a
627/// `--delete-branch` — purely cosmetic dead weight, hence Warning not Failed.
628///
629/// Branches already fully merged into one of the trunk branches
630/// (configured via `[doctor].trunks`, default `["dev", "main"]`) are
631/// filtered out: keeping them is the project convention, and surfacing
632/// them would make the check produce N false positives on every
633/// successful release. Repos with non-standard trunk names (`master`,
634/// release-trains like `release-3.x`, …) opt in by overriding the list
635/// in `.gwm.toml`. An empty list disables the filter entirely.
636fn check_orphan_branches(ctx: &DoctorCtx<'_>, trees: &[worktree::WorktreeInfo]) -> Check {
637  let name = "no orphan gwm branches";
638
639  let claimed: BTreeSet<String> = trees.iter().filter_map(|w| w.branch.clone()).collect();
640
641  // Resolve the trunk OIDs once. Missing trunks (e.g. a repo without `dev`,
642  // or a `[doctor].trunks` entry that doesn't exist locally) are silently
643  // skipped — we only check against what exists.
644  let trunk_oids: Vec<git2::Oid> = ctx
645    .config
646    .doctor
647    .trunks
648    .iter()
649    .filter_map(|t| {
650      ctx
651        .repo
652        .find_branch(t, BranchType::Local)
653        .ok()
654        .and_then(|b| b.get().target())
655    })
656    .collect();
657
658  let branches = match ctx.repo.branches(Some(BranchType::Local)) {
659    Ok(b) => b,
660    Err(e) => return Check::failed(name, format!("could not list local branches: {}", e)),
661  };
662
663  // Issue #417: "did gwm create this branch?" is a question about this repo's
664  // `worktree.branch_pattern`, so it is asked with a parser compiled from it.
665  // Against the built-in shape, every branch in a repo with a custom pattern
666  // read as user-managed and no orphan was ever reported.
667  let parser = crate::naming::BranchParser::from_config(ctx.config, &worktree::repo_name(ctx.repo));
668
669  let mut orphans: Vec<String> = Vec::new();
670  let mut merged_count: usize = 0;
671  for entry in branches.flatten() {
672    let (branch, _) = entry;
673    let Ok(Some(branch_name)) = branch.name() else { continue };
674    if parser.parse(branch_name).is_none() {
675      continue; // user-managed branch, leave it alone
676    }
677    if claimed.contains(branch_name) {
678      continue; // has a worktree — not orphan in any sense
679    }
680    let Some(branch_oid) = branch.get().target() else {
681      continue;
682    };
683    match is_merged_into_any(ctx.repo, branch_oid, &trunk_oids) {
684      Ok(true) => {
685        merged_count += 1;
686        continue; // preserved on purpose per CONTRIBUTING — not flagged
687      }
688      Ok(false) => {
689        // Real orphan — fall through.
690      }
691      Err(e) => {
692        // libgit2 couldn't walk the graph (missing objects, shallow
693        // clone, repo corruption). Surface this loudly: silently
694        // assuming "not merged" and recommending `git branch -d` would
695        // be actively dangerous.
696        return Check::failed(
697          name,
698          format!("could not determine merge status for {}: {}", branch_name, e),
699        )
700        .with_hint("check the repository integrity (`git fsck`) or re-fetch missing objects");
701      }
702    }
703    orphans.push(branch_name.to_string());
704  }
705
706  if orphans.is_empty() {
707    let detail = if merged_count == 0 {
708      "every gwm-style branch has a matching worktree".to_string()
709    } else {
710      format!(
711        "{} merged gwm-style branch(es) preserved per CONTRIBUTING, no unmerged orphans",
712        merged_count
713      )
714    };
715    return Check::ok(name, detail);
716  }
717
718  let suggestions: Vec<String> = orphans.iter().map(|b| format!("git branch -d {}", b)).collect();
719  Check::warning(
720    name,
721    format!("{} unmerged orphan branch(es): {}", orphans.len(), orphans.join(", ")),
722  )
723  .with_hint(suggestions.join(" && "))
724}
725
726/// Returns `Ok(true)` iff `branch_oid` is fully reachable from at least
727/// one of `trunks` — i.e. the branch is merged into one of the trunks
728/// (or is equal to it). Implemented via libgit2's descendant check:
729/// trunk is a descendant of the branch iff the branch is reachable
730/// from trunk. Propagates `git2::Error` so callers can distinguish
731/// "definitively unmerged" from "could not tell" — silently swallowing
732/// the error would let a misclassification lead to a destructive
733/// `git branch -d` suggestion.
734fn is_merged_into_any(
735  repo: &git2::Repository,
736  branch_oid: git2::Oid,
737  trunks: &[git2::Oid],
738) -> std::result::Result<bool, git2::Error> {
739  for trunk_oid in trunks {
740    if *trunk_oid == branch_oid {
741      return Ok(true);
742    }
743    if repo.graph_descendant_of(*trunk_oid, branch_oid)? {
744      return Ok(true);
745    }
746  }
747  Ok(false)
748}
749
750/// Probe a directory for write access by creating and deleting a unique
751/// sentinel file. More reliable across platforms than parsing Unix mode
752/// bits. Uses `tempfile::Builder` so concurrent `gwm doctor` runs don't
753/// collide on a fixed filename, and so a SIGKILL mid-probe doesn't leak
754/// a stray sentinel into the user's worktree base — `NamedTempFile`
755/// RAII-cleans on drop.
756fn is_writable_dir(dir: &Path) -> bool {
757  tempfile::Builder::new()
758    .prefix(".gwm-doctor-probe-")
759    .rand_bytes(8)
760    .tempfile_in(dir)
761    .is_ok()
762}