use crate::config::{expand_placeholders, Config, CONFIG_FILE};
use crate::error::Result;
use crate::naming::parse_branch;
use crate::worktree;
use git2::BranchType;
use std::collections::BTreeSet;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct DoctorReport {
pub checks: Vec<Check>,
}
impl DoctorReport {
pub fn new() -> Self {
Self::default()
}
pub fn severity(&self) -> CheckStatus {
let mut s = CheckStatus::Ok;
for c in &self.checks {
match c.status {
CheckStatus::Failed => return CheckStatus::Failed,
CheckStatus::Warning if s == CheckStatus::Ok => s = CheckStatus::Warning,
_ => {}
}
}
s
}
pub fn exit_code(&self) -> i32 {
match self.severity() {
CheckStatus::Ok => 0,
CheckStatus::Warning => 1,
CheckStatus::Failed => 2,
}
}
}
#[derive(Debug, Clone)]
pub struct Check {
pub name: String,
pub status: CheckStatus,
pub detail: String,
pub fix_hint: Option<String>,
}
impl Check {
pub fn ok(name: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
status: CheckStatus::Ok,
detail: detail.into(),
fix_hint: None,
}
}
pub fn warning(name: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
status: CheckStatus::Warning,
detail: detail.into(),
fix_hint: None,
}
}
pub fn failed(name: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
status: CheckStatus::Failed,
detail: detail.into(),
fix_hint: None,
}
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.fix_hint = Some(hint.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckStatus {
Ok,
Warning,
Failed,
}
pub type Severity = CheckStatus;
pub struct DoctorCtx<'a> {
pub repo_workdir: &'a Path,
pub repo: &'a git2::Repository,
pub config: &'a Config,
pub global_config_path: Option<&'a Path>,
}
pub fn run(ctx: &DoctorCtx<'_>) -> Result<DoctorReport> {
let mut report = DoctorReport::new();
report.checks.push(check_config_parses(ctx));
report.checks.push(check_guard_references(ctx));
report.checks.push(check_when_predicates(ctx));
report.checks.push(check_binaries_on_path(ctx));
match worktree::list(ctx.repo) {
Ok(trees) => {
report.checks.push(check_prunable_worktrees(&trees));
report.checks.push(check_orphan_branches(ctx, &trees));
}
Err(e) => {
let detail = format!("could not list worktrees: {}", e);
report.checks.push(Check::failed("no prunable worktrees", &detail));
report.checks.push(Check::failed("no orphan gwm branches", &detail));
}
}
report.checks.push(check_base_dir_writable(ctx));
report.checks.push(check_tui_keymap(ctx));
Ok(report)
}
fn check_tui_keymap(ctx: &DoctorCtx<'_>) -> Check {
let name = "[tui.keys] keymap resolves";
let keys = match Config::merge_layered(ctx.repo_workdir, ctx.global_config_path) {
Ok(cfg) => cfg.tui.keys,
Err(_) => ctx.config.tui.keys.clone(),
};
let keymap = match keys.resolved_keymap() {
Ok(km) => km,
Err(e) => {
return Check::failed(name, format!("{}", e))
.with_hint("fix the `[tui.keys]` entry called out above; the full list of action slugs is `gwm tui keys`");
}
};
let modal = match keys.resolved_modal_keymap() {
Ok(mk) => mk,
Err(e) => {
return Check::failed(name, format!("{}", e)).with_hint(
"fix the `[tui.keys.modal.<context>]` entry called out above; `gwm tui keys` lists every context and verb",
);
}
};
let bindings = keymap.list();
let quit_has_user_binding = bindings
.iter()
.any(|b| b.action == crate::tui::keymap::Action::Quit && !b.chords.is_empty());
if !quit_has_user_binding {
return Check::warning(
name,
"`quit` has no binding — Ctrl+C still exits the TUI as a hard-coded fallback, but no discoverable key remains",
)
.with_hint("add `quit = [\"q\", \"Esc\"]` (or any other key) to `[tui.keys]`");
}
let bound_count = bindings.iter().filter(|b| !b.chords.is_empty()).count();
let modal_bound = modal.list().iter().filter(|b| !b.keys.is_empty()).count();
Check::ok(
name,
format!("{} global + {} modal binding(s) bound", bound_count, modal_bound),
)
}
fn check_config_parses(ctx: &DoctorCtx<'_>) -> Check {
let path = ctx.repo_workdir.join(CONFIG_FILE);
let name = ".gwm.toml parses";
if !path.exists() {
return Check::ok(name, "no .gwm.toml present — defaults assumed");
}
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
return Check::failed(name, format!("could not read {}: {}", path.display(), e));
}
};
let cfg = match toml::from_str::<Config>(&raw) {
Ok(cfg) => cfg,
Err(e) => {
return Check::failed(name, format!("invalid TOML in {}: {}", path.display(), e))
.with_hint("fix the syntax or back it up and re-run `gwm init`");
}
};
match cfg.validate_profiles() {
Ok(()) => Check::ok(name, format!("{} parses cleanly", path.display())),
Err(e) => Check::failed(name, format!("invalid profile in {}: {}", path.display(), e))
.with_hint("fix the `[exec.profiles]` / `[clean.profiles]` entry it names"),
}
}
fn check_guard_references(ctx: &DoctorCtx<'_>) -> Check {
let name = "guard references resolve";
let bs = &ctx.config.bootstrap;
let mut dangling: Vec<String> = Vec::new();
for copy in &bs.copy {
for guard_name in ©.guards {
if ctx.config.guard_by_name(guard_name).is_none() {
dangling.push(format!(
"{} (referenced from copy {} -> {})",
guard_name, copy.from, copy.to
));
}
}
}
if dangling.is_empty() {
let count: usize = bs.copy.iter().map(|c| c.guards.len()).sum();
return Check::ok(name, format!("{} guard reference(s) resolve", count));
}
Check::failed(name, format!("dangling guard reference(s): {}", dangling.join("; ")))
.with_hint("declare the missing `[[bootstrap.guard]]` block(s) or drop the reference")
}
const SUPPORTED_WHEN_PREFIXES: &[&str] = &["file_exists:", "cmd_exists:", "env_set:", "env_eq:", "glob_exists:"];
fn check_when_predicates(ctx: &DoctorCtx<'_>) -> Check {
let name = "`when` predicates supported";
let bs = &ctx.config.bootstrap;
let mut unknown: Vec<String> = Vec::new();
let mut recognised: usize = 0;
for cmd in &bs.command {
let Some(w) = &cmd.when else { continue };
let mut had_unknown = false;
for atom in crate::bootstrap::when_atoms(w) {
if !SUPPORTED_WHEN_PREFIXES.iter().any(|p| atom.starts_with(p)) {
unknown.push(format!("{} (on command `{}`)", atom, cmd.name));
had_unknown = true;
}
}
if !had_unknown {
recognised += 1;
}
}
if unknown.is_empty() {
let detail = if recognised == 0 {
"no `when:` predicates configured".to_string()
} else {
format!("{} predicate(s) recognised", recognised)
};
return Check::ok(name, detail);
}
Check::failed(name, format!("unknown `when` predicate(s): {}", unknown.join("; ")))
.with_hint(format!("supported keywords: {}", SUPPORTED_WHEN_PREFIXES.join(", ")))
}
const COMMAND_WRAPPERS: &[&str] = &["env", "command"];
fn extract_binary(run: &str) -> Option<String> {
let tokens = shell_words::split(run).ok()?;
let mut iter = tokens.into_iter().peekable();
while iter.peek().is_some_and(|t| !t.starts_with('=') && t.contains('=')) {
iter.next();
}
if iter.peek().is_some_and(|t| COMMAND_WRAPPERS.contains(&t.as_str())) {
iter.next(); while let Some(t) = iter.peek() {
if t.starts_with('-') || (!t.starts_with('=') && t.contains('=')) {
iter.next();
} else {
break;
}
}
}
iter.next()
}
fn extract_launcher_binary(command: &str) -> Option<String> {
let cleaned = command
.replace("{base}", "BASE")
.replace("{head}", "HEAD")
.replace("{path}", "PATH")
.replace("{diff}", "/tmp/diff");
extract_binary(&cleaned)
}
fn check_binaries_on_path(ctx: &DoctorCtx<'_>) -> Check {
let name = "external binaries on PATH";
let mut needed: BTreeSet<String> = BTreeSet::new();
let git_tui = ctx.config.git_tui.resolved();
if let Some(bin) = extract_launcher_binary(&git_tui.command) {
needed.insert(bin);
}
if ctx.repo_workdir.join(".envrc").exists() {
needed.insert("direnv".into());
}
if let Some(review) = ctx.config.review.resolved() {
if let Some(bin) = extract_launcher_binary(&review.command) {
needed.insert(bin);
}
}
for cmd in &ctx.config.bootstrap.command {
if let Some(bin) = extract_binary(&cmd.run) {
needed.insert(bin);
}
}
let mut missing: Vec<String> = Vec::new();
let mut found: usize = 0;
for bin in &needed {
if which::which(bin).is_ok() {
found += 1;
} else {
missing.push(bin.clone());
}
}
if missing.is_empty() {
return Check::ok(name, format!("{}/{} binaries found", found, needed.len()));
}
Check::warning(name, format!("not on PATH: {}", missing.join(", ")))
.with_hint("install the missing binaries or remove the steps that need them")
}
fn check_base_dir_writable(ctx: &DoctorCtx<'_>) -> Check {
let name = "base directory writable";
let repo_name = worktree::repo_name(ctx.repo);
let repo_path = ctx.repo.workdir();
let base_expanded = match expand_placeholders(&ctx.config.worktree.base, &repo_name, None, None, None, repo_path) {
Ok(s) => s,
Err(e) => return Check::failed(name, format!("could not expand base placeholders: {}", e)),
};
let base = Path::new(&base_expanded);
if base.exists() {
return if is_writable_dir(base) {
Check::ok(name, format!("{} is writable", base.display()))
} else {
Check::failed(name, format!("{} exists but is not writable", base.display()))
.with_hint("fix the permissions, or set `[worktree].base` to a writable path")
};
}
let parent = match base.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => {
return Check::ok(
name,
format!("{} will be created on first `gwm create`", base.display()),
)
}
};
if !parent.exists() {
return Check::warning(
name,
format!(
"neither {} nor its parent {} exists yet",
base.display(),
parent.display()
),
)
.with_hint("create the parent directory, or pick a different `[worktree].base`");
}
if is_writable_dir(parent) {
Check::ok(
name,
format!(
"{} will be created on first `gwm create` (parent writable)",
base.display()
),
)
} else {
Check::failed(name, format!("parent {} is not writable", parent.display()))
.with_hint("fix the permissions, or set `[worktree].base` to a writable path")
}
}
fn check_prunable_worktrees(trees: &[worktree::WorktreeInfo]) -> Check {
let name = "no prunable worktrees";
let prunable: Vec<String> = trees.iter().filter(|w| w.is_prunable).map(|w| w.name.clone()).collect();
if prunable.is_empty() {
return Check::ok(name, format!("{} worktree(s) tracked, none prunable", trees.len()));
}
let noun = if prunable.len() == 1 { "entry" } else { "entries" };
Check::warning(
name,
format!("{} prunable {}: {}", prunable.len(), noun, prunable.join(", ")),
)
.with_hint("run `gwm prune` to clear them")
}
fn check_orphan_branches(ctx: &DoctorCtx<'_>, trees: &[worktree::WorktreeInfo]) -> Check {
let name = "no orphan gwm branches";
let claimed: BTreeSet<String> = trees.iter().filter_map(|w| w.branch.clone()).collect();
let trunk_oids: Vec<git2::Oid> = ctx
.config
.doctor
.trunks
.iter()
.filter_map(|t| {
ctx
.repo
.find_branch(t, BranchType::Local)
.ok()
.and_then(|b| b.get().target())
})
.collect();
let branches = match ctx.repo.branches(Some(BranchType::Local)) {
Ok(b) => b,
Err(e) => return Check::failed(name, format!("could not list local branches: {}", e)),
};
let mut orphans: Vec<String> = Vec::new();
let mut merged_count: usize = 0;
for entry in branches.flatten() {
let (branch, _) = entry;
let Ok(Some(branch_name)) = branch.name() else { continue };
if parse_branch(branch_name).is_none() {
continue; }
if claimed.contains(branch_name) {
continue; }
let Some(branch_oid) = branch.get().target() else {
continue;
};
match is_merged_into_any(ctx.repo, branch_oid, &trunk_oids) {
Ok(true) => {
merged_count += 1;
continue; }
Ok(false) => {
}
Err(e) => {
return Check::failed(
name,
format!("could not determine merge status for {}: {}", branch_name, e),
)
.with_hint("check the repository integrity (`git fsck`) or re-fetch missing objects");
}
}
orphans.push(branch_name.to_string());
}
if orphans.is_empty() {
let detail = if merged_count == 0 {
"every gwm-style branch has a matching worktree".to_string()
} else {
format!(
"{} merged gwm-style branch(es) preserved per CONTRIBUTING, no unmerged orphans",
merged_count
)
};
return Check::ok(name, detail);
}
let suggestions: Vec<String> = orphans.iter().map(|b| format!("git branch -d {}", b)).collect();
Check::warning(
name,
format!("{} unmerged orphan branch(es): {}", orphans.len(), orphans.join(", ")),
)
.with_hint(suggestions.join(" && "))
}
fn is_merged_into_any(
repo: &git2::Repository,
branch_oid: git2::Oid,
trunks: &[git2::Oid],
) -> std::result::Result<bool, git2::Error> {
for trunk_oid in trunks {
if *trunk_oid == branch_oid {
return Ok(true);
}
if repo.graph_descendant_of(*trunk_oid, branch_oid)? {
return Ok(true);
}
}
Ok(false)
}
fn is_writable_dir(dir: &Path) -> bool {
tempfile::Builder::new()
.prefix(".gwm-doctor-probe-")
.rand_bytes(8)
.tempfile_in(dir)
.is_ok()
}