diff --git a/src/cli/commands/health.rs b/src/cli/commands/health.rs index d3efca3..99bac66 100644 --- a/src/cli/commands/health.rs +++ b/src/cli/commands/health.rs @@ -14,6 +14,12 @@ //! - Configurable stale-threshold via `--stale-days` CLI flag. //! - Opt-out via `--no-overlay` for fully offline mode. //! +//! Phase 3 additions: +//! - Detect in-progress git operations: rebase, merge, cherry-pick. +//! Checked via git-dir state files (`rebase-merge/`, `MERGE_HEAD`, etc.). +//! Works for both main and linked worktrees (resolves `gitdir:` pointer). +//! Failures are soft: detection errors return `false` rather than aborting. +//! //! All checks are read-only; no worktree state is modified. use std::path::Path; @@ -70,21 +76,19 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) let mut records: Vec = Vec::new(); for ws in &workspaces { + // --- resolve effective git directory --------------------------- + // For linked worktrees, `.git` is a text file: `gitdir: `. + // All per-worktree state files live under that resolved path. + let effective_git_dir = resolve_git_dir(&ws.path); + // --- lock file ------------------------------------------------- - let git_dir = ws.path.join(".git"); - let lock_path = if git_dir.is_file() { - std::fs::read_to_string(&git_dir) - .ok() - .and_then(|s| { - s.strip_prefix("gitdir: ") - .map(|p| std::path::PathBuf::from(p.trim())) - }) - .unwrap_or_else(|| git_dir.clone()) - .join("index.lock") - } else { - git_dir.join("index.lock") - }; - let has_lock = lock_path.exists(); + let has_lock = effective_git_dir.join("index.lock").exists(); + + // --- in-progress git operations (Phase 3) ---------------------- + let rebase_in_progress = effective_git_dir.join("rebase-merge").is_dir() + || effective_git_dir.join("rebase-apply").is_dir(); + let merge_in_progress = effective_git_dir.join("MERGE_HEAD").exists(); + let cherry_pick_in_progress = effective_git_dir.join("CHERRY_PICK_HEAD").exists(); // --- uncommitted ----------------------------------------------- let uncommitted = git::get_uncommitted_files(&ws.path) @@ -111,6 +115,9 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) has_lock, ci_status, pr_number, + rebase_in_progress, + merge_in_progress, + cherry_pick_in_progress, }); } @@ -118,6 +125,26 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) Ok(()) } +/// Resolve the effective git directory for a worktree path. +/// +/// For a linked worktree, `.git` is a text file containing +/// `gitdir: `. For the main worktree, `.git` is a directory. +/// Returns the resolved path, or `/.git` as a fallback. +fn resolve_git_dir(ws_path: &Path) -> std::path::PathBuf { + let dot_git = ws_path.join(".git"); + if dot_git.is_file() { + std::fs::read_to_string(&dot_git) + .ok() + .and_then(|s| { + s.strip_prefix("gitdir: ") + .map(|p| std::path::PathBuf::from(p.trim())) + }) + .unwrap_or(dot_git) + } else { + dot_git + } +} + /// Resolve CI status for a worktree branch via the GitHub client. /// /// Returns `(ci_status, pr_number)`. Both are `None` when: @@ -152,3 +179,110 @@ async fn fetch_ci_overlay( } } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + /// Create a fake main-worktree layout: `/.git/` is a directory. + fn make_main_git_dir(root: &TempDir) -> std::path::PathBuf { + let git_dir = root.path().join(".git"); + fs::create_dir_all(&git_dir).unwrap(); + git_dir + } + + /// Create a fake linked-worktree layout: + /// `/.git` is a file pointing to ``. + fn make_linked_git_dir(root: &TempDir, target: &std::path::Path) { + let dot_git = root.path().join(".git"); + fs::write(&dot_git, format!("gitdir: {}\n", target.display())).unwrap(); + } + + #[test] + fn resolve_git_dir_main_worktree() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + let resolved = resolve_git_dir(tmp.path()); + assert_eq!(resolved, git_dir, "main worktree: resolved path = .git dir"); + } + + #[test] + fn resolve_git_dir_linked_worktree() { + let main_tmp = TempDir::new().unwrap(); + let linked_tmp = TempDir::new().unwrap(); + let target = main_tmp.path().join(".git").join("worktrees").join("feat"); + fs::create_dir_all(&target).unwrap(); + make_linked_git_dir(&linked_tmp, &target); + + let resolved = resolve_git_dir(linked_tmp.path()); + assert_eq!( + resolved, target, + "linked worktree: resolved path = gitdir target" + ); + } + + #[test] + fn detects_rebase_merge_dir() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + fs::create_dir_all(git_dir.join("rebase-merge")).unwrap(); + + let effective = resolve_git_dir(tmp.path()); + assert!(effective.join("rebase-merge").is_dir()); + assert!(effective.join("rebase-merge").is_dir() || effective.join("rebase-apply").is_dir()); + } + + #[test] + fn detects_rebase_apply_dir() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + fs::create_dir_all(git_dir.join("rebase-apply")).unwrap(); + + let effective = resolve_git_dir(tmp.path()); + let rebase_in_progress = + effective.join("rebase-merge").is_dir() || effective.join("rebase-apply").is_dir(); + assert!( + rebase_in_progress, + "rebase-apply dir should trigger rebase_in_progress" + ); + } + + #[test] + fn detects_merge_head() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + fs::write(git_dir.join("MERGE_HEAD"), "abc123\n").unwrap(); + + let effective = resolve_git_dir(tmp.path()); + assert!(effective.join("MERGE_HEAD").exists()); + } + + #[test] + fn detects_cherry_pick_head() { + let tmp = TempDir::new().unwrap(); + let git_dir = make_main_git_dir(&tmp); + fs::write(git_dir.join("CHERRY_PICK_HEAD"), "abc123\n").unwrap(); + + let effective = resolve_git_dir(tmp.path()); + assert!(effective.join("CHERRY_PICK_HEAD").exists()); + } + + #[test] + fn no_false_positives_when_clean() { + let tmp = TempDir::new().unwrap(); + make_main_git_dir(&tmp); + + let effective = resolve_git_dir(tmp.path()); + assert!(!effective.join("rebase-merge").is_dir()); + assert!(!effective.join("rebase-apply").is_dir()); + assert!(!effective.join("MERGE_HEAD").exists()); + assert!(!effective.join("CHERRY_PICK_HEAD").exists()); + assert!(!effective.join("index.lock").exists()); + } +} diff --git a/src/cli/commands/workspace.rs b/src/cli/commands/workspace.rs index 8aef3ed..bddd258 100644 --- a/src/cli/commands/workspace.rs +++ b/src/cli/commands/workspace.rs @@ -315,7 +315,7 @@ pub async fn list(repo: &Path, no_pr: bool, full: bool, mode: Mode) -> Result<() // Fetch live PR status from GitHub if let Some(ref remote_url) = remote_url { if let Ok(Some(gh)) = github::GitHubClient::new(remote_url, &config) { - for (_ticket, (pr_num, state)) in pr_map.iter_mut() { + for (pr_num, state) in pr_map.values_mut() { if let Ok(status) = gh.get_pr_status(*pr_num).await { *state = status.state; } diff --git a/src/output/human.rs b/src/output/human.rs index b94e225..7eaaf65 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1156,14 +1156,35 @@ pub fn print_health(records: &[super::HealthRecord]) { let ci_issue = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")); + // Phase 3: in-progress git operation tags + let rebase_tag = if r.rebase_in_progress { + " ⚠ rebase in progress!".red().to_string() + } else { + String::new() + }; + let merge_tag = if r.merge_in_progress { + " ⚠ merge in progress!".red().to_string() + } else { + String::new() + }; + let cherry_tag = if r.cherry_pick_in_progress { + " ⚠ cherry-pick in progress!".red().to_string() + } else { + String::new() + }; + + let op_in_progress = + r.rebase_in_progress || r.merge_in_progress || r.cherry_pick_in_progress; + let any_issue = 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 { + let icon = if r.has_lock || ci_issue || op_in_progress { "✗".red().to_string() } else if any_issue { "⚠".yellow().to_string() @@ -1176,13 +1197,16 @@ pub fn print_health(records: &[super::HealthRecord]) { } println!( - " {} {:<20}{}{}{}{}", + " {} {:<20}{}{}{}{}{}{}{}", icon, r.ticket.bold(), uncommitted_tag, stale_tag, lock_tag, ci_tag, + rebase_tag, + merge_tag, + cherry_tag, ); } println!(); diff --git a/src/output/json.rs b/src/output/json.rs index 616cf26..264274f 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -380,6 +380,9 @@ pub fn print_health(records: &[super::HealthRecord]) { "ci_status": r.ci_status, "pr_number": r.pr_number, "ci_failing": ci_failing, + "rebase_in_progress": r.rebase_in_progress, + "merge_in_progress": r.merge_in_progress, + "cherry_pick_in_progress": r.cherry_pick_in_progress, }) }) .collect(); @@ -391,6 +394,9 @@ pub fn print_health(records: &[super::HealthRecord]) { .map(|d| d > r.stale_threshold_days) .unwrap_or(false) && !matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")) + && !r.rebase_in_progress + && !r.merge_in_progress + && !r.cherry_pick_in_progress }); println!( "{}", diff --git a/src/output/mod.rs b/src/output/mod.rs index e17e3a7..4e2df8f 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -60,6 +60,15 @@ pub struct HealthRecord { pub ci_status: Option, /// GitHub PR number linked to this worktree's branch, if any. pub pr_number: Option, + /// Phase 3: a rebase is in progress in this worktree. + /// Detected by the presence of `.git/rebase-merge/` or `.git/rebase-apply/`. + pub rebase_in_progress: bool, + /// Phase 3: a merge is in progress in this worktree. + /// Detected by the presence of `.git/MERGE_HEAD`. + pub merge_in_progress: bool, + /// Phase 3: a cherry-pick is in progress in this worktree. + /// Detected by the presence of `.git/CHERRY_PICK_HEAD`. + pub cherry_pick_in_progress: bool, } /// Per-worktree test outcome produced by `parsec test` (issue #247).