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
162 changes: 148 additions & 14 deletions src/cli/commands/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,21 +76,19 @@ 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 {
// --- resolve effective git directory ---------------------------
// For linked worktrees, `.git` is a text file: `gitdir: <path>`.
// 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)
Expand All @@ -111,13 +115,36 @@ 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,
});
}

output::print_health(&records, mode);
Ok(())
}

/// Resolve the effective git directory for a worktree path.
///
/// For a linked worktree, `.git` is a text file containing
/// `gitdir: <absolute-path>`. For the main worktree, `.git` is a directory.
/// Returns the resolved path, or `<ws_path>/.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:
Expand Down Expand Up @@ -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: `<dir>/.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:
/// `<root>/.git` is a file pointing to `<target>`.
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());
}
}
2 changes: 1 addition & 1 deletion src/cli/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
28 changes: 26 additions & 2 deletions src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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!();
Expand Down
6 changes: 6 additions & 0 deletions src/output/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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!(
"{}",
Expand Down
9 changes: 9 additions & 0 deletions src/output/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ pub struct HealthRecord {
pub ci_status: Option<String>,
/// GitHub PR number linked to this worktree's branch, if any.
pub pr_number: Option<u64>,
/// 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).
Expand Down
Loading