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;
11use crate::registry;
12
13use super::display::get_worktree_status;
14use super::pr_cache::PrCache;
15use super::setup_claude;
16
17/// Worktree info collected during health check.
18struct WtInfo {
19    branch: String,
20    path: std::path::PathBuf,
21    status: String,
22}
23
24/// Perform health check on all worktrees.
25pub fn doctor(session_start: bool, quiet: bool) -> Result<()> {
26    if session_start {
27        return doctor_session_start(quiet);
28    }
29    let repo = git::get_repo_root(None)?;
30    println!(
31        "\n{}\n",
32        style("git-worktree-manager Health Check").cyan().bold()
33    );
34
35    let mut issues = 0u32;
36    let mut warnings = 0u32;
37
38    // 1. Check Git version
39    check_git_version(&mut issues);
40
41    // 2. Check worktree accessibility
42    let (worktrees, stale_count) = check_worktree_accessibility(&repo, &mut issues)?;
43
44    // 3. Check for uncommitted changes
45    check_uncommitted_changes(&worktrees, &mut warnings);
46
47    // 4. Check if worktrees are behind base branch
48    let behind = check_behind_base(&worktrees, &repo, &mut warnings);
49
50    // 5. Check for merge conflicts
51    let conflicted = check_merge_conflicts(&worktrees, &mut issues);
52
53    // 6. Check Claude Code integration
54    check_claude_integration();
55
56    // Summary
57    print_summary(issues, warnings);
58
59    // Recommendations
60    print_recommendations(stale_count, &behind, &conflicted);
61
62    Ok(())
63}
64
65/// Hook-friendly single-line health summary. Always returns Ok(()) so a
66/// SessionStart hook never blocks the Claude Code session.
67fn doctor_session_start(quiet: bool) -> Result<()> {
68    let cwd = std::env::current_dir().ok();
69    let cwd_ok = cwd.as_ref().map(|p| p.exists()).unwrap_or(false);
70    let cwd_str = cwd
71        .as_ref()
72        .map(|p| p.display().to_string())
73        .unwrap_or_else(|| "?".into());
74
75    // Branch + base + registration are best-effort: failures here must not
76    // abort the line. Each failed lookup contributes "?" to the output.
77    let repo_root = git::get_repo_root(None).ok();
78    let branch = repo_root
79        .as_deref()
80        .and_then(|root| git::get_current_branch(Some(root)).ok())
81        .unwrap_or_else(|| "?".into());
82    let base = if branch != "?" {
83        repo_root
84            .as_deref()
85            .and_then(|root| {
86                let key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
87                git::get_config(&key, Some(root))
88            })
89            .unwrap_or_else(|| "?".into())
90    } else {
91        "?".into()
92    };
93    let registered = {
94        let registry = registry::load_registry();
95        cwd.as_ref()
96            .map(|p| {
97                let key = p
98                    .canonicalize()
99                    .unwrap_or_else(|_| p.clone())
100                    .to_string_lossy()
101                    .to_string();
102                registry.repositories.contains_key(&key)
103            })
104            .unwrap_or(false)
105    };
106
107    let prefix = if quiet { "gw:" } else { "gw doctor:" };
108    println!(
109        "{} cwd={} ok={} branch={} base={} registered={}",
110        prefix, cwd_str, cwd_ok, branch, base, registered,
111    );
112    Ok(())
113}
114
115/// Check Git version meets minimum requirement.
116fn check_git_version(issues: &mut u32) {
117    println!("{}", style("1. Checking Git version...").bold());
118    match Command::new("git").arg("--version").output() {
119        Ok(output) if output.status.success() => {
120            let version_output = String::from_utf8_lossy(&output.stdout);
121            let version_str = version_output
122                .split_whitespace()
123                .nth(2)
124                .unwrap_or("unknown");
125
126            let is_ok =
127                version_meets_minimum(version_str, MIN_GIT_VERSION_MAJOR, MIN_GIT_VERSION_MINOR);
128
129            if is_ok {
130                println!(
131                    "   {} Git version {} (minimum: {})",
132                    style("*").green(),
133                    version_str,
134                    MIN_GIT_VERSION,
135                );
136            } else {
137                println!(
138                    "   {} Git version {} is too old (minimum: {})",
139                    style("x").red(),
140                    version_str,
141                    MIN_GIT_VERSION,
142                );
143                *issues += 1;
144            }
145        }
146        _ => {
147            println!("   {} Could not detect Git version", style("x").red());
148            *issues += 1;
149        }
150    }
151    println!();
152}
153
154/// Check that all worktrees are accessible (not stale).
155fn check_worktree_accessibility(
156    repo: &std::path::Path,
157    issues: &mut u32,
158) -> Result<(Vec<WtInfo>, u32)> {
159    println!("{}", style("2. Checking worktree accessibility...").bold());
160    let feature_worktrees = git::get_feature_worktrees(Some(repo))?;
161    let mut stale_count = 0u32;
162    let mut worktrees: Vec<WtInfo> = Vec::new();
163
164    // doctor needs fresh state; bypass the 60s TTL.
165    let pr_cache = PrCache::load_or_fetch(repo, true);
166
167    for (branch_name, path) in &feature_worktrees {
168        let status = get_worktree_status(path, repo, Some(branch_name.as_str()), &pr_cache);
169        if status == "stale" {
170            stale_count += 1;
171            println!(
172                "   {} {}: Stale (directory missing)",
173                style("x").red(),
174                branch_name
175            );
176            *issues += 1;
177        }
178        worktrees.push(WtInfo {
179            branch: branch_name.clone(),
180            path: path.clone(),
181            status,
182        });
183    }
184
185    if stale_count == 0 {
186        println!(
187            "   {} All {} worktrees are accessible",
188            style("*").green(),
189            worktrees.len()
190        );
191    }
192    println!();
193
194    Ok((worktrees, stale_count))
195}
196
197/// Check for uncommitted changes in worktrees.
198fn check_uncommitted_changes(worktrees: &[WtInfo], warnings: &mut u32) {
199    println!("{}", style("3. Checking for uncommitted changes...").bold());
200    let mut dirty: Vec<String> = Vec::new();
201    for wt in worktrees {
202        if wt.status == "modified" || wt.status == "active" {
203            if let Ok(r) = git::git_command(&["status", "--porcelain"], Some(&wt.path), false, true)
204            {
205                if r.returncode == 0 && !r.stdout.trim().is_empty() {
206                    dirty.push(wt.branch.clone());
207                }
208            }
209        }
210    }
211
212    if dirty.is_empty() {
213        println!("   {} No uncommitted changes", style("*").green());
214    } else {
215        println!(
216            "   {} {} worktree(s) with uncommitted changes:",
217            style("!").yellow(),
218            dirty.len()
219        );
220        for b in &dirty {
221            println!("      - {}", b);
222        }
223        *warnings += 1;
224    }
225    println!();
226}
227
228/// Check if worktrees are behind their base branch.
229fn check_behind_base(
230    worktrees: &[WtInfo],
231    repo: &std::path::Path,
232    warnings: &mut u32,
233) -> Vec<(String, String, String)> {
234    println!(
235        "{}",
236        style("4. Checking if worktrees are behind base branch...").bold()
237    );
238    let mut behind: Vec<(String, String, String)> = Vec::new();
239
240    for wt in worktrees {
241        if wt.status == "stale" {
242            continue;
243        }
244        let key = format_config_key(CONFIG_KEY_BASE_BRANCH, &wt.branch);
245        let base = match git::get_config(&key, Some(repo)) {
246            Some(b) => b,
247            None => continue,
248        };
249
250        let origin_base = format!("origin/{}", base);
251        if let Ok(r) = git::git_command(
252            &[
253                "rev-list",
254                "--count",
255                &format!("{}..{}", wt.branch, origin_base),
256            ],
257            Some(&wt.path),
258            false,
259            true,
260        ) {
261            if r.returncode == 0 {
262                let count = r.stdout.trim();
263                if count != "0" {
264                    behind.push((wt.branch.clone(), base.clone(), count.to_string()));
265                }
266            }
267        }
268    }
269
270    if behind.is_empty() {
271        println!(
272            "   {} All worktrees are up-to-date with base",
273            style("*").green()
274        );
275    } else {
276        println!(
277            "   {} {} worktree(s) behind base branch:",
278            style("!").yellow(),
279            behind.len()
280        );
281        for (b, base, count) in &behind {
282            println!("      - {}: {} commit(s) behind {}", b, count, base);
283        }
284        println!(
285            "   {}",
286            style("Tip: Use 'gw sync --all' to update all worktrees").dim()
287        );
288        *warnings += 1;
289    }
290    println!();
291
292    behind
293}
294
295/// Check for merge conflicts in worktrees.
296fn check_merge_conflicts(worktrees: &[WtInfo], issues: &mut u32) -> Vec<(String, usize)> {
297    println!("{}", style("5. Checking for merge conflicts...").bold());
298    let mut conflicted: Vec<(String, usize)> = Vec::new();
299
300    for wt in worktrees {
301        if wt.status == "stale" {
302            continue;
303        }
304        if let Ok(r) = git::git_command(
305            &["diff", "--name-only", "--diff-filter=U"],
306            Some(&wt.path),
307            false,
308            true,
309        ) {
310            if r.returncode == 0 && !r.stdout.trim().is_empty() {
311                let count = r.stdout.trim().lines().count();
312                conflicted.push((wt.branch.clone(), count));
313            }
314        }
315    }
316
317    if conflicted.is_empty() {
318        println!("   {} No merge conflicts detected", style("*").green());
319    } else {
320        println!(
321            "   {} {} worktree(s) with merge conflicts:",
322            style("x").red(),
323            conflicted.len()
324        );
325        for (b, count) in &conflicted {
326            println!("      - {}: {} conflicted file(s)", b, count);
327        }
328        *issues += 1;
329    }
330    println!();
331
332    conflicted
333}
334
335/// Check Claude Code installation and skill integration.
336fn check_claude_integration() {
337    println!("{}", style("6. Checking Claude Code integration...").bold());
338
339    let has_claude = Command::new("which")
340        .arg("claude")
341        .output()
342        .map(|o| o.status.success())
343        .unwrap_or(false);
344
345    if !has_claude {
346        println!(
347            "   {} Claude Code not detected (optional)",
348            style("-").dim()
349        );
350    } else if setup_claude::is_plugin_installed() {
351        println!("   {} gw plugin installed", style("*").green());
352    } else if setup_claude::is_skill_installed() {
353        // Legacy skill-only install. Suggest the upgrade.
354        println!(
355            "   {} Legacy gw skill installed (pre-plugin layout)",
356            style("!").yellow()
357        );
358        println!(
359            "   {}",
360            style("Tip: Re-run 'gw setup-claude' to upgrade from skill to plugin").dim()
361        );
362    } else {
363        println!(
364            "   {} Claude Code detected but gw plugin not installed",
365            style("!").yellow()
366        );
367        println!(
368            "   {}",
369            style("Tip: Run 'gw setup-claude' to install the gw plugin for Claude Code").dim()
370        );
371    }
372    println!();
373}
374
375/// Print health check summary.
376fn print_summary(issues: u32, warnings: u32) {
377    println!("{}", style("Summary:").cyan().bold());
378    if issues == 0 && warnings == 0 {
379        println!("{}\n", style("* Everything looks healthy!").green().bold());
380    } else {
381        if issues > 0 {
382            println!(
383                "{}",
384                style(format!("x {} issue(s) found", issues)).red().bold()
385            );
386        }
387        if warnings > 0 {
388            println!(
389                "{}",
390                style(format!("! {} warning(s) found", warnings))
391                    .yellow()
392                    .bold()
393            );
394        }
395        println!();
396    }
397}
398
399/// Print remediation recommendations.
400fn print_recommendations(
401    stale_count: u32,
402    behind: &[(String, String, String)],
403    conflicted: &[(String, usize)],
404) {
405    let has_recommendations = stale_count > 0 || !behind.is_empty() || !conflicted.is_empty();
406    if has_recommendations {
407        println!("{}", style("Recommendations:").bold());
408        if stale_count > 0 {
409            println!(
410                "  - Run {} to clean up stale worktrees",
411                style("gw prune").cyan()
412            );
413        }
414        if !behind.is_empty() {
415            println!(
416                "  - Run {} to update all worktrees",
417                style("gw sync --all").cyan()
418            );
419        }
420        if !conflicted.is_empty() {
421            println!("  - Resolve conflicts in conflicted worktrees");
422        }
423        println!();
424    }
425}