1use 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 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 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 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
101pub 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 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 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
146fn check_tui_keymap(ctx: &DoctorCtx<'_>) -> Check {
165 let name = "[tui.keys] keymap resolves";
166
167 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 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 let bindings = keymap.list();
208
209 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 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
238fn 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 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
274fn 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 ©.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
303const SUPPORTED_WHEN_PREFIXES: &[&str] = &["file_exists:", "cmd_exists:", "env_set:", "env_eq:", "glob_exists:"];
306
307fn 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 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
355const COMMAND_WRAPPERS: &[&str] = &["env", "command"];
363
364fn extract_binary(run: &str) -> Option<String> {
374 let tokens = shell_words::split(run).ok()?;
375 let mut iter = tokens.into_iter().peekable();
376
377 while iter.peek().is_some_and(|t| !t.starts_with('=') && t.contains('=')) {
379 iter.next();
380 }
381
382 if iter.peek().is_some_and(|t| COMMAND_WRAPPERS.contains(&t.as_str())) {
386 iter.next(); 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
399fn 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
413fn check_binaries_on_path(ctx: &DoctorCtx<'_>) -> Check {
423 let name = "external binaries on PATH";
424 let mut needed: BTreeSet<String> = BTreeSet::new();
425
426 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 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 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
469fn 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 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
527fn 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
547fn 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 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; }
594 if claimed.contains(branch_name) {
595 continue; }
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; }
605 Ok(false) => {
606 }
608 Err(e) => {
609 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
643fn 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
667fn 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}