mod common;
use std::path::PathBuf;
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::{Duration, Instant};
use assert_matches::assert_matches;
use iso_code::{Config, CreateOptions, DeleteOptions, GcOptions, Manager, WorktreeError};
use common::create_test_repo;
#[test]
fn qa_c_001_concurrent_create_same_branch() {
let repo = create_test_repo();
let repo_path: PathBuf = repo.path().to_path_buf();
let barrier = Arc::new(Barrier::new(10));
let mut handles = Vec::new();
for i in 0..10 {
let repo_path = repo_path.clone();
let barrier = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
let mgr = Manager::new(&repo_path, Config::default()).unwrap();
barrier.wait();
let wt_path = repo_path.join(format!("race-{i}"));
mgr.create("feature-x", &wt_path, CreateOptions::default())
.map(|(h, _)| h)
}));
}
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let successes = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(
successes, 1,
"exactly one thread should win the race; got {successes} successes"
);
for r in &results {
if let Err(e) = r {
assert_matches!(
e,
WorktreeError::BranchAlreadyCheckedOut { .. }
| WorktreeError::WorktreePathExists(_)
| WorktreeError::StateLockContention { .. }
| WorktreeError::GitCommandFailed { .. }
);
}
}
let list = common::git_output(repo.path(), &["worktree", "list", "--porcelain"]);
let feature_blocks = list.matches("branch refs/heads/feature-x").count();
assert_eq!(feature_blocks, 1, "git worktree list:\n{list}");
}
#[test]
fn qa_c_002_concurrent_remove_racing_gc() {
let repo = create_test_repo();
let mut cfg = Config::default();
cfg.offline = true;
let mgr = Manager::new(repo.path(), cfg.clone()).unwrap();
let wt_path = repo.path().join("race-wt");
let (handle, _) = mgr
.create("race-gc", &wt_path, CreateOptions::default())
.unwrap();
let repo_path: PathBuf = repo.path().to_path_buf();
let cfg2 = cfg.clone();
let barrier = Arc::new(Barrier::new(2));
let b1 = Arc::clone(&barrier);
let h_delete = thread::spawn(move || {
let mgr = Manager::new(&repo_path, cfg2).unwrap();
b1.wait();
let mut opts = DeleteOptions::default();
opts.force = true;
mgr.delete(&handle, opts)
});
let repo_path2: PathBuf = repo.path().to_path_buf();
let cfg3 = cfg.clone();
let b2 = Arc::clone(&barrier);
let h_gc = thread::spawn(move || {
let mgr = Manager::new(&repo_path2, cfg3).unwrap();
b2.wait();
let mut opts = GcOptions::default();
opts.dry_run = false;
opts.force = true;
mgr.gc(opts)
});
let delete_result = h_delete.join().unwrap();
let gc_result = h_gc.join().unwrap();
assert!(delete_result.is_ok() || gc_result.is_ok());
assert!(!wt_path.exists(), "worktree dir must be gone after race");
}
#[test]
fn qa_c_003_state_json_read_modify_write_contention() {
use iso_code::state;
use serde_json::Value;
let repo = create_test_repo();
let _ = Manager::new(repo.path(), Config::default()).unwrap();
let repo_path: PathBuf = repo.path().to_path_buf();
let barrier = Arc::new(Barrier::new(20));
let mut handles = Vec::new();
for _ in 0..20 {
let repo_path = repo_path.clone();
let barrier = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
barrier.wait();
state::with_state_timeout(&repo_path, None, 60_000, |s| {
let cur = s
.extra
.get("qa_c_003_counter")
.and_then(|v| v.as_u64())
.unwrap_or(0);
s.extra
.insert("qa_c_003_counter".to_string(), Value::from(cur + 1));
Ok(())
})
.map(|_| ())
}));
}
for h in handles {
h.join().unwrap().expect("state update must succeed");
}
let final_state = state::read_state(repo.path(), None).unwrap();
let counter = final_state
.extra
.get("qa_c_003_counter")
.and_then(|v| v.as_u64())
.unwrap_or(0);
assert_eq!(counter, 20, "no lost updates under contention");
}
#[test]
fn qa_c_004_circuit_breaker_trips_after_consecutive_failures() {
let repo = create_test_repo();
let _ = Manager::new(repo.path(), Config::default()).unwrap();
let mut cfg = Config::default();
cfg.circuit_breaker_threshold = 3;
let mgr = Manager::new(repo.path(), cfg).unwrap();
mgr.list().expect("baseline list should succeed");
let git_dir = repo.path().join(".git");
let hidden = repo.path().join(".git-hidden");
std::fs::rename(&git_dir, &hidden).unwrap();
let mut seen_breaker = false;
for i in 0..6 {
match mgr.list() {
Ok(_) => {}
Err(WorktreeError::CircuitBreakerOpen { consecutive_failures }) => {
assert!(
consecutive_failures >= 3,
"breaker should trip at or after threshold, got {consecutive_failures} on attempt {i}"
);
seen_breaker = true;
break;
}
Err(_other) => {
}
}
}
std::fs::rename(&hidden, &git_dir).unwrap();
assert!(
seen_breaker,
"circuit breaker did not trip after 6 failure attempts"
);
}
#[cfg(unix)]
#[test]
fn qa_c_005_stale_lock_recovery_after_sigkill() {
use iso_code::state;
let repo = create_test_repo();
let repo_path = repo.path().to_path_buf();
let _ = Manager::new(&repo_path, Config::default()).unwrap();
let pid = unsafe { libc::fork() };
if pid == 0 {
let _lock_guard = state::with_state_timeout(&repo_path, None, 5_000, |_s| Ok(()));
let lock_path = state::state_lock_path(&repo_path, None);
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.unwrap();
unsafe {
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&file);
libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB);
}
std::thread::sleep(Duration::from_secs(60));
unsafe { libc::_exit(0) };
}
std::thread::sleep(Duration::from_millis(200));
unsafe {
libc::kill(pid, libc::SIGKILL);
libc::waitpid(pid, std::ptr::null_mut(), 0);
}
let start = Instant::now();
let mgr = Manager::new(&repo_path, Config::default())
.expect("Manager::new() should recover after SIGKILL");
mgr.list().expect("list should succeed after recovery");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(6),
"stale lock recovery took {elapsed:?} — should be under 6s"
);
}
#[test]
fn qa_c_007_concurrent_list_no_index_lock_leak() {
let repo = create_test_repo();
let _ = Manager::new(repo.path(), Config::default()).unwrap();
let repo_path: PathBuf = repo.path().to_path_buf();
let barrier = Arc::new(Barrier::new(20));
let mut handles = Vec::new();
for _ in 0..20 {
let repo_path = repo_path.clone();
let barrier = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
let mgr = Manager::new(&repo_path, Config::default()).unwrap();
barrier.wait();
mgr.list().map(|_| ())
}));
}
for h in handles {
h.join().unwrap().expect("concurrent list must succeed");
}
assert!(
!repo.path().join(".git").join("index.lock").exists(),
".git/index.lock leaked after concurrent operations"
);
}
#[test]
fn qa_c_008_pid_reuse_false_positive_detected() {
use iso_code::state;
let repo = create_test_repo();
let _ = Manager::new(repo.path(), Config::default()).unwrap();
let lock_path = state::state_lock_path(repo.path(), None);
let payload = serde_json::json!({
"pid": std::process::id(),
"start_time": 42,
"uuid": "00000000-0000-0000-0000-000000000000",
"hostname": "not-this-host",
"acquired_at": "2020-01-01T00:00:00Z",
});
std::fs::write(&lock_path, payload.to_string()).unwrap();
let start = Instant::now();
let mgr = Manager::new(repo.path(), Config::default())
.expect("Manager::new() should tolerate stale lock payload");
mgr.list().expect("list should succeed despite stale payload");
assert!(
start.elapsed() < Duration::from_secs(5),
"stale payload should not cause lock-acquisition timeout"
);
}