use assert_cmd::Command;
use open_loops::sessions::claude_code::encode_project_path;
use predicates::prelude::*;
use std::path::Path;
fn git(repo: &Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
}
fn loops(home: &Path) -> Command {
let mut cmd = Command::cargo_bin("loops").unwrap();
cmd.env("OPEN_LOOPS_HOME", home);
cmd
}
fn toml_path(p: &Path) -> String {
p.display().to_string().replace('\\', "/")
}
#[test]
fn full_flow_init_list_resume_cache_ignore() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo = tmp.path().join("projetos/meu-app");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
git(&repo, &["checkout", "-b", "feat/login"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "feat: login wip"]);
loops(&home)
.arg("init")
.arg(tmp.path().join("projetos"))
.assert()
.success()
.stdout(predicate::str::contains("roots registered"));
loops(&home)
.assert()
.success()
.stdout(predicate::str::contains("meu-app/feat/login"));
let cfg_path = home.join("config.toml");
let cfg = std::fs::read_to_string(&cfg_path).unwrap();
std::fs::write(
&cfg_path,
cfg.replace("llm_command = \"claude -p\"", "llm_command = \"cat\""),
)
.unwrap();
loops(&home)
.args(["resume", "feat/login"])
.assert()
.success()
.stdout(predicate::str::contains("feat: login wip"))
.stdout(predicate::str::contains("## Sources"))
.stdout(predicate::str::contains("**Confidence:** low"));
loops(&home)
.args(["resume", "feat/login", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("Dry run — LLM not invoked"))
.stdout(predicate::str::contains("feat: login wip"))
.stdout(predicate::str::contains("**Confidence:** low"));
let cfg = std::fs::read_to_string(&cfg_path).unwrap();
std::fs::write(
&cfg_path,
cfg.replace("llm_command = \"cat\"", "llm_command = \"false\""),
)
.unwrap();
loops(&home)
.args(["resume", "feat/login"])
.assert()
.success()
.stdout(predicate::str::contains("## Sources"));
loops(&home)
.args(["ignore", "projetos/meu-app/feat/login"])
.assert()
.success();
loops(&home)
.assert()
.success()
.stdout(predicate::str::contains("feat/login").not());
}
#[test]
fn resume_no_match_guides_user() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projetos");
std::fs::create_dir_all(&root).unwrap();
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.args(["resume", "does-not-exist"])
.assert()
.failure()
.stderr(predicate::str::contains("no loop matches"));
}
#[test]
fn list_and_resume_without_roots_guides_user() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
loops(&home)
.assert()
.failure()
.stderr(predicate::str::contains("no roots configured"));
loops(&home)
.args(["resume", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains("no roots configured"));
}
#[test]
fn ignore_key_without_slash_rejects_with_helpful_message() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
loops(&home)
.args(["ignore", "noslash"])
.assert()
.failure()
.stderr(predicate::str::contains("expected format: repo/branch"));
}
#[test]
fn resume_ambiguous_query_lists_candidates() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo = tmp.path().join("projetos/app");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
git(&repo, &["checkout", "-b", "feat/login"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "feat: login"]);
git(&repo, &["checkout", "main"]);
git(&repo, &["checkout", "-b", "feat/signup"]);
std::fs::write(repo.join("c.txt"), "c").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "feat: signup"]);
loops(&home)
.arg("init")
.arg(tmp.path().join("projetos"))
.assert()
.success();
loops(&home)
.args(["resume", "feat"])
.assert()
.failure()
.stderr(predicate::str::contains("ambiguous query"))
.stderr(predicate::str::contains("app/feat/login"))
.stderr(predicate::str::contains("app/feat/signup"));
}
#[test]
fn list_prints_warnings_for_broken_repos() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projetos");
let empty = root.join("vazio");
std::fs::create_dir_all(&empty).unwrap();
git(&empty, &["init", "-b", "main"]);
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.assert()
.success()
.stderr(predicate::str::contains("warning"));
}
#[test]
fn completions_generates_script_for_shell() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
loops(&home)
.arg("completions")
.arg("bash")
.assert()
.success()
.stdout(predicate::str::contains("loops"));
}
fn init_repo(repo: &std::path::Path) {
std::fs::create_dir_all(repo).unwrap();
git(repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
git(repo, &["add", "."]);
git(repo, &["commit", "-m", "init"]);
}
#[test]
fn worktrees_aggregates_across_multiple_repos() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projetos");
for (i, name) in ["app-a", "app-b"].iter().enumerate() {
let repo = root.join(name);
init_repo(&repo);
let wt = tmp.path().join(format!("wt-{i}"));
git(
&repo,
&["worktree", "add", wt.to_str().unwrap(), "-b", "fix/done"],
);
}
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.arg("worktrees")
.assert()
.success()
.stdout(predicate::str::contains("app-a/wt-0"))
.stdout(predicate::str::contains("app-b/wt-1"))
.stdout(predicate::str::contains("2 worktree(s) to clean up"));
}
#[test]
fn worktrees_never_suggests_removing_unmerged_or_dirty() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projetos");
let repo = root.join("app");
init_repo(&repo);
let cold = tmp.path().join("wt-cold");
git(
&repo,
&["worktree", "add", cold.to_str().unwrap(), "-b", "feat/cold"],
);
std::fs::write(cold.join("c.txt"), "c").unwrap();
git(&cold, &["add", "."]);
git(&cold, &["commit", "-m", "wip"]);
let dirty = tmp.path().join("wt-dirty");
git(
&repo,
&[
"worktree",
"add",
dirty.to_str().unwrap(),
"-b",
"feat/dirty",
],
);
std::fs::write(dirty.join("d.txt"), "d").unwrap();
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.arg("worktrees")
.assert()
.success()
.stdout(predicate::str::contains("cold"))
.stdout(predicate::str::contains("active"))
.stdout(predicate::str::contains("nothing to clean up"))
.stdout(predicate::str::contains("worktree remove").not());
}
#[test]
fn worktrees_output_is_ascii() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projetos");
let repo = root.join("app");
init_repo(&repo);
let wt = tmp.path().join("wt");
git(
&repo,
&["worktree", "add", wt.to_str().unwrap(), "-b", "fix/done"],
);
loops(&home).arg("init").arg(&root).assert().success();
let out = loops(&home)
.arg("worktrees")
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(out.is_ascii(), "worktrees output must be ASCII-only");
}
#[test]
fn worktrees_clean_environment_has_no_false_positive() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projetos");
let repo = root.join("app");
init_repo(&repo); loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.arg("worktrees")
.assert()
.success()
.stdout(predicate::str::contains("home"))
.stdout(predicate::str::contains("nothing to clean up"))
.stdout(predicate::str::contains("worktree remove").not());
}
#[test]
fn completions_for_zsh_and_fish_are_nonempty() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
for shell in ["zsh", "fish"] {
loops(&home)
.arg("completions")
.arg(shell)
.assert()
.success()
.stdout(predicate::str::contains("loops"));
}
}
#[test]
fn worktrees_lists_and_suggests_cleanup() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projetos");
let repo = root.join("meu-app");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
let wt = tmp.path().join("wt-done");
git(
&repo,
&["worktree", "add", wt.to_str().unwrap(), "-b", "fix/done"],
);
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.arg("worktrees")
.assert()
.success()
.stdout(predicate::str::contains("deletable"))
.stdout(predicate::str::contains("worktree remove"));
loops(&home).arg("wt").assert().success();
}
#[test]
fn list_filters_by_query_term() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projects");
for name in ["api", "web"] {
let repo = root.join(name);
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
git(&repo, &["checkout", "-b", "feat/x"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "wip"]);
}
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.arg("api")
.assert()
.success()
.stdout(predicate::str::contains("projects/api/feat/x"))
.stdout(predicate::str::contains("web/feat/x").not());
}
#[test]
fn list_finds_branches_in_bare_worktree_layout() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projects");
let container = root.join("my-app");
std::fs::create_dir_all(&container).unwrap();
let bare = container.join(".bare");
std::fs::create_dir_all(&bare).unwrap();
git(&bare, &["init", "--bare", "-b", "main"]);
std::fs::write(container.join(".git"), "gitdir: ./.bare\n").unwrap();
let main = container.join("main");
git(
&container,
&["worktree", "add", "-b", "main", main.to_str().unwrap()],
);
std::fs::write(main.join("a.txt"), "a").unwrap();
git(&main, &["add", "."]);
git(&main, &["commit", "-m", "init"]);
git(&main, &["checkout", "-b", "feat/login"]);
std::fs::write(main.join("b.txt"), "b").unwrap();
git(&main, &["add", "."]);
git(&main, &["commit", "-m", "feat"]);
git(&main, &["checkout", "main"]);
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.assert()
.success()
.stdout(predicate::str::contains("my-app/feat/login"));
}
#[test]
fn resume_includes_session_excerpt_for_branch_in_worktree() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projects");
let container = root.join("my-app");
let bare = container.join(".bare");
std::fs::create_dir_all(&bare).unwrap();
git(&bare, &["init", "--bare", "-b", "main"]);
std::fs::write(container.join(".git"), "gitdir: ./.bare\n").unwrap();
let main = container.join("main");
git(
&container,
&["worktree", "add", "-b", "main", main.to_str().unwrap()],
);
std::fs::write(main.join("a.txt"), "a").unwrap();
git(&main, &["add", "."]);
git(&main, &["commit", "-m", "init"]);
let feat = container.join("feat-login");
git(
&container,
&[
"worktree",
"add",
"-b",
"feat/login",
feat.to_str().unwrap(),
],
);
std::fs::write(feat.join("b.txt"), "b").unwrap();
git(&feat, &["add", "."]);
git(&feat, &["commit", "-m", "feat: login wip"]);
let sessions = tmp.path().join("ai-sessions");
let feat_path = std::fs::canonicalize(&feat).unwrap_or(feat);
let proj = sessions.join(encode_project_path(&feat_path));
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(
proj.join("s.jsonl"),
concat!(
r#"{"type":"user","message":{"content":"resume the login feature please"}}"#,
"\n",
),
)
.unwrap();
loops(&home).arg("init").arg(&root).assert().success();
let cfg_path = home.join("config.toml");
let raw = std::fs::read_to_string(&cfg_path).unwrap();
let rewritten: String = raw
.lines()
.map(|l| {
if l.trim_start().starts_with("sessions_dir") {
format!("sessions_dir = \"{}\"", toml_path(&sessions))
} else if l.trim_start().starts_with("llm_command") {
"llm_command = \"cat\"".to_string()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&cfg_path, rewritten + "\n").unwrap();
loops(&home)
.args(["resume", "feat/login"])
.assert()
.success()
.stdout(predicate::str::contains("resume the login feature please"));
}
#[test]
fn inventory_write_through_on_list() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo = tmp.path().join("projects/my-app");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
git(&repo, &["checkout", "-b", "feat/cache-me"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "feat: cache me"]);
loops(&home)
.arg("init")
.arg(tmp.path().join("projects"))
.assert()
.success();
loops(&home)
.assert()
.success()
.stdout(predicate::str::contains("my-app/feat/cache-me"));
let inv_dir = home.join("inventory");
assert!(
inv_dir.exists(),
"inventory dir should be created after first scan"
);
let entries: Vec<_> = std::fs::read_dir(&inv_dir)
.unwrap()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.collect();
assert_eq!(entries.len(), 1, "exactly one inventory JSON expected");
let json_raw = std::fs::read_to_string(entries[0].path()).unwrap();
let json: serde_json::Value = serde_json::from_str(&json_raw).unwrap();
assert!(json["repo_path"].is_string(), "repo_path must be present");
assert!(json["indexed_at"].is_string(), "indexed_at must be present");
let loops_arr = json["loops"].as_array().unwrap();
assert_eq!(loops_arr.len(), 1);
let memo = &loops_arr[0];
assert_eq!(memo["branch"].as_str().unwrap(), "feat/cache-me");
assert!(memo["head_sha"].is_string());
assert!(memo["ab_base_sha"].is_string());
assert_eq!(memo["ahead"].as_u64().unwrap(), 1);
assert_eq!(memo["behind"].as_u64().unwrap(), 0);
loops(&home)
.arg("--fresh")
.assert()
.success()
.stdout(predicate::str::contains("feat/cache-me"));
loops(&home)
.arg("refresh")
.assert()
.success()
.stderr(predicate::str::contains("refreshed 1 repo"));
}
#[test]
fn scoped_refresh_does_not_prune_other_inventory() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let api = tmp.path().join("projects/api-service");
let web = tmp.path().join("projects/web-app");
for repo in [&api, &web] {
std::fs::create_dir_all(repo).unwrap();
git(repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
git(repo, &["add", "."]);
git(repo, &["commit", "-m", "init"]);
git(repo, &["checkout", "-b", "feat/x"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
git(repo, &["add", "."]);
git(repo, &["commit", "-m", "feat"]);
}
loops(&home)
.arg("init")
.arg(tmp.path().join("projects"))
.assert()
.success();
loops(&home).assert().success();
let inv_dir = home.join("inventory");
assert_eq!(
count_inventory_json(&inv_dir),
2,
"expected one inventory file per repo"
);
loops(&home)
.args(["refresh", "repo:api"])
.assert()
.success()
.stderr(predicate::str::contains("refreshed 1 repo"));
assert_eq!(
count_inventory_json(&inv_dir),
2,
"scoped refresh must not delete inventory for repos outside the query"
);
}
#[test]
fn resume_dry_run_skips_ahead_behind_without_attr_filter() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo = tmp.path().join("projetos/meu-app");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "a").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
git(&repo, &["checkout", "-b", "feat/login"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "feat: login wip"]);
loops(&home)
.arg("init")
.arg(tmp.path().join("projetos"))
.assert()
.success();
loops(&home)
.args(["resume", "feat/login", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("ahead: -, behind: -"));
}
fn repo_with_feature(root: &Path, name: &str) {
let repo = root.join(name);
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), format!("base-{name}")).unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
git(&repo, &["checkout", "-b", "feat/x"]);
std::fs::write(repo.join("b.txt"), format!("feat-{name}")).unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "feat"]);
git(&repo, &["checkout", "main"]);
}
fn count_inventory_json(dir: &Path) -> usize {
std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.count()
})
.unwrap_or(0)
}
#[test]
fn refresh_bare_term_scopes_to_matching_repos() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let projects = tmp.path().join("projects");
for name in ["alpha", "beta", "gamma"] {
repo_with_feature(&projects, name);
}
loops(&home).arg("init").arg(&projects).assert().success();
loops(&home).assert().success();
loops(&home)
.args(["refresh", "beta"])
.assert()
.success()
.stderr(predicate::str::contains("refreshed 1 repo"));
loops(&home)
.args(["refresh", "repo:beta"])
.assert()
.success()
.stderr(predicate::str::contains("refreshed 1 repo"));
loops(&home)
.arg("refresh")
.assert()
.success()
.stderr(predicate::str::contains("refreshed 3 repos"));
}
#[test]
fn refresh_prunes_disk_gone_repo_even_when_out_of_scope() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let projects = tmp.path().join("projects");
for name in ["api-1", "api-2", "web-1"] {
repo_with_feature(&projects, name);
}
loops(&home).arg("init").arg(&projects).assert().success();
loops(&home).assert().success();
let inv_dir = home.join("inventory");
assert_eq!(
count_inventory_json(&inv_dir),
3,
"one inventory file per repo"
);
std::fs::remove_dir_all(projects.join("web-1")).unwrap();
loops(&home)
.args(["refresh", "repo:api"])
.assert()
.success()
.stderr(predicate::str::contains("refreshed 2 repos"))
.stderr(predicate::str::contains("removed orphan inventory"));
assert_eq!(
count_inventory_json(&inv_dir),
2,
"disk-gone web-1 inventory must be pruned"
);
}
#[test]
fn cache_hit_serves_memo_and_fresh_recomputes() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let projects = tmp.path().join("projects");
repo_with_feature(&projects, "app");
loops(&home).arg("init").arg(&projects).assert().success();
loops(&home).assert().success();
let inv_dir = home.join("inventory");
let inv_file = std::fs::read_dir(&inv_dir)
.unwrap()
.flatten()
.find(|e| e.path().extension().is_some_and(|x| x == "json"))
.unwrap()
.path();
let mut json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&inv_file).unwrap()).unwrap();
json["loops"][0]["ahead"] = serde_json::json!(99);
std::fs::write(&inv_file, serde_json::to_string(&json).unwrap()).unwrap();
loops(&home)
.arg("ahead:99")
.assert()
.success()
.stdout(predicate::str::contains("feat/x"));
loops(&home)
.args(["--fresh", "ahead:99"])
.assert()
.success()
.stderr(predicate::str::contains("No loops match"));
}
#[test]
fn refresh_branch_filter_scopes_to_matching_branch() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let projects = tmp.path().join("projects");
for (name, branch) in [("api", "feat/login"), ("web", "feat/cart")] {
let repo = projects.join(name);
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]);
std::fs::write(repo.join("a.txt"), format!("base-{name}")).unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "init"]);
git(&repo, &["checkout", "-b", branch]);
std::fs::write(repo.join("b.txt"), format!("feat-{name}")).unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "feat"]);
}
loops(&home).arg("init").arg(&projects).assert().success();
loops(&home).assert().success();
loops(&home)
.args(["refresh", "branch:login"])
.assert()
.success()
.stderr(predicate::str::contains("refreshed 1 repo"));
}
#[test]
fn refresh_no_match_reindexes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let projects = tmp.path().join("projects");
for name in ["alpha", "beta"] {
repo_with_feature(&projects, name);
}
loops(&home).arg("init").arg(&projects).assert().success();
loops(&home).assert().success();
loops(&home)
.args(["refresh", "zzz-no-such-repo-or-branch"])
.assert()
.success()
.stderr(predicate::str::contains("refreshed 0 repos"));
}
#[test]
fn worktrees_of_one_repo_share_a_single_inventory_file() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projects");
repo_with_feature(&root, "app");
let repo = root.join("app");
let wt = tmp.path().join("wt-y");
git(
&repo,
&["worktree", "add", wt.to_str().unwrap(), "-b", "feat/y"],
);
std::fs::write(wt.join("c.txt"), "c").unwrap();
git(&wt, &["add", "."]);
git(&wt, &["commit", "-m", "feat y"]);
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.assert()
.success()
.stdout(predicate::str::contains("feat/x"))
.stdout(predicate::str::contains("feat/y"));
assert_eq!(
count_inventory_json(&home.join("inventory")),
1,
"two worktrees of one repo must share a single inventory file"
);
}
#[test]
fn inventory_writes_survive_concurrent_processes() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let projects = tmp.path().join("projects");
for name in ["api", "web", "cli"] {
repo_with_feature(&projects, name);
}
loops(&home).arg("init").arg(&projects).assert().success();
let bin = env!("CARGO_BIN_EXE_loops");
let handles: Vec<_> = (0..8)
.map(|_| {
let home = home.clone();
let bin = bin.to_string();
std::thread::spawn(move || {
std::process::Command::new(bin)
.env("OPEN_LOOPS_HOME", &home)
.arg("--fresh")
.output()
.unwrap()
})
})
.collect();
for h in handles {
let out = h.join().unwrap();
assert!(out.status.success(), "concurrent scan exited non-zero");
}
let inv_dir = home.join("inventory");
let mut tmp_left = 0;
for entry in std::fs::read_dir(&inv_dir).unwrap().flatten() {
let path = entry.path();
match path.extension().and_then(|e| e.to_str()) {
Some("tmp") => tmp_left += 1,
Some("json") => {
let raw = std::fs::read_to_string(&path).unwrap();
serde_json::from_str::<serde_json::Value>(&raw)
.unwrap_or_else(|e| panic!("corrupt inventory {}: {e}", path.display()));
}
_ => {}
}
}
assert_eq!(tmp_left, 0, "no .tmp file should survive concurrent writes");
assert_eq!(
count_inventory_json(&inv_dir),
3,
"all three repos' inventory must be present and valid"
);
}
#[test]
fn stale_origin_head_falls_back_to_main() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let root = tmp.path().join("projects");
repo_with_feature(&root, "app");
let repo = root.join("app");
git(
&repo,
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/ghost",
],
);
loops(&home).arg("init").arg(&root).assert().success();
loops(&home)
.assert()
.success()
.stdout(predicate::str::contains("app/feat/x"));
}
#[test]
fn refresh_rewrites_corrupt_inventory_for_in_scope_repo() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let projects = tmp.path().join("projects");
repo_with_feature(&projects, "app");
loops(&home).arg("init").arg(&projects).assert().success();
loops(&home).assert().success();
let inv_dir = home.join("inventory");
let inv_file = std::fs::read_dir(&inv_dir)
.unwrap()
.flatten()
.find(|e| e.path().extension().is_some_and(|x| x == "json"))
.unwrap()
.path();
std::fs::write(&inv_file, b"{ corrupt").unwrap();
loops(&home).arg("refresh").assert().success();
assert_eq!(
count_inventory_json(&inv_dir),
1,
"in-scope file must survive"
);
let raw = std::fs::read_to_string(&inv_file).unwrap();
serde_json::from_str::<serde_json::Value>(&raw)
.expect("inventory must be valid JSON again after refresh");
}