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 ©.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}