Skip to main content

git_worktree_manager/operations/
diagnostics.rs

1use std::process::Command;
2
3use console::style;
4
5use crate::constants::{
6    format_config_key, version_meets_minimum, CONFIG_KEY_BASE_BRANCH, MIN_GIT_VERSION,
7    MIN_GIT_VERSION_MAJOR, MIN_GIT_VERSION_MINOR,
8};
9use crate::error::Result;
10use crate::git;
11
12use super::display::get_worktree_status;
13use super::pr_cache::PrCache;
14use super::setup_claude;
15
16/// Worktree info collected during health check.
17struct WtInfo {
18    branch: String,
19    path: std::path::PathBuf,
20    status: String,
21}
22
23/// Perform health check on all worktrees.
24pub fn doctor(session_start: bool, quiet: bool) -> Result<()> {
25    if session_start {
26        return doctor_session_start(quiet);
27    }
28    let repo = git::get_repo_root(None)?;
29    println!(
30        "\n{}\n",
31        style("git-worktree-manager Health Check").cyan().bold()
32    );
33
34    let mut issues = 0u32;
35    let mut warnings = 0u32;
36
37    check_git_version(&mut issues);
38    let (worktrees, stale_count) = check_worktree_accessibility(&repo, &mut issues)?;
39    check_uncommitted_changes(&worktrees, &mut warnings);
40    check_busy_worktrees(&worktrees);
41    check_claude_integration();
42
43    print_summary(issues, warnings);
44    print_recommendations(stale_count);
45
46    Ok(())
47}
48
49/// Hook-friendly single-line health summary. Always returns Ok(()) so a
50/// SessionStart hook never blocks the Claude Code session.
51fn doctor_session_start(quiet: bool) -> Result<()> {
52    let cwd = std::env::current_dir().ok();
53    let cwd_ok = cwd.as_ref().map(|p| p.exists()).unwrap_or(false);
54    let cwd_str = cwd
55        .as_ref()
56        .map(|p| p.display().to_string())
57        .unwrap_or_else(|| "?".into());
58
59    // Branch + base are best-effort: failures here must not abort the
60    // line. Each failed lookup contributes "?" to the output.
61    let repo_root = git::get_repo_root(None).ok();
62    let branch = repo_root
63        .as_deref()
64        .and_then(|root| git::get_current_branch(Some(root)).ok())
65        .unwrap_or_else(|| "?".into());
66    let base = if branch != "?" {
67        repo_root
68            .as_deref()
69            .and_then(|root| {
70                let key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
71                git::get_config(&key, Some(root))
72            })
73            .unwrap_or_else(|| "?".into())
74    } else {
75        "?".into()
76    };
77    let prefix = if quiet { "gw:" } else { "gw doctor:" };
78    println!(
79        "{} cwd={} ok={} branch={} base={}",
80        prefix, cwd_str, cwd_ok, branch, base,
81    );
82    Ok(())
83}
84
85/// Check Git version meets minimum requirement.
86fn check_git_version(issues: &mut u32) {
87    println!("{}", style("1. Checking Git version...").bold());
88    match Command::new("git").arg("--version").output() {
89        Ok(output) if output.status.success() => {
90            let version_output = String::from_utf8_lossy(&output.stdout);
91            let version_str = version_output
92                .split_whitespace()
93                .nth(2)
94                .unwrap_or("unknown");
95
96            let is_ok =
97                version_meets_minimum(version_str, MIN_GIT_VERSION_MAJOR, MIN_GIT_VERSION_MINOR);
98
99            if is_ok {
100                println!(
101                    "   {} Git version {} (minimum: {})",
102                    style("*").green(),
103                    version_str,
104                    MIN_GIT_VERSION,
105                );
106            } else {
107                println!(
108                    "   {} Git version {} is too old (minimum: {})",
109                    style("x").red(),
110                    version_str,
111                    MIN_GIT_VERSION,
112                );
113                *issues += 1;
114            }
115        }
116        _ => {
117            println!("   {} Could not detect Git version", style("x").red());
118            *issues += 1;
119        }
120    }
121    println!();
122}
123
124/// Check that all worktrees are accessible (not stale).
125fn check_worktree_accessibility(
126    repo: &std::path::Path,
127    issues: &mut u32,
128) -> Result<(Vec<WtInfo>, u32)> {
129    println!("{}", style("2. Checking worktree accessibility...").bold());
130    let feature_worktrees = git::get_feature_worktrees(Some(repo))?;
131    let mut stale_count = 0u32;
132    let mut worktrees: Vec<WtInfo> = Vec::new();
133
134    // Use a default (empty) cache — stale detection depends only on path
135    // existence, not on PR state, so there is no need for a gh call here.
136    let pr_cache = PrCache::default();
137
138    for (branch_name, path) in &feature_worktrees {
139        let status = get_worktree_status(path, repo, Some(branch_name.as_str()), &pr_cache);
140        if status == "stale" {
141            stale_count += 1;
142            println!(
143                "   {} {}: Stale (directory missing)",
144                style("x").red(),
145                branch_name
146            );
147            *issues += 1;
148        }
149        worktrees.push(WtInfo {
150            branch: branch_name.clone(),
151            path: path.clone(),
152            status,
153        });
154    }
155
156    if stale_count == 0 {
157        println!(
158            "   {} All {} worktrees are accessible",
159            style("*").green(),
160            worktrees.len()
161        );
162    }
163    println!();
164
165    Ok((worktrees, stale_count))
166}
167
168/// Check for uncommitted changes in worktrees.
169fn check_uncommitted_changes(worktrees: &[WtInfo], warnings: &mut u32) {
170    println!("{}", style("3. Checking for uncommitted changes...").bold());
171    let mut dirty: Vec<String> = Vec::new();
172    for wt in worktrees {
173        if wt.status == "modified" || wt.status == "active" {
174            if let Ok(r) = git::git_command(&["status", "--porcelain"], Some(&wt.path), false, true)
175            {
176                if r.returncode == 0 && !r.stdout.trim().is_empty() {
177                    dirty.push(wt.branch.clone());
178                }
179            }
180        }
181    }
182
183    if dirty.is_empty() {
184        println!("   {} No uncommitted changes", style("*").green());
185    } else {
186        println!(
187            "   {} {} worktree(s) with uncommitted changes:",
188            style("!").yellow(),
189            dirty.len()
190        );
191        for b in &dirty {
192            println!("      - {}", b);
193        }
194        *warnings += 1;
195    }
196    println!();
197}
198
199/// Check for worktrees with active sessions in other shells.
200/// This is informational — it tells the user where parallel work is
201/// happening, not whether something is wrong.
202fn check_busy_worktrees(worktrees: &[WtInfo]) {
203    println!("{}", style("4. Checking busy worktrees...").bold());
204    let busy: Vec<&WtInfo> = worktrees
205        .iter()
206        .filter(|w| !crate::operations::busy::detect_busy_lockfile_only(&w.path).is_empty())
207        .collect();
208    if busy.is_empty() {
209        println!("   {} No busy worktrees", style("*").green());
210    } else {
211        println!(
212            "   {} {} busy worktree(s) (active in another session):",
213            style("i").cyan(),
214            busy.len()
215        );
216        for wt in &busy {
217            println!("      - {}", wt.branch);
218        }
219    }
220    println!();
221}
222
223/// Check Claude Code installation and skill integration.
224fn check_claude_integration() {
225    println!("{}", style("5. Checking Claude Code integration...").bold());
226
227    // Use git::has_command for cross-platform PATH search instead of shelling
228    // out to `which` (Unix-only).
229    let has_claude = git::has_command("claude");
230
231    let msgs = setup_claude_doctor_messages();
232
233    if !has_claude {
234        println!(
235            "   {} Claude Code not detected (optional)",
236            style("-").dim()
237        );
238    } else if setup_claude::is_installed() {
239        println!("   {} {}", style("*").green(), msgs.installed);
240    } else {
241        println!("   {} {}", style("!").yellow(), msgs.missing_alert);
242        println!("   {}", style(format!("Tip: {}", msgs.missing_tip)).dim());
243    }
244    println!();
245}
246
247/// Print health check summary.
248fn print_summary(issues: u32, warnings: u32) {
249    println!("{}", style("Summary:").cyan().bold());
250    if issues == 0 && warnings == 0 {
251        println!("{}\n", style("* Everything looks healthy!").green().bold());
252    } else {
253        if issues > 0 {
254            println!(
255                "{}",
256                style(format!("x {} issue(s) found", issues)).red().bold()
257            );
258        }
259        if warnings > 0 {
260            println!(
261                "{}",
262                style(format!("! {} warning(s) found", warnings))
263                    .yellow()
264                    .bold()
265            );
266        }
267        println!();
268    }
269}
270
271/// Print remediation recommendations.
272fn print_recommendations(stale_count: u32) {
273    if stale_count == 0 {
274        return;
275    }
276    println!("{}", style("Recommendations:").bold());
277    println!(
278        "  - Run {} to clean up stale worktrees",
279        style("gw rm").cyan()
280    );
281    println!();
282}
283
284/// Source-of-truth strings for the Claude Code section of `gw doctor`.
285pub struct SetupClaudeDoctorMessages {
286    pub installed: &'static str,
287    pub missing_alert: &'static str,
288    pub missing_tip: &'static str,
289}
290
291/// Returns the strings printed by [`check_claude_integration`].
292pub fn setup_claude_doctor_messages() -> SetupClaudeDoctorMessages {
293    SetupClaudeDoctorMessages {
294        installed: "gw skills installed",
295        missing_alert: "Claude Code detected but gw skills not installed",
296        missing_tip: "Run 'gw setup-claude' to install gw skills and hooks into this repo",
297    }
298}