use std::process::Command;
use std::{fs, path::PathBuf};
use loopflow::engine::git::{is_clean, worktree_move, worktree_remove};
use loopflow::engine::worktrees::{
create_with_schema, create_with_schema_synced, list_worktrees, list_worktrees_local,
preserve_worktree, wave_name_from_worktree_and_main,
};
use loopflow_test_support::TestRepo;
fn git_stdout(repo: &std::path::Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("git command should run");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn worktree_add_creates_directory() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "feature", None, None).expect("create");
assert!(result.path.exists());
assert!(
result.branch.contains("feature"),
"branch name should include short name: {}",
result.branch
);
}
#[test]
fn worktree_add_is_on_correct_branch() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "feature", None, None).expect("create");
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&result.path)
.output()
.expect("git rev-parse");
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(branch, result.branch);
}
#[test]
fn worktree_remove_deletes_directory() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "feature", None, None).expect("create");
worktree_remove(repo.path(), &result.path).expect("remove");
assert!(!result.path.exists());
}
#[test]
fn worktree_list_includes_created() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "feature", None, None).expect("create");
let worktrees = list_worktrees(repo.path()).expect("list");
assert!(worktrees
.iter()
.any(|wt| wt.branch.as_deref() == Some(result.branch.as_str())));
}
#[test]
fn worktree_state_detects_dirty() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "feature", None, None).expect("create");
std::fs::write(result.path.join("dirty.txt"), "dirty").expect("write");
assert!(!is_clean(&result.path).expect("is_clean"));
}
#[test]
fn worktree_move_preserves_content() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "feature", None, None).expect("create");
let file_path = result.path.join("note.txt");
std::fs::write(&file_path, "content").expect("write");
let new_path = result.path.with_extension("moved");
worktree_move(repo.path(), &result.path, &new_path).expect("move");
assert!(!result.path.exists());
assert!(new_path.exists());
assert!(new_path.join("note.txt").exists());
}
#[test]
fn create_with_schema_synced_updates_main_before_creation() {
let repo = TestRepo::new();
let original_head = git_stdout(repo.path(), &["rev-parse", "HEAD"]);
let clone_dir = tempfile::TempDir::new().expect("temp clone dir");
let status = Command::new("git")
.args([
"clone",
repo.bare_path().to_str().expect("remote path"),
clone_dir.path().to_str().expect("clone path"),
])
.status()
.expect("clone remote");
assert!(status.success(), "clone should succeed");
let status = Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(clone_dir.path())
.status()
.expect("set email");
assert!(status.success(), "git config user.email should succeed");
let status = Command::new("git")
.args(["config", "user.name", "test"])
.current_dir(clone_dir.path())
.status()
.expect("set name");
assert!(status.success(), "git config user.name should succeed");
std::fs::write(clone_dir.path().join("remote.txt"), "remote update").expect("write file");
let status = Command::new("git")
.args(["add", "."])
.current_dir(clone_dir.path())
.status()
.expect("git add");
assert!(status.success(), "git add should succeed");
let status = Command::new("git")
.args(["commit", "-m", "remote update"])
.current_dir(clone_dir.path())
.status()
.expect("git commit");
assert!(status.success(), "git commit should succeed");
let status = Command::new("git")
.args(["push", "origin", "main"])
.current_dir(clone_dir.path())
.status()
.expect("git push");
assert!(status.success(), "git push should succeed");
let _ = create_with_schema_synced(repo.path(), "sync-check", None, None).expect("create");
let updated_head = git_stdout(repo.path(), &["rev-parse", "HEAD"]);
let origin_head = git_stdout(repo.path(), &["rev-parse", "origin/main"]);
assert_ne!(
original_head, updated_head,
"main head should advance after sync"
);
assert_eq!(
updated_head, origin_head,
"main should be reset to origin/main before worktree creation"
);
}
#[test]
fn wt_switch_finds_worktree_by_wave_name() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "docs", None, None).expect("create");
let worktrees = list_worktrees(repo.path()).expect("list");
let name =
wave_name_from_worktree_and_main(&result.path, repo.path()).expect("should have wave name");
let matches: Vec<_> = worktrees
.iter()
.filter(|wt| {
wave_name_from_worktree_and_main(&wt.path, repo.path()).as_deref()
== Some(name.as_str())
})
.collect();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, result.path);
}
#[test]
fn wt_switch_prefix_matching_is_dot_delimited() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "waves", None, None).expect("create");
let wave_name =
wave_name_from_worktree_and_main(&result.path, repo.path()).expect("should have wave name");
let worktrees = list_worktrees(repo.path()).expect("list");
let prefix = "wav";
let matches: Vec<_> = worktrees
.iter()
.filter(|wt| {
let wt_name = wave_name_from_worktree_and_main(&wt.path, repo.path());
wt_name.as_deref() == Some(prefix)
|| wt_name
.as_ref()
.map(|n| n.starts_with(&format!("{prefix}.")))
.unwrap_or(false)
})
.collect();
assert!(
matches.is_empty(),
"prefix matching is dot-delimited, not substring: got {:?}",
wave_name
);
}
#[test]
fn wt_switch_finds_timestamped_worktree_by_short_name() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "waves", None, None).expect("create");
let timestamped = result.path.with_file_name(format!(
"{}.1772406404",
result.path.file_name().unwrap().to_string_lossy()
));
worktree_move(repo.path(), &result.path, ×tamped).expect("move");
let worktrees = list_worktrees(repo.path()).expect("list");
let wave_name =
wave_name_from_worktree_and_main(×tamped, repo.path()).expect("should have wave name");
assert_eq!(wave_name, "waves.1772406404");
let exact: Vec<_> = worktrees
.iter()
.filter(|wt| {
wave_name_from_worktree_and_main(&wt.path, repo.path()).as_deref()
== Some("waves.1772406404")
})
.collect();
assert_eq!(exact.len(), 1);
let prefix: Vec<_> = worktrees
.iter()
.filter(|wt| {
let n = wave_name_from_worktree_and_main(&wt.path, repo.path());
n.as_ref().map(|n| n.starts_with("waves.")).unwrap_or(false)
})
.collect();
assert_eq!(prefix.len(), 1);
}
#[test]
fn wt_switch_prefers_exact_branch_match_over_wave_name_path() {
let repo = TestRepo::new();
let old_branch = "jack.chord-model.20260316_1856";
let new_branch = "jack.chord-model.20260318_0010";
let old_path = repo.path().parent().expect("repo parent").join(format!(
"{}.chord-model.20260316_1856",
repo.path().file_name().expect("repo dir").to_string_lossy()
));
let new_path = repo.path().parent().expect("repo parent").join(format!(
"{}.chord-model",
repo.path().file_name().expect("repo dir").to_string_lossy()
));
add_worktree(repo.path(), &old_path, old_branch);
add_worktree(repo.path(), &new_path, new_branch);
let directive_path = repo.path().join("directive.txt");
let status = Command::new(env!("CARGO_BIN_EXE_lf"))
.args(["op", "wt", "switch", old_branch])
.current_dir(repo.path())
.env("LOOPFLOW_DIRECTIVE_FILE", &directive_path)
.status()
.expect("run lf op wt switch");
assert!(status.success(), "lf op wt switch should succeed");
let directive = fs::read_to_string(&directive_path).expect("read directive");
let target = PathBuf::from(
directive
.trim()
.strip_prefix("cd ")
.expect("directive should start with cd "),
);
assert_eq!(
fs::canonicalize(target).expect("canonicalize directive target"),
fs::canonicalize(old_path).expect("canonicalize old path")
);
}
#[test]
fn wt_switch_finds_exact_branch_match_with_custom_schema() {
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join(".lf")).expect("create .lf");
fs::write(
repo.path().join(".lf/config.yaml"),
"branch_names:\n schema: \"{user}/{name}\"\n",
)
.expect("write config");
let feature_path = repo.path().parent().expect("repo parent").join(format!(
"{}.feature",
repo.path().file_name().expect("repo dir").to_string_lossy()
));
add_worktree(repo.path(), &feature_path, "jack/feature");
let directive_path = repo.path().join("directive.txt");
let status = Command::new(env!("CARGO_BIN_EXE_lf"))
.args(["op", "wt", "switch", "jack/feature"])
.current_dir(repo.path())
.env("LOOPFLOW_DIRECTIVE_FILE", &directive_path)
.status()
.expect("run lf op wt switch");
assert!(status.success(), "lf op wt switch should succeed");
let directive = fs::read_to_string(&directive_path).expect("read directive");
let target = PathBuf::from(
directive
.trim()
.strip_prefix("cd ")
.expect("directive should start with cd "),
);
assert_eq!(
fs::canonicalize(target).expect("canonicalize directive target"),
fs::canonicalize(feature_path).expect("canonicalize feature path")
);
}
#[test]
fn wt_switch_does_not_map_branch_name_to_unrelated_worktree_path() {
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join(".lf")).expect("create .lf");
fs::write(
repo.path().join(".lf/config.yaml"),
"branch_names:\n schema: \"{user}/{name}\"\n",
)
.expect("write config");
let feature_path = repo.path().parent().expect("repo parent").join(format!(
"{}.feature",
repo.path().file_name().expect("repo dir").to_string_lossy()
));
add_worktree(repo.path(), &feature_path, "feature-live");
let status = Command::new("git")
.args(["branch", "jack/feature"])
.current_dir(repo.path())
.status()
.expect("git branch");
assert!(status.success(), "git branch should succeed");
let output = Command::new(env!("CARGO_BIN_EXE_lf"))
.args(["op", "wt", "switch", "jack/feature"])
.current_dir(repo.path())
.output()
.expect("run lf op wt switch");
assert!(
!output.status.success(),
"lf op wt switch should fail for unrelated branch/worktree reuse"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("no worktree found for 'jack/feature'"),
"unexpected stderr: {stderr}"
);
}
fn add_worktree(repo: &std::path::Path, path: &std::path::Path, branch: &str) {
let status = Command::new("git")
.args(["worktree", "add", "-b", branch])
.arg(path)
.arg("main")
.current_dir(repo)
.status()
.expect("git worktree add");
assert!(
status.success(),
"git worktree add should succeed for {}",
branch
);
}
#[test]
fn nested_worktree_not_recognized_as_wave() {
let dir = tempfile::TempDir::new().unwrap();
let main_repo = dir.path().join("repo");
let nested = main_repo
.join(".claude")
.join("worktrees")
.join("repo.feature");
std::fs::create_dir_all(&nested).unwrap();
let result = wave_name_from_worktree_and_main(&nested, &main_repo);
assert_eq!(
result, None,
"nested worktree should not produce a wave name"
);
}
#[test]
fn preserve_worktree_uses_human_readable_timestamp() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "feature", None, None).expect("create");
let preserved = preserve_worktree(repo.path(), &result.path, None).expect("preserve");
let dir_name = preserved.file_name().unwrap().to_string_lossy().to_string();
let suffix = dir_name.rsplit_once('.').expect("should have dot").1;
assert_eq!(
suffix.len(),
13,
"timestamp should be YYYYMMDD_HHMM (13 chars), got: {suffix}"
);
let (date, time) = suffix.split_once('_').expect("should have underscore");
assert_eq!(date.len(), 8, "date part should be 8 digits: {date}");
assert_eq!(time.len(), 4, "time part should be 4 digits: {time}");
assert!(
date.chars().all(|c| c.is_ascii_digit()),
"date should be all digits: {date}"
);
assert!(
time.chars().all(|c| c.is_ascii_digit()),
"time should be all digits: {time}"
);
}
#[test]
fn preserve_worktree_uses_explicit_suffix_when_provided() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "feature", None, None).expect("create");
let preserved =
preserve_worktree(repo.path(), &result.path, Some("20260304_1442")).expect("preserve");
let dir_name = preserved.file_name().unwrap().to_string_lossy().to_string();
assert!(
dir_name.ends_with(".20260304_1442"),
"should use the provided suffix, got: {dir_name}"
);
}
#[test]
fn branch_at_main_not_detected_as_squash_merged() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "fresh", None, None).expect("create");
std::fs::write(result.path.join("scratch.txt"), "notes").expect("write");
let (_, states) = list_worktrees_local(repo.path()).expect("list");
let wt = states
.iter()
.find(|wt| wt.branch.as_deref() == Some(&result.branch))
.expect("should find worktree");
assert!(
!wt.squash_merged,
"branch at same commit as main should not be squash-merged"
);
assert!(!wt.prunable, "fresh worktree should not be prunable");
assert!(
wt.fresh,
"worktree with no commits beyond main should be fresh"
);
}
#[test]
fn fresh_worktree_is_not_prunable() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "newwave", None, None).expect("create");
let (_, states) = list_worktrees_local(repo.path()).expect("list");
let wt = states
.iter()
.find(|wt| wt.branch.as_deref() == Some(&result.branch))
.expect("should find worktree");
assert!(wt.fresh, "worktree with no commits should be fresh");
assert!(!wt.prunable, "fresh worktree should not be prunable");
assert!(!wt.dirty, "clean worktree should not be dirty");
}
#[test]
fn fresh_dirty_worktree_is_not_prunable() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "wip", None, None).expect("create");
std::fs::write(result.path.join("work.txt"), "in progress").expect("write");
let (_, states) = list_worktrees_local(repo.path()).expect("list");
let wt = states
.iter()
.find(|wt| wt.branch.as_deref() == Some(&result.branch))
.expect("should find worktree");
assert!(wt.fresh, "no commits beyond main means fresh");
assert!(wt.dirty, "uncommitted changes means dirty");
assert!(!wt.prunable, "dirty fresh worktree must not be prunable");
}
#[test]
fn worktree_with_commits_is_active_not_fresh() {
let repo = TestRepo::new();
let result = create_with_schema(repo.path(), "active", None, None).expect("create");
std::fs::write(result.path.join("feature.txt"), "work").expect("write");
git_stdout(&result.path, &["add", "."]);
git_stdout(&result.path, &["commit", "-m", "feature work"]);
let (_, states) = list_worktrees_local(repo.path()).expect("list");
let wt = states
.iter()
.find(|wt| wt.branch.as_deref() == Some(&result.branch))
.expect("should find worktree");
assert!(!wt.fresh, "worktree with commits beyond main is not fresh");
assert!(!wt.prunable, "active worktree should not be prunable");
assert!(!wt.merged, "active worktree is not merged");
}
#[test]
fn branch_from_squash_merged_parent_stays_fresh() {
let repo = TestRepo::new();
let landed = create_with_schema(repo.path(), "rules-old", None, None).expect("create landed");
std::fs::write(landed.path.join("rules.txt"), "rule").expect("write");
git_stdout(&landed.path, &["add", "."]);
git_stdout(&landed.path, &["commit", "-m", "add rule"]);
git_stdout(repo.path(), &["merge", "--squash", landed.branch.as_str()]);
git_stdout(repo.path(), &["commit", "-m", "land rules"]);
git_stdout(repo.path(), &["push", "origin", "main"]);
let fresh = create_with_schema(repo.path(), "rules-new", Some(landed.branch.as_str()), None)
.expect("create fresh from landed");
let (_, states) = list_worktrees_local(repo.path()).expect("list");
let wt = states
.iter()
.find(|wt| wt.branch.as_deref() == Some(&fresh.branch))
.expect("should find fresh worktree");
assert!(
wt.squash_merged,
"branch should be tree-equal to main after squash merge"
);
assert!(wt.fresh, "rotated branch should still be treated as fresh");
assert!(
!wt.prunable,
"fresh branch must not be pruned immediately after rotation"
);
}