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;
11
12use super::display::get_worktree_status;
13
14struct WtInfo {
16 branch: String,
17 path: std::path::PathBuf,
18 status: String,
19}
20
21pub fn doctor() -> Result<()> {
23 let repo = git::get_repo_root(None)?;
24 println!(
25 "\n{}\n",
26 style("git-worktree-manager Health Check").cyan().bold()
27 );
28
29 let mut issues = 0u32;
30 let mut warnings = 0u32;
31
32 check_git_version(&mut issues);
34
35 let (worktrees, stale_count) = check_worktree_accessibility(&repo, &mut issues)?;
37
38 check_uncommitted_changes(&worktrees, &mut warnings);
40
41 let behind = check_behind_base(&worktrees, &repo, &mut warnings);
43
44 let conflicted = check_merge_conflicts(&worktrees, &mut issues);
46
47 print_summary(issues, warnings);
49
50 print_recommendations(stale_count, &behind, &conflicted);
52
53 Ok(())
54}
55
56fn check_git_version(issues: &mut u32) {
58 println!("{}", style("1. Checking Git version...").bold());
59 match Command::new("git").arg("--version").output() {
60 Ok(output) if output.status.success() => {
61 let version_output = String::from_utf8_lossy(&output.stdout);
62 let version_str = version_output
63 .split_whitespace()
64 .nth(2)
65 .unwrap_or("unknown");
66
67 let is_ok =
68 version_meets_minimum(version_str, MIN_GIT_VERSION_MAJOR, MIN_GIT_VERSION_MINOR);
69
70 if is_ok {
71 println!(
72 " {} Git version {} (minimum: {})",
73 style("*").green(),
74 version_str,
75 MIN_GIT_VERSION,
76 );
77 } else {
78 println!(
79 " {} Git version {} is too old (minimum: {})",
80 style("x").red(),
81 version_str,
82 MIN_GIT_VERSION,
83 );
84 *issues += 1;
85 }
86 }
87 _ => {
88 println!(" {} Could not detect Git version", style("x").red());
89 *issues += 1;
90 }
91 }
92 println!();
93}
94
95fn check_worktree_accessibility(
97 repo: &std::path::Path,
98 issues: &mut u32,
99) -> Result<(Vec<WtInfo>, u32)> {
100 println!("{}", style("2. Checking worktree accessibility...").bold());
101 let feature_worktrees = git::get_feature_worktrees(Some(repo))?;
102 let mut stale_count = 0u32;
103 let mut worktrees: Vec<WtInfo> = Vec::new();
104
105 for (branch_name, path) in &feature_worktrees {
106 let status = get_worktree_status(path, repo);
107 if status == "stale" {
108 stale_count += 1;
109 println!(
110 " {} {}: Stale (directory missing)",
111 style("x").red(),
112 branch_name
113 );
114 *issues += 1;
115 }
116 worktrees.push(WtInfo {
117 branch: branch_name.clone(),
118 path: path.clone(),
119 status,
120 });
121 }
122
123 if stale_count == 0 {
124 println!(
125 " {} All {} worktrees are accessible",
126 style("*").green(),
127 worktrees.len()
128 );
129 }
130 println!();
131
132 Ok((worktrees, stale_count))
133}
134
135fn check_uncommitted_changes(worktrees: &[WtInfo], warnings: &mut u32) {
137 println!("{}", style("3. Checking for uncommitted changes...").bold());
138 let mut dirty: Vec<String> = Vec::new();
139 for wt in worktrees {
140 if wt.status == "modified" || wt.status == "active" {
141 if let Ok(r) = git::git_command(&["status", "--porcelain"], Some(&wt.path), false, true)
142 {
143 if r.returncode == 0 && !r.stdout.trim().is_empty() {
144 dirty.push(wt.branch.clone());
145 }
146 }
147 }
148 }
149
150 if dirty.is_empty() {
151 println!(" {} No uncommitted changes", style("*").green());
152 } else {
153 println!(
154 " {} {} worktree(s) with uncommitted changes:",
155 style("!").yellow(),
156 dirty.len()
157 );
158 for b in &dirty {
159 println!(" - {}", b);
160 }
161 *warnings += 1;
162 }
163 println!();
164}
165
166fn check_behind_base(
168 worktrees: &[WtInfo],
169 repo: &std::path::Path,
170 warnings: &mut u32,
171) -> Vec<(String, String, String)> {
172 println!(
173 "{}",
174 style("4. Checking if worktrees are behind base branch...").bold()
175 );
176 let mut behind: Vec<(String, String, String)> = Vec::new();
177
178 for wt in worktrees {
179 if wt.status == "stale" {
180 continue;
181 }
182 let key = format_config_key(CONFIG_KEY_BASE_BRANCH, &wt.branch);
183 let base = match git::get_config(&key, Some(repo)) {
184 Some(b) => b,
185 None => continue,
186 };
187
188 let origin_base = format!("origin/{}", base);
189 if let Ok(r) = git::git_command(
190 &[
191 "rev-list",
192 "--count",
193 &format!("{}..{}", wt.branch, origin_base),
194 ],
195 Some(&wt.path),
196 false,
197 true,
198 ) {
199 if r.returncode == 0 {
200 let count = r.stdout.trim();
201 if count != "0" {
202 behind.push((wt.branch.clone(), base.clone(), count.to_string()));
203 }
204 }
205 }
206 }
207
208 if behind.is_empty() {
209 println!(
210 " {} All worktrees are up-to-date with base",
211 style("*").green()
212 );
213 } else {
214 println!(
215 " {} {} worktree(s) behind base branch:",
216 style("!").yellow(),
217 behind.len()
218 );
219 for (b, base, count) in &behind {
220 println!(" - {}: {} commit(s) behind {}", b, count, base);
221 }
222 println!(
223 " {}",
224 style("Tip: Use 'gw sync --all' to update all worktrees").dim()
225 );
226 *warnings += 1;
227 }
228 println!();
229
230 behind
231}
232
233fn check_merge_conflicts(worktrees: &[WtInfo], issues: &mut u32) -> Vec<(String, usize)> {
235 println!("{}", style("5. Checking for merge conflicts...").bold());
236 let mut conflicted: Vec<(String, usize)> = Vec::new();
237
238 for wt in worktrees {
239 if wt.status == "stale" {
240 continue;
241 }
242 if let Ok(r) = git::git_command(
243 &["diff", "--name-only", "--diff-filter=U"],
244 Some(&wt.path),
245 false,
246 true,
247 ) {
248 if r.returncode == 0 && !r.stdout.trim().is_empty() {
249 let count = r.stdout.trim().lines().count();
250 conflicted.push((wt.branch.clone(), count));
251 }
252 }
253 }
254
255 if conflicted.is_empty() {
256 println!(" {} No merge conflicts detected", style("*").green());
257 } else {
258 println!(
259 " {} {} worktree(s) with merge conflicts:",
260 style("x").red(),
261 conflicted.len()
262 );
263 for (b, count) in &conflicted {
264 println!(" - {}: {} conflicted file(s)", b, count);
265 }
266 *issues += 1;
267 }
268 println!();
269
270 conflicted
271}
272
273fn print_summary(issues: u32, warnings: u32) {
275 println!("{}", style("Summary:").cyan().bold());
276 if issues == 0 && warnings == 0 {
277 println!("{}\n", style("* Everything looks healthy!").green().bold());
278 } else {
279 if issues > 0 {
280 println!(
281 "{}",
282 style(format!("x {} issue(s) found", issues)).red().bold()
283 );
284 }
285 if warnings > 0 {
286 println!(
287 "{}",
288 style(format!("! {} warning(s) found", warnings))
289 .yellow()
290 .bold()
291 );
292 }
293 println!();
294 }
295}
296
297fn print_recommendations(
299 stale_count: u32,
300 behind: &[(String, String, String)],
301 conflicted: &[(String, usize)],
302) {
303 let has_recommendations = stale_count > 0 || !behind.is_empty() || !conflicted.is_empty();
304 if has_recommendations {
305 println!("{}", style("Recommendations:").bold());
306 if stale_count > 0 {
307 println!(
308 " - Run {} to clean up stale worktrees",
309 style("gw prune").cyan()
310 );
311 }
312 if !behind.is_empty() {
313 println!(
314 " - Run {} to update all worktrees",
315 style("gw sync --all").cyan()
316 );
317 }
318 if !conflicted.is_empty() {
319 println!(" - Resolve conflicts in conflicted worktrees");
320 }
321 println!();
322 }
323}