git_worktree_manager/operations/
diagnostics.rs1use 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
17struct WtInfo {
19 branch: String,
20 path: std::path::PathBuf,
21 status: String,
22}
23
24pub 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 check_git_version(&mut issues);
40
41 let (worktrees, stale_count) = check_worktree_accessibility(&repo, &mut issues)?;
43
44 check_uncommitted_changes(&worktrees, &mut warnings);
46
47 let behind = check_behind_base(&worktrees, &repo, &mut warnings);
49
50 let conflicted = check_merge_conflicts(&worktrees, &mut issues);
52
53 check_claude_integration();
55
56 print_summary(issues, warnings);
58
59 print_recommendations(stale_count, &behind, &conflicted);
61
62 Ok(())
63}
64
65fn 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 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
115fn 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
154fn 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 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
197fn 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
228fn 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
295fn 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
335fn 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 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
375fn 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
399fn 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}