From 89dfc2f768780b7668a8c4ae20bbb625f2bfa52c Mon Sep 17 00:00:00 2001 From: erishforG Date: Fri, 18 Sep 2026 09:35:46 +0900 Subject: [PATCH] fix(health): flag missing registered worktrees Co-Authored-By: OpenAI Codex --- src/cli/commands/health.rs | 3 +++ src/output/human.rs | 14 +++++++++++--- src/output/json.rs | 4 +++- src/output/mod.rs | 2 ++ tests/cli_tests.rs | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/health.rs b/src/cli/commands/health.rs index 99bac66..c3f1f27 100644 --- a/src/cli/commands/health.rs +++ b/src/cli/commands/health.rs @@ -76,6 +76,8 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) let mut records: Vec = Vec::new(); for ws in &workspaces { + let missing = !ws.path.is_dir(); + // --- resolve effective git directory --------------------------- // For linked worktrees, `.git` is a text file: `gitdir: `. // All per-worktree state files live under that resolved path. @@ -109,6 +111,7 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) records.push(HealthRecord { ticket: ws.ticket.clone(), + missing, uncommitted, stale_days: stale_days_val, stale_threshold_days: stale_threshold, diff --git a/src/output/human.rs b/src/output/human.rs index 7eaaf65..0b7f9b1 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1100,6 +1100,12 @@ pub fn print_health(records: &[super::HealthRecord]) { println!("{}", "parsec health".bold()); let mut issues = 0usize; for r in records { + let missing_tag = if r.missing { + " ⚠ worktree missing!".red().to_string() + } else { + String::new() + }; + let lock_tag = if r.has_lock { " ⚠ lock file!".red().to_string() } else { @@ -1176,7 +1182,8 @@ pub fn print_health(records: &[super::HealthRecord]) { let op_in_progress = r.rebase_in_progress || r.merge_in_progress || r.cherry_pick_in_progress; - let any_issue = r.has_lock + let any_issue = r.missing + || r.has_lock || r.uncommitted > 0 || ci_issue || op_in_progress @@ -1184,7 +1191,7 @@ pub fn print_health(records: &[super::HealthRecord]) { .map(|d| d > r.stale_threshold_days) .unwrap_or(false); - let icon = if r.has_lock || ci_issue || op_in_progress { + let icon = if r.missing || r.has_lock || ci_issue || op_in_progress { "✗".red().to_string() } else if any_issue { "⚠".yellow().to_string() @@ -1197,9 +1204,10 @@ pub fn print_health(records: &[super::HealthRecord]) { } println!( - " {} {:<20}{}{}{}{}{}{}{}", + " {} {:<20}{}{}{}{}{}{}{}{}", icon, r.ticket.bold(), + missing_tag, uncommitted_tag, stale_tag, lock_tag, diff --git a/src/output/json.rs b/src/output/json.rs index 264274f..bcc2b54 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -373,6 +373,7 @@ pub fn print_health(records: &[super::HealthRecord]) { let ci_failing = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")); json!({ "ticket": r.ticket, + "missing": r.missing, "has_lock": r.has_lock, "uncommitted": r.uncommitted, "stale_days": r.stale_days, @@ -387,7 +388,8 @@ pub fn print_health(records: &[super::HealthRecord]) { }) .collect(); let all_healthy = records.iter().all(|r| { - !r.has_lock + !r.missing + && !r.has_lock && r.uncommitted == 0 && !r .stale_days diff --git a/src/output/mod.rs b/src/output/mod.rs index 4e2df8f..155694e 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -47,6 +47,8 @@ pub struct DoctorCheck { pub struct HealthRecord { /// Ticket identifier for the worktree. pub ticket: String, + /// Whether the registered worktree directory is missing from disk. + pub missing: bool, /// Number of uncommitted files (staged + unstaged). pub uncommitted: usize, /// Days since the last commit, or `None` when the history is unreadable. diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 4fafbb7..400d843 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1909,6 +1909,38 @@ fn test_health_json_one_worktree() { ); } +/// A registry entry whose directory was removed externally must be reported +/// as missing and make the aggregate health result unhealthy. +#[test] +fn test_health_json_flags_missing_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "HL-MISSING", "--repo", repo_path]) + .assert() + .success(); + + let repo_name = repo.path().file_name().unwrap().to_string_lossy(); + let worktree_path = repo + .path() + .parent() + .unwrap() + .join(format!("{repo_name}.HL-MISSING")); + std::fs::remove_dir_all(&worktree_path).unwrap(); + + let output = parsec() + .args(["health", "--json", "--repo", repo_path]) + .output() + .unwrap(); + assert!(output.status.success()); + + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["all_healthy"], false); + assert_eq!(parsed["worktrees"][0]["ticket"], "HL-MISSING"); + assert_eq!(parsed["worktrees"][0]["missing"], true); +} + /// `parsec health` must exit 0 even when worktrees have issues — health is /// informational and must not be used as a CI gate in Phase 1. #[test]