use super::*;
use std::fs;
fn write_pin(root: &Path, palace: &str) -> PathBuf {
let dir = root.join(TRUSTY_TOOLS_DIR);
fs::create_dir_all(&dir).expect("create .trusty-tools");
let path = root.join(PIN_FILE_REL);
let pin = ProjectPin::new(palace);
fs::write(&path, serde_yaml::to_string(&pin).expect("serialise")).expect("write pin");
path
}
fn init_repo(root: &Path, remote: Option<&str>) -> bool {
let ok = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !ok(&["init", "-q"]) {
return false;
}
ok(&["config", "user.email", "t@example.com"]);
ok(&["config", "user.name", "T"]);
if let Some(url) = remote {
ok(&["config", "remote.origin.url", url]);
}
true
}
#[test]
fn finds_git_root_from_nested_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
let nested = root.join("crates").join("foo");
fs::create_dir_all(&nested).unwrap();
let found = find_project_root(&nested).expect("root");
assert_eq!(found, fs::canonicalize(&root).unwrap());
}
#[test]
fn trusty_tools_dir_is_a_marker() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(TRUSTY_TOOLS_DIR)).unwrap();
assert!(find_project_root(&root).is_some());
}
fn repo_with_worktree(tmp: &Path) -> Option<(PathBuf, PathBuf)> {
let main = tmp.join("my-project");
fs::create_dir_all(&main).unwrap();
if !init_repo(&main, None) {
return None;
}
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&main)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
fs::write(main.join("README.md"), "x").unwrap();
if !run(&["add", "-A"]) || !run(&["commit", "-qm", "init"]) {
return None;
}
let wt = main.join(".claude").join("worktrees").join("agent-x");
if !run(&["worktree", "add", "-q", "-b", "wt-branch", wt.to_str()?]) {
return None;
}
Some((main, wt))
}
#[test]
fn a_worktree_resolves_to_its_main_checkout() {
let tmp = tempfile::tempdir().expect("tempdir");
let Some((main, wt)) = repo_with_worktree(tmp.path()) else {
eprintln!("skipping a_worktree_resolves_to_its_main_checkout: git unavailable");
return;
};
assert!(
wt.join(".git").is_file(),
"fixture must reproduce git's linked-worktree `.git` FILE"
);
let expected = fs::canonicalize(&main).expect("canonicalize main");
assert_eq!(
find_project_root(&wt).expect("worktree resolves"),
expected,
"a worktree must resolve to the checkout that owns it"
);
let nested = wt.join("crates").join("foo");
fs::create_dir_all(&nested).unwrap();
assert_eq!(
find_project_root(&nested).expect("nested dir resolves"),
expected,
"a directory inside a worktree must resolve to the same checkout"
);
}
#[test]
fn a_separate_git_dir_checkout_is_its_own_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let outer = tmp.path().join("outer");
let sub = outer.join("sub");
fs::create_dir_all(&sub).unwrap();
if !init_repo(&outer, None) {
eprintln!("skipping a_separate_git_dir_checkout_is_its_own_root: git unavailable");
return;
}
let separate = outer.join(".git").join("modules").join("sub");
fs::create_dir_all(separate.parent().unwrap()).unwrap();
let init = Command::new("git")
.arg("-C")
.arg(&sub)
.args([
"init",
"-q",
&format!("--separate-git-dir={}", separate.display()),
".",
])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !init {
eprintln!("skipping a_separate_git_dir_checkout_is_its_own_root: cannot init");
return;
}
assert!(
sub.join(".git").is_file(),
"fixture must reproduce the `.git` FILE a submodule checkout carries"
);
assert_eq!(
find_project_root(&sub).expect("resolves"),
fs::canonicalize(&sub).expect("canonicalize sub"),
"a checkout whose git dir is elsewhere is still its own project root"
);
}
#[test]
fn a_malformed_dot_git_file_leaves_the_directory_as_the_root() {
let tmp = tempfile::tempdir().expect("tempdir");
for (name, body) in [
("garbage", "not a gitdir pointer at all\n"),
("empty-pointer", "gitdir: \n"),
("dangling", "gitdir: /nonexistent-trusty-test-admin-5888\n"),
] {
let root = tmp.path().join(name);
fs::create_dir_all(&root).unwrap();
fs::write(root.join(".git"), body).unwrap();
assert_eq!(
find_project_root(&root).expect("resolves"),
fs::canonicalize(&root).expect("canonicalize root"),
"a `.git` file that does not resolve ({name}) must leave the directory as the root"
);
}
}
#[test]
fn no_markers_returns_none() {
let missing = Path::new("/nonexistent-trusty-test-root-5802/deeper");
assert!(find_project_root(missing).is_none());
}
#[test]
fn reads_a_valid_pin() {
let tmp = tempfile::tempdir().expect("tempdir");
write_pin(tmp.path(), "canonical-name");
let pin = read_project_pin(tmp.path()).expect("ok").expect("some");
assert_eq!(pin.palace, "canonical-name");
}
#[test]
fn absent_pin_is_ok_none() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(read_project_pin(tmp.path()).expect("ok").is_none());
}
#[test]
fn malformed_pin_is_an_error_not_a_fallthrough() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("some-project");
fs::create_dir_all(root.join(".git")).unwrap();
fs::create_dir_all(root.join(TRUSTY_TOOLS_DIR)).unwrap();
fs::write(
root.join(PIN_FILE_REL),
"schema_version: 1\npalace:\n not: a-string\n",
)
.unwrap();
let err = read_project_pin(&root).expect_err("malformed pin must error");
assert!(
matches!(err, PalaceResolveError::PinMalformed { .. }),
"expected PinMalformed, got {err:?}"
);
let err = resolve_palace(&root).expect_err("resolver must not fall through");
assert!(
matches!(err, PalaceResolveError::PinMalformed { .. }),
"expected PinMalformed from resolve_palace, got {err:?}"
);
}
#[test]
fn empty_pin_palace_is_an_error() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
write_pin(&root, " ");
let err = resolve_palace(&root).expect_err("empty pin must error");
assert!(
matches!(err, PalaceResolveError::PinEmpty { .. }),
"expected PinEmpty, got {err:?}"
);
}
#[test]
#[serial_test::serial]
fn a_pin_naming_an_invalid_palace_id_is_an_error() {
let _guard = EnvGuard::clear();
let too_long = "a".repeat(PALACE_ID_MAX_LEN + 1);
let cases = [
"tripbot.tours",
"../evil",
"..",
"a/b",
"UPPER",
"has_underscore",
"-leading-hyphen",
too_long.as_str(),
];
for value in cases {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
write_pin(&root, value);
match resolve_palace(&root) {
Ok(resolution) => panic!("pin {value:?} resolved to {:?}", resolution.id),
Err(PalaceResolveError::PinInvalid { value: got, .. }) => {
assert_eq!(got, value, "the error must name the rejected value");
}
Err(other) => panic!("pin {value:?} expected PinInvalid, got {other:?}"),
}
}
}
#[test]
#[serial_test::serial]
fn a_valid_pin_still_resolves_and_is_trimmed() {
let _guard = EnvGuard::clear();
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
write_pin(&root, " canonical-name9 ");
let got = resolve_palace(&root).expect("a valid pin resolves");
assert_eq!(got.id, "canonical-name9");
assert_eq!(got.source, PalaceSource::PinFile);
}
#[cfg(unix)]
#[test]
fn unreadable_pin_is_an_error() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
let path = write_pin(&root, "pinned");
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o000);
fs::set_permissions(&path, perms).unwrap();
let result = read_project_pin(&root);
let mut restore = fs::metadata(&path).unwrap().permissions();
restore.set_mode(0o644);
fs::set_permissions(&path, restore).unwrap();
if let Ok(Some(_)) = result {
eprintln!("skipping unreadable_pin_is_an_error: running with root privileges");
return;
}
let err = result.expect_err("unreadable pin must error");
assert!(
matches!(err, PalaceResolveError::PinUnreadable { .. }),
"expected PinUnreadable, got {err:?}"
);
}
#[test]
#[serial_test::serial]
fn pin_beats_git_derivation() {
let _guard = EnvGuard::clear();
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("checkout-dir");
fs::create_dir_all(&root).unwrap();
if !init_repo(&root, Some("git@github.com:bobmatnyc/trusty-tools.git")) {
eprintln!("skipping pin_beats_git_derivation: git unavailable");
return;
}
write_pin(&root, "trusty-tools");
let got = resolve_palace(&root).expect("resolves");
assert_eq!(got.id, "trusty-tools");
assert_eq!(got.source, PalaceSource::PinFile);
}
#[test]
#[serial_test::serial]
fn git_owner_repo_used_when_unpinned() {
let _guard = EnvGuard::clear();
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("checkout-dir");
fs::create_dir_all(&root).unwrap();
if !init_repo(&root, Some("git@github.com:bobmatnyc/trusty-tools.git")) {
eprintln!("skipping git_owner_repo_used_when_unpinned: git unavailable");
return;
}
let got = resolve_palace(&root).expect("resolves");
assert_eq!(got.id, "bobmatnyc-trusty-tools");
assert_eq!(got.source, PalaceSource::GitOwnerRepo);
}
#[test]
#[serial_test::serial]
fn env_override_wins_over_pin_and_warns() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
write_pin(&root, "pinned-name");
let _guard = EnvGuard::set("operator-choice");
let got = resolve_palace(&root).expect("resolves");
assert_eq!(got.id, "operator-choice");
assert_eq!(got.source, PalaceSource::EnvOverride);
}
#[test]
#[serial_test::serial]
fn blank_env_override_falls_through_to_pin() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
write_pin(&root, "pinned-name");
let _guard = EnvGuard::set(" ");
let got = resolve_palace(&root).expect("resolves");
assert_eq!(got.id, "pinned-name");
assert_eq!(got.source, PalaceSource::PinFile);
}
#[test]
#[serial_test::serial]
fn an_over_long_env_override_still_resolves_to_an_acceptable_id() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
let _guard = EnvGuard::set(&"Very_Long Operator Palace Name ".repeat(6));
let got = resolve_palace(&root).expect("resolves");
assert_eq!(got.source, PalaceSource::EnvOverride);
assert!(
crate::palace_id::palace_id_is_valid(&got.id),
"override-derived id must pass the daemon gate, got {:?} ({} bytes)",
got.id,
got.id.len()
);
}
#[test]
#[serial_test::serial]
fn a_long_project_dir_resolves_to_an_acceptable_id() {
let _guard = EnvGuard::clear();
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp
.path()
.join("a-project-directory-named-far-past-any-reasonable-length-limit-indeed");
fs::create_dir_all(root.join(TRUSTY_TOOLS_DIR)).unwrap();
let got = resolve_palace(&root).expect("resolves");
assert!(
crate::palace_id::palace_id_is_valid(&got.id),
"parent/dir id must pass the daemon gate, got {:?} ({} bytes)",
got.id,
got.id.len()
);
}
#[test]
#[serial_test::serial]
fn malformed_pin_errors_even_under_env_override() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
fs::create_dir_all(root.join(TRUSTY_TOOLS_DIR)).unwrap();
fs::write(root.join(PIN_FILE_REL), "palace: [unclosed\n").unwrap();
let _guard = EnvGuard::set("operator-choice");
let err = resolve_palace(&root).expect_err("must error");
assert!(
matches!(err, PalaceResolveError::PinMalformed { .. }),
"expected PinMalformed, got {err:?}"
);
}
#[test]
#[serial_test::serial]
fn worktree_and_main_checkout_agree() {
let _guard = EnvGuard::clear();
let tmp = tempfile::tempdir().expect("tempdir");
let main = tmp.path().join("my-project");
fs::create_dir_all(&main).unwrap();
if !init_repo(&main, None) {
eprintln!("skipping worktree_and_main_checkout_agree: git unavailable");
return;
}
fs::write(main.join("README.md"), "x").unwrap();
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&main)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !run(&["add", "-A"]) || !run(&["commit", "-qm", "init"]) {
eprintln!("skipping worktree_and_main_checkout_agree: cannot commit");
return;
}
let wt = main.join(".claude").join("worktrees").join("agent-x");
if !run(&[
"worktree",
"add",
"-q",
"-b",
"wt-branch",
wt.to_str().unwrap(),
]) {
eprintln!("skipping worktree_and_main_checkout_agree: cannot add worktree");
return;
}
let from_main = resolve_palace(&main).expect("main resolves");
let from_worktree = resolve_palace(&wt).expect("worktree resolves");
assert_eq!(
from_main.id, from_worktree.id,
"a worktree must resolve to the same palace as its main checkout \
(main={:?} from {:?}, worktree={:?} from {:?})",
from_main.id, from_main.source, from_worktree.id, from_worktree.source
);
assert_eq!(from_main.source, PalaceSource::ParentDir);
assert_eq!(from_worktree.source, PalaceSource::ParentDir);
}
#[test]
#[serial_test::serial]
fn worktree_and_main_checkout_agree_when_pinned() {
let _guard = EnvGuard::clear();
let tmp = tempfile::tempdir().expect("tempdir");
let main = tmp.path().join("my-project");
fs::create_dir_all(&main).unwrap();
if !init_repo(&main, Some("git@github.com:acme/widget.git")) {
eprintln!("skipping worktree_and_main_checkout_agree_when_pinned: git unavailable");
return;
}
write_pin(&main, "legacy-widget");
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&main)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !run(&["add", "-A"]) || !run(&["commit", "-qm", "init"]) {
eprintln!("skipping worktree_and_main_checkout_agree_when_pinned: cannot commit");
return;
}
let wt = main.join(".claude").join("worktrees").join("agent-y");
if !run(&["worktree", "add", "-q", "-b", "wt2", wt.to_str().unwrap()]) {
eprintln!("skipping worktree_and_main_checkout_agree_when_pinned: cannot add worktree");
return;
}
let from_main = resolve_palace(&main).expect("main resolves");
let from_worktree = resolve_palace(&wt).expect("worktree resolves");
assert_eq!(from_main.id, "legacy-widget");
assert_eq!(from_worktree.id, "legacy-widget");
assert_eq!(from_worktree.source, PalaceSource::PinFile);
}
#[test]
fn separate_git_dir_child_yields_none_not_a_git_internals_path() {
let tmp = tempfile::tempdir().expect("tempdir");
let outer = tmp.path().join("outer");
let sub = outer.join("sub");
fs::create_dir_all(&sub).unwrap();
if !init_repo(&outer, None) {
eprintln!(
"skipping separate_git_dir_child_yields_none_not_a_git_internals_path: git unavailable"
);
return;
}
let modules = outer.join(".git").join("modules");
fs::create_dir_all(&modules).unwrap();
let separate = modules.join("sub");
let init = Command::new("git")
.arg("-C")
.arg(&sub)
.args([
"init",
"-q",
&format!("--separate-git-dir={}", separate.display()),
".",
])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !init {
eprintln!(
"skipping separate_git_dir_child_yields_none_not_a_git_internals_path: cannot init"
);
return;
}
assert!(
sub.join(".git").is_file(),
"fixture must reproduce the `.git` FILE a submodule checkout carries"
);
let resolved = main_worktree_root(&sub);
assert_eq!(
resolved, None,
"a common dir under `.git/modules` names no working tree; the probe must \
decline rather than return {resolved:?}"
);
}
#[test]
fn git_probes_outside_a_repo_are_none() {
let missing = Path::new("/nonexistent-trusty-test-root-5802");
assert!(git_remote_origin(missing).is_none());
assert!(main_worktree_root(missing).is_none());
}
#[test]
#[serial_test::serial]
fn explicit_remote_beats_probed_remote() {
let _guard = EnvGuard::clear();
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(&root).unwrap();
if !init_repo(&root, Some("git@github.com:probed/local.git")) {
eprintln!("skipping explicit_remote_beats_probed_remote: git unavailable");
return;
}
let got = resolve_palace_with_remote(&root, Some("git@github.com:acme/widget.git"))
.expect("resolves");
assert_eq!(got.id, "acme-widget");
assert_eq!(got.source, PalaceSource::GitOwnerRepo);
}
struct EnvGuard(Option<String>);
impl EnvGuard {
fn clear() -> Self {
let prior = std::env::var(crate::palace_id::PALACE_OVERRIDE_ENV).ok();
unsafe { std::env::remove_var(crate::palace_id::PALACE_OVERRIDE_ENV) };
Self(prior)
}
fn set(value: &str) -> Self {
let prior = std::env::var(crate::palace_id::PALACE_OVERRIDE_ENV).ok();
unsafe { std::env::set_var(crate::palace_id::PALACE_OVERRIDE_ENV, value) };
Self(prior)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(v) => std::env::set_var(crate::palace_id::PALACE_OVERRIDE_ENV, v),
None => std::env::remove_var(crate::palace_id::PALACE_OVERRIDE_ENV),
}
}
}
}
#[test]
fn new_stamps_the_current_schema_version() {
let pin = ProjectPin::new("canonical-name");
assert_eq!(pin.schema_version, PIN_SCHEMA_VERSION);
assert_eq!(pin.palace, "canonical-name");
assert_eq!(pin.note, None);
}
#[test]
fn with_note_round_trips_through_yaml() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(tmp.path().join(TRUSTY_TOOLS_DIR)).unwrap();
let pin = ProjectPin::new("canonical-name").with_note(Some("pinned before reorg".to_string()));
fs::write(
tmp.path().join(PIN_FILE_REL),
serde_yaml::to_string(&pin).unwrap(),
)
.unwrap();
let read_back = read_project_pin(tmp.path()).expect("ok").expect("some");
assert_eq!(read_back, pin);
}
#[test]
#[serial_test::serial]
fn a_caller_with_no_git_identity_still_resolves() {
let _env = EnvGuard::clear();
let tmp = tempfile::tempdir().expect("tempdir");
let plain = tmp.path().join("assistant-scratch");
fs::create_dir_all(&plain).unwrap();
let resolved = resolve_palace(&plain).expect("a directory with no git identity must resolve");
assert_eq!(resolved.source, PalaceSource::ParentDir);
assert!(
!resolved.id.is_empty(),
"level 4 must still produce a usable id"
);
}