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::parse_branch;
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  Ok(report)
144}
145
146/// TUI keymap diagnostic (issue #87). Re-runs the same
147/// [`crate::tui::keymap::Keymap`] resolution path the TUI itself uses
148/// at startup, so any user-facing `[tui.keys]` mistake surfaces here
149/// before the TUI actually fails to dispatch.
150///
151/// Three outcomes:
152///
153/// 1. **Failed** — the keymap fails to resolve (parse error, unknown
154///    action slug, chord conflict, prefix collision). The detail
155///    repeats the underlying [`crate::error::GwmError::Config`]
156///    message verbatim so the user can paste it into a search.
157/// 2. **Warning** — the keymap resolves, but `quit` has been
158///    unbound entirely. The hard-coded `Ctrl+C` branch in `run_app`
159///    keeps the TUI exitable; we warn anyway because losing the
160///    discoverable quit key is a hostile UX choice users usually
161///    don't realise they made.
162/// 3. **Ok** — keymap is valid and `quit` has at least one
163///    user-visible binding.
164fn check_tui_keymap(ctx: &DoctorCtx<'_>) -> Check {
165  let name = "[tui.keys] keymap resolves";
166
167  // Re-derive `[tui.keys]` from the on-disk config (#219 review): the lenient
168  // `repo_context_lenient` returns `Config::default()` when `load_for_repo`
169  // rejects the user's file, so validating `ctx.config` here would silently
170  // OK a config that actually refuses to start the TUI. The global layer is
171  // the one threaded into the context (not an ambient `global_config_path()`
172  // read) so the check stays deterministic for injected contexts. Fall back to
173  // `ctx.config` only when the *merge* fails — a parse / shape error already
174  // surfaced by `check_config_parses`.
175  let keys = match Config::merge_layered(ctx.repo_workdir, ctx.global_config_path) {
176    Ok(cfg) => cfg.tui.keys,
177    Err(_) => ctx.config.tui.keys.clone(),
178  };
179
180  let keymap = match keys.resolved_keymap() {
181    Ok(km) => km,
182    Err(e) => {
183      return Check::failed(name, format!("{}", e))
184        .with_hint("fix the `[tui.keys]` entry called out above; the full list of action slugs is `gwm tui keys`");
185    }
186  };
187
188  // Issue #219: the contextual modal keymap (`[tui.keys.modal.<context>]`) is
189  // validated the same way — an unknown context / verb, a multi-stroke
190  // chord, or a per-context conflict surfaces here with the offending
191  // coordinate so `gwm doctor` flags it before the user hits it live.
192  // #219 review (P2): resolve it *before* the quit warning so a hard modal
193  // error (Failed) is never downgraded to the `quit` Warning when a config
194  // carries both an unbound `quit` and an invalid modal binding.
195  let modal = match keys.resolved_modal_keymap() {
196    Ok(mk) => mk,
197    Err(e) => {
198      return Check::failed(name, format!("{}", e)).with_hint(
199        "fix the `[tui.keys.modal.<context>]` entry called out above; `gwm tui keys` lists every context and verb",
200      );
201    }
202  };
203
204  // Snapshot once. The pre-review version called `keymap.list()`
205  // twice — both `quit_has_user_binding` and the success count
206  // cloned the bindings vector. One snapshot reused below.
207  let bindings = keymap.list();
208
209  // Quit is special: the only hard-coded escape hatch is `Ctrl+C` in
210  // `run_app`. We don't refuse an empty `quit` binding (per the design
211  // note in `src/tui/keymap.rs`), but we do flag it so the user knows
212  // the discoverable key is gone.
213  let quit_has_user_binding = bindings
214    .iter()
215    .any(|b| b.action == crate::tui::keymap::Action::Quit && !b.chords.is_empty());
216  if !quit_has_user_binding {
217    return Check::warning(
218      name,
219      "`quit` has no binding — Ctrl+C still exits the TUI as a hard-coded fallback, but no discoverable key remains",
220    )
221    .with_hint("add `quit = [\"q\", \"Esc\"]` (or any other key) to `[tui.keys]`");
222  }
223
224  // Count only actions with at least one chord. The pre-review
225  // version used `bindings.len()` which includes unbound entries
226  // (`action = []` in `[tui.keys]` leaves the action in the list
227  // with an empty chord vec), inflating the count visible in
228  // `gwm doctor` output and misleading the user about how many
229  // actions are actually reachable.
230  let bound_count = bindings.iter().filter(|b| !b.chords.is_empty()).count();
231  let modal_bound = modal.list().iter().filter(|b| !b.keys.is_empty()).count();
232  Check::ok(
233    name,
234    format!("{} global + {} modal binding(s) bound", bound_count, modal_bound),
235  )
236}
237
238/// Check #1: `.gwm.toml` parses cleanly. Missing config is fine — defaults
239/// are documented and identical to what `gwm init` writes out. Invalid TOML
240/// is a hard failure since it would crash every other subcommand.
241fn check_config_parses(ctx: &DoctorCtx<'_>) -> Check {
242  let path = ctx.repo_workdir.join(CONFIG_FILE);
243  let name = ".gwm.toml parses";
244
245  if !path.exists() {
246    return Check::ok(name, "no .gwm.toml present — defaults assumed");
247  }
248
249  let raw = match std::fs::read_to_string(&path) {
250    Ok(s) => s,
251    Err(e) => {
252      return Check::failed(name, format!("could not read {}: {}", path.display(), e));
253    }
254  };
255
256  let cfg = match toml::from_str::<Config>(&raw) {
257    Ok(cfg) => cfg,
258    Err(e) => {
259      return Check::failed(name, format!("invalid TOML in {}: {}", path.display(), e))
260        .with_hint("fix the syntax or back it up and re-run `gwm init`");
261    }
262  };
263  // `[exec.profiles]` / `[clean.profiles]` semantics (non-empty command, a
264  // worktree-relative single-name `dirs`) parse cleanly, so check them here
265  // too — otherwise doctor reports green on a profile the loader and the
266  // `gwm exec`/`gwm clean` commands reject (issue #324 review).
267  match cfg.validate_profiles() {
268    Ok(()) => Check::ok(name, format!("{} parses cleanly", path.display())),
269    Err(e) => Check::failed(name, format!("invalid profile in {}: {}", path.display(), e))
270      .with_hint("fix the `[exec.profiles]` / `[clean.profiles]` entry it names"),
271  }
272}
273
274/// Check #2: every `[[bootstrap.copy]].guards = [...]` entry references a
275/// `[[bootstrap.guard]].name` that actually exists. Dangling references are
276/// silent footguns — the copy step would proceed unchecked and the guard
277/// would never trip.
278fn check_guard_references(ctx: &DoctorCtx<'_>) -> Check {
279  let name = "guard references resolve";
280  let bs = &ctx.config.bootstrap;
281
282  let mut dangling: Vec<String> = Vec::new();
283  for copy in &bs.copy {
284    for guard_name in &copy.guards {
285      if ctx.config.guard_by_name(guard_name).is_none() {
286        dangling.push(format!(
287          "{} (referenced from copy {} -> {})",
288          guard_name, copy.from, copy.to
289        ));
290      }
291    }
292  }
293
294  if dangling.is_empty() {
295    let count: usize = bs.copy.iter().map(|c| c.guards.len()).sum();
296    return Check::ok(name, format!("{} guard reference(s) resolve", count));
297  }
298
299  Check::failed(name, format!("dangling guard reference(s): {}", dangling.join("; ")))
300    .with_hint("declare the missing `[[bootstrap.guard]]` block(s) or drop the reference")
301}
302
303/// Recognised `when:` predicate keywords. Update this list when a new
304/// keyword lands in `bootstrap.rs::evaluate_when`.
305const SUPPORTED_WHEN_PREFIXES: &[&str] = &["file_exists:", "cmd_exists:", "env_set:", "env_eq:", "glob_exists:"];
306
307/// Check #3: every `[[bootstrap.command]].when` predicate uses one of the
308/// supported keywords. Unknown predicates default to `true` in
309/// `bootstrap::evaluate_when`, so the command runs anyway and the user's
310/// intended gating condition is silently ignored — that's still a footgun
311/// worth flagging, just not "command never runs".
312///
313/// Walks every atom in the expression (via `bootstrap::when_atoms`) so
314/// negated atoms (`!env_set:CI`) and compound expressions
315/// (`file_exists:a && bogus:1`) are validated as a whole instead of
316/// being green-lit by their first keyword.
317fn check_when_predicates(ctx: &DoctorCtx<'_>) -> Check {
318  let name = "`when` predicates supported";
319  let bs = &ctx.config.bootstrap;
320
321  let mut unknown: Vec<String> = Vec::new();
322  let mut recognised: usize = 0;
323  for cmd in &bs.command {
324    let Some(w) = &cmd.when else { continue };
325    // Walk every atom in the expression (via `bootstrap::when_atoms`) so
326    // negated atoms (`!env_set:CI`) and compound expressions (`file_exists:a
327    // && bogus:1`) are validated as a whole rather than green-lit by their
328    // first keyword. A command is `recognised` only when all its atoms
329    // pass — a single unknown atom kicks it into `unknown`.
330    let mut had_unknown = false;
331    for atom in crate::bootstrap::when_atoms(w) {
332      if !SUPPORTED_WHEN_PREFIXES.iter().any(|p| atom.starts_with(p)) {
333        unknown.push(format!("{} (on command `{}`)", atom, cmd.name));
334        had_unknown = true;
335      }
336    }
337    if !had_unknown {
338      recognised += 1;
339    }
340  }
341
342  if unknown.is_empty() {
343    let detail = if recognised == 0 {
344      "no `when:` predicates configured".to_string()
345    } else {
346      format!("{} predicate(s) recognised", recognised)
347    };
348    return Check::ok(name, detail);
349  }
350
351  Check::failed(name, format!("unknown `when` predicate(s): {}", unknown.join("; ")))
352    .with_hint(format!("supported keywords: {}", SUPPORTED_WHEN_PREFIXES.join(", ")))
353}
354
355/// Common shell wrappers that introduce the real binary after their
356/// own switches / env assignments. Caught by Copilot's review on
357/// PR #76: pre-fix, `env FOO=bar lumen diff` made the doctor check
358/// `env` against `$PATH` (which is always present) and miss the real
359/// launcher `lumen`. Keep this list narrow on purpose — exotic
360/// wrappers (`nice`, `time`, `nohup`) take positional args, which we
361/// would risk consuming and ending up with the wrong binary.
362const COMMAND_WRAPPERS: &[&str] = &["env", "command"];
363
364/// Extract the executable name from a shell command string. Tokenises
365/// via `shell_words` so quoted args (`"my tool" --flag`) and escaped
366/// whitespace are handled the way the shell would, then skips leading
367/// `FOO=bar` env assignments and recognised `env`/`command` wrappers
368/// (and the wrapper's own `KEY=VAL` / `-flag` tokens) before returning
369/// the first token that looks like a real binary name. Returns `None`
370/// for empty strings or strings that fail to parse (unbalanced quotes
371/// — better to surface nothing than a garbage binary name that would
372/// produce a confusing PATH warning).
373fn extract_binary(run: &str) -> Option<String> {
374  let tokens = shell_words::split(run).ok()?;
375  let mut iter = tokens.into_iter().peekable();
376
377  // Skip leading `KEY=VAL` env assignments (POSIX `FOO=bar tool` form).
378  while iter.peek().is_some_and(|t| !t.starts_with('=') && t.contains('=')) {
379    iter.next();
380  }
381
382  // Recognise a wrapper (`env`, `command`) and skip its own `-flag` /
383  // `KEY=VAL` arguments before reaching the real binary. Stops on the
384  // first positional non-flag, non-assignment token.
385  if iter.peek().is_some_and(|t| COMMAND_WRAPPERS.contains(&t.as_str())) {
386    iter.next(); // consume the wrapper itself
387    while let Some(t) = iter.peek() {
388      if t.starts_with('-') || (!t.starts_with('=') && t.contains('=')) {
389        iter.next();
390      } else {
391        break;
392      }
393    }
394  }
395
396  iter.next()
397}
398
399/// Same as [`extract_binary`] but pre-strips the launcher placeholders
400/// so a template like `lumen diff {base}..{head}` reduces to `lumen`
401/// before tokenisation. Used for the issue #75 [`crate::config::GitTuiConfig`] /
402/// [`crate::config::ReviewConfig`] entries so the doctor warning
403/// names the actual binary, not a placeholder fragment.
404fn extract_launcher_binary(command: &str) -> Option<String> {
405  let cleaned = command
406    .replace("{base}", "BASE")
407    .replace("{head}", "HEAD")
408    .replace("{path}", "PATH")
409    .replace("{diff}", "/tmp/diff");
410  extract_binary(&cleaned)
411}
412
413/// Check #4: every binary referenced by the bootstrap commands resolves on
414/// `$PATH`. `lazygit` (the TUI's `l` keybinding's default) and `direnv`
415/// (only if the repo has an `.envrc`) are also checked because they're
416/// the two "ambient" dependencies whose absence routinely confuses new
417/// users. Configured launchers ([git_tui], [review] — issue #75) are
418/// added to the same set so the user gets one consolidated warning.
419///
420/// Missing binaries are surfaced as Warning, not Failed — the user may not
421/// rely on that step at all, but the visibility matters.
422fn check_binaries_on_path(ctx: &DoctorCtx<'_>) -> Check {
423  let name = "external binaries on PATH";
424  let mut needed: BTreeSet<String> = BTreeSet::new();
425
426  // Ambient deps the rest of the CLI uses. `[git_tui]` may override the
427  // lazygit default; we extract whatever binary the resolved launcher
428  // names so a `gitui` / `tig` user gets the right warning.
429  let git_tui = ctx.config.git_tui.resolved();
430  if let Some(bin) = extract_launcher_binary(&git_tui.command) {
431    needed.insert(bin);
432  }
433  if ctx.repo_workdir.join(".envrc").exists() {
434    needed.insert("direnv".into());
435  }
436  // Review launcher is opt-in; only probe when the user actually
437  // configured one (`command` or `tool`).
438  if let Some(review) = ctx.config.review.resolved() {
439    if let Some(bin) = extract_launcher_binary(&review.command) {
440      needed.insert(bin);
441    }
442  }
443
444  // Whatever the user's own bootstrap commands invoke.
445  for cmd in &ctx.config.bootstrap.command {
446    if let Some(bin) = extract_binary(&cmd.run) {
447      needed.insert(bin);
448    }
449  }
450
451  let mut missing: Vec<String> = Vec::new();
452  let mut found: usize = 0;
453  for bin in &needed {
454    if which::which(bin).is_ok() {
455      found += 1;
456    } else {
457      missing.push(bin.clone());
458    }
459  }
460
461  if missing.is_empty() {
462    return Check::ok(name, format!("{}/{} binaries found", found, needed.len()));
463  }
464
465  Check::warning(name, format!("not on PATH: {}", missing.join(", ")))
466    .with_hint("install the missing binaries or remove the steps that need them")
467}
468
469/// Check #7: the configured worktree `base` directory exists and is
470/// writable. Absence is fine when the parent is writable (gwm creates the
471/// base lazily on `gwm create`); a non-writable base is a Failed because
472/// every future `create` would error out.
473fn check_base_dir_writable(ctx: &DoctorCtx<'_>) -> Check {
474  let name = "base directory writable";
475  let repo_name = worktree::repo_name(ctx.repo);
476  let repo_path = ctx.repo.workdir();
477  let base_expanded = match expand_placeholders(&ctx.config.worktree.base, &repo_name, None, None, None, repo_path) {
478    Ok(s) => s,
479    Err(e) => return Check::failed(name, format!("could not expand base placeholders: {}", e)),
480  };
481  let base = Path::new(&base_expanded);
482
483  if base.exists() {
484    return if is_writable_dir(base) {
485      Check::ok(name, format!("{} is writable", base.display()))
486    } else {
487      Check::failed(name, format!("{} exists but is not writable", base.display()))
488        .with_hint("fix the permissions, or set `[worktree].base` to a writable path")
489    };
490  }
491
492  // Base doesn't exist yet — gwm will create it. Check the parent instead.
493  let parent = match base.parent() {
494    Some(p) if !p.as_os_str().is_empty() => p,
495    _ => {
496      return Check::ok(
497        name,
498        format!("{} will be created on first `gwm create`", base.display()),
499      )
500    }
501  };
502  if !parent.exists() {
503    return Check::warning(
504      name,
505      format!(
506        "neither {} nor its parent {} exists yet",
507        base.display(),
508        parent.display()
509      ),
510    )
511    .with_hint("create the parent directory, or pick a different `[worktree].base`");
512  }
513  if is_writable_dir(parent) {
514    Check::ok(
515      name,
516      format!(
517        "{} will be created on first `gwm create` (parent writable)",
518        base.display()
519      ),
520    )
521  } else {
522    Check::failed(name, format!("parent {} is not writable", parent.display()))
523      .with_hint("fix the permissions, or set `[worktree].base` to a writable path")
524  }
525}
526
527/// Check #5: no prunable worktree entries left in `.git/worktrees/`. These
528/// happen when a worktree's working directory is deleted manually without
529/// going through `gwm remove` — the admin record stays and confuses future
530/// `gwm list` invocations.
531fn check_prunable_worktrees(trees: &[worktree::WorktreeInfo]) -> Check {
532  let name = "no prunable worktrees";
533
534  let prunable: Vec<String> = trees.iter().filter(|w| w.is_prunable).map(|w| w.name.clone()).collect();
535  if prunable.is_empty() {
536    return Check::ok(name, format!("{} worktree(s) tracked, none prunable", trees.len()));
537  }
538
539  let noun = if prunable.len() == 1 { "entry" } else { "entries" };
540  Check::warning(
541    name,
542    format!("{} prunable {}: {}", prunable.len(), noun, prunable.join(", ")),
543  )
544  .with_hint("run `gwm prune` to clear them")
545}
546
547/// Check #6: every local branch matching the `<type>/#<issue>-<desc>`
548/// shape has a worktree pointing at it. A branch without a worktree was
549/// likely created by `gwm create` and lost its worktree without a
550/// `--delete-branch` — purely cosmetic dead weight, hence Warning not Failed.
551///
552/// Branches already fully merged into one of the trunk branches
553/// (configured via `[doctor].trunks`, default `["dev", "main"]`) are
554/// filtered out: keeping them is the project convention, and surfacing
555/// them would make the check produce N false positives on every
556/// successful release. Repos with non-standard trunk names (`master`,
557/// release-trains like `release-3.x`, …) opt in by overriding the list
558/// in `.gwm.toml`. An empty list disables the filter entirely.
559fn check_orphan_branches(ctx: &DoctorCtx<'_>, trees: &[worktree::WorktreeInfo]) -> Check {
560  let name = "no orphan gwm branches";
561
562  let claimed: BTreeSet<String> = trees.iter().filter_map(|w| w.branch.clone()).collect();
563
564  // Resolve the trunk OIDs once. Missing trunks (e.g. a repo without `dev`,
565  // or a `[doctor].trunks` entry that doesn't exist locally) are silently
566  // skipped — we only check against what exists.
567  let trunk_oids: Vec<git2::Oid> = ctx
568    .config
569    .doctor
570    .trunks
571    .iter()
572    .filter_map(|t| {
573      ctx
574        .repo
575        .find_branch(t, BranchType::Local)
576        .ok()
577        .and_then(|b| b.get().target())
578    })
579    .collect();
580
581  let branches = match ctx.repo.branches(Some(BranchType::Local)) {
582    Ok(b) => b,
583    Err(e) => return Check::failed(name, format!("could not list local branches: {}", e)),
584  };
585
586  let mut orphans: Vec<String> = Vec::new();
587  let mut merged_count: usize = 0;
588  for entry in branches.flatten() {
589    let (branch, _) = entry;
590    let Ok(Some(branch_name)) = branch.name() else { continue };
591    if parse_branch(branch_name).is_none() {
592      continue; // user-managed branch, leave it alone
593    }
594    if claimed.contains(branch_name) {
595      continue; // has a worktree — not orphan in any sense
596    }
597    let Some(branch_oid) = branch.get().target() else {
598      continue;
599    };
600    match is_merged_into_any(ctx.repo, branch_oid, &trunk_oids) {
601      Ok(true) => {
602        merged_count += 1;
603        continue; // preserved on purpose per CONTRIBUTING — not flagged
604      }
605      Ok(false) => {
606        // Real orphan — fall through.
607      }
608      Err(e) => {
609        // libgit2 couldn't walk the graph (missing objects, shallow
610        // clone, repo corruption). Surface this loudly: silently
611        // assuming "not merged" and recommending `git branch -d` would
612        // be actively dangerous.
613        return Check::failed(
614          name,
615          format!("could not determine merge status for {}: {}", branch_name, e),
616        )
617        .with_hint("check the repository integrity (`git fsck`) or re-fetch missing objects");
618      }
619    }
620    orphans.push(branch_name.to_string());
621  }
622
623  if orphans.is_empty() {
624    let detail = if merged_count == 0 {
625      "every gwm-style branch has a matching worktree".to_string()
626    } else {
627      format!(
628        "{} merged gwm-style branch(es) preserved per CONTRIBUTING, no unmerged orphans",
629        merged_count
630      )
631    };
632    return Check::ok(name, detail);
633  }
634
635  let suggestions: Vec<String> = orphans.iter().map(|b| format!("git branch -d {}", b)).collect();
636  Check::warning(
637    name,
638    format!("{} unmerged orphan branch(es): {}", orphans.len(), orphans.join(", ")),
639  )
640  .with_hint(suggestions.join(" && "))
641}
642
643/// Returns `Ok(true)` iff `branch_oid` is fully reachable from at least
644/// one of `trunks` — i.e. the branch is merged into one of the trunks
645/// (or is equal to it). Implemented via libgit2's descendant check:
646/// trunk is a descendant of the branch iff the branch is reachable
647/// from trunk. Propagates `git2::Error` so callers can distinguish
648/// "definitively unmerged" from "could not tell" — silently swallowing
649/// the error would let a misclassification lead to a destructive
650/// `git branch -d` suggestion.
651fn is_merged_into_any(
652  repo: &git2::Repository,
653  branch_oid: git2::Oid,
654  trunks: &[git2::Oid],
655) -> std::result::Result<bool, git2::Error> {
656  for trunk_oid in trunks {
657    if *trunk_oid == branch_oid {
658      return Ok(true);
659    }
660    if repo.graph_descendant_of(*trunk_oid, branch_oid)? {
661      return Ok(true);
662    }
663  }
664  Ok(false)
665}
666
667/// Probe a directory for write access by creating and deleting a unique
668/// sentinel file. More reliable across platforms than parsing Unix mode
669/// bits. Uses `tempfile::Builder` so concurrent `gwm doctor` runs don't
670/// collide on a fixed filename, and so a SIGKILL mid-probe doesn't leak
671/// a stray sentinel into the user's worktree base — `NamedTempFile`
672/// RAII-cleans on drop.
673fn is_writable_dir(dir: &Path) -> bool {
674  tempfile::Builder::new()
675    .prefix(".gwm-doctor-probe-")
676    .rand_bytes(8)
677    .tempfile_in(dir)
678    .is_ok()
679}