Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli/commands/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool)
let mut records: Vec<HealthRecord> = 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: <path>`.
// All per-worktree state files live under that resolved path.
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1176,15 +1182,16 @@ 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
|| r.stale_days
.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()
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/output/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/output/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading