use super::*;
#[test]
fn launch_and_daemon_agree_on_repos_root_env() {
let _g = crate::core::trusty_tools_config::env_test_lock();
unsafe {
std::env::set_var(REPOS_ROOT_ENV, "/explicit/repos/root");
std::env::remove_var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV);
}
let unified = base_clone_path("owner", "repo");
let cfg = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let gh = trusty_common::github_path::GithubPath {
owner: "owner".into(),
repo: "repo".into(),
};
let pre_fix_launch = crate::core::trusty_tools_config::workspace_subpath(&cfg, &gh);
unsafe { std::env::remove_var(REPOS_ROOT_ENV) };
assert_eq!(
unified,
PathBuf::from("/explicit/repos/root/owner/repo"),
"the unified resolver (used by BOTH entry points) must honour \
TRUSTY_MPM_REPOS_ROOT"
);
assert_ne!(
unified, pre_fix_launch,
"the pre-#1807 tm launch path (workspace_subpath) diverged from the \
daemon root; tm launch must now use base_clone_path instead"
);
}
#[test]
fn ensure_base_clone_migrates_old_layout_dir_aside() {
let src = crate::test_support::hermetic_temp_dir();
let src_path = src.path();
let init = std::process::Command::new("git")
.args(["init", src_path.to_str().expect("src utf8")])
.output()
.expect("git init src");
assert!(init.status.success(), "git init src failed");
for (k, v) in [("user.email", "t@example.com"), ("user.name", "T")] {
let ok = std::process::Command::new("git")
.args(["-C", src_path.to_str().expect("utf8"), "config", k, v])
.status()
.expect("git config");
assert!(ok.success(), "git config {k} failed");
}
std::fs::write(src_path.join("README"), b"src").expect("write README");
let add = std::process::Command::new("git")
.args(["-C", src_path.to_str().expect("utf8"), "add", "."])
.status()
.expect("git add");
assert!(add.success(), "git add failed");
let commit = std::process::Command::new("git")
.args([
"-C",
src_path.to_str().expect("utf8"),
"commit",
"-m",
"init",
])
.status()
.expect("git commit");
assert!(commit.success(), "git commit failed");
let parent = crate::test_support::hermetic_temp_dir();
let base = parent.path().join("owner").join("repo");
let old_session = base.join("00000000-old-session-uuid");
std::fs::create_dir_all(&old_session).expect("create old-layout subdir");
std::fs::write(old_session.join("MARKER"), b"legacy").expect("write marker");
assert!(
!base.join(".git").exists(),
"precondition: no top-level .git"
);
let url = src_path.to_str().expect("src url utf8");
ensure_base_clone(url, &base, None).expect("ensure_base_clone must migrate + clone");
assert!(
base.join(".git").exists(),
"fresh shared-base clone must exist at the base path after migration"
);
let repo_parent = base.parent().expect("base has parent");
let backup = std::fs::read_dir(repo_parent)
.expect("read owner dir")
.filter_map(Result::ok)
.map(|e| e.path())
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.contains(OLD_LAYOUT_BACKUP_SUFFIX))
.unwrap_or(false)
})
.expect("a migrated backup dir must exist");
assert!(
backup
.join("00000000-old-session-uuid")
.join("MARKER")
.exists(),
"the old per-session data must be preserved under the backup dir"
);
}
#[test]
fn migrate_old_layout_aside_ignores_empty_and_git_dirs() {
let empty = crate::test_support::hermetic_temp_dir();
let empty_base = empty.path().join("owner").join("repo");
std::fs::create_dir_all(&empty_base).expect("create empty base");
assert!(
migrate_old_layout_aside(&empty_base)
.expect("empty dir must not error")
.is_none(),
"an empty base dir must not be migrated"
);
assert!(empty_base.is_dir(), "empty base dir must remain in place");
let git = crate::test_support::hermetic_temp_dir();
let git_base = git.path().join("owner").join("repo");
std::fs::create_dir_all(git_base.join(".git")).expect("create .git");
std::fs::write(git_base.join("file"), b"x").expect("write file");
assert!(
migrate_old_layout_aside(&git_base)
.expect("git dir must not error")
.is_none(),
"a dir with a top-level .git must not be migrated"
);
assert!(
git_base.join(".git").is_dir(),
".git dir must remain in place"
);
let absent = git.path().join("does").join("not").join("exist");
assert!(
migrate_old_layout_aside(&absent)
.expect("absent path must not error")
.is_none(),
"an absent path must not be migrated"
);
}
#[test]
fn migrate_old_layout_aside_refuses_a_dir_holding_a_dot_base_store() {
let tmp = crate::test_support::hermetic_temp_dir();
let base = tmp.path().join("owner").join("repo");
let live = base.join(".base").join(".worktrees").join("live");
std::fs::create_dir_all(&live).expect("create legacy worktree");
let marker = live.join("IN-USE.txt");
std::fs::write(&marker, b"live session").expect("write marker");
let err = migrate_old_layout_aside(&base)
.expect_err("a dir holding a .base store must be refused, not migrated");
assert!(
err.contains(".base"),
"the refusal must name the legacy store, got: {err}"
);
assert!(
base.is_dir(),
"the project dir must stay exactly where it is"
);
assert_eq!(
std::fs::read(&marker).expect("the legacy worktree must survive untouched"),
b"live session",
"#4270: an existing .base worktree must not be disturbed, relocated, or deleted"
);
let siblings: Vec<_> = std::fs::read_dir(base.parent().unwrap())
.expect("read owner dir")
.filter_map(|e| e.ok().map(|e| e.file_name()))
.collect();
assert_eq!(
siblings.len(),
1,
"no backup sibling may be created, got {siblings:?}"
);
}
#[test]
fn migrate_old_layout_aside_refuses_a_dir_holding_live_worktrees() {
let tmp = crate::test_support::hermetic_temp_dir();
let base = tmp.path().join("owner").join("repo");
let live = base.join(".worktrees").join("sess-1");
std::fs::create_dir_all(&live).expect("create live worktree");
let marker = live.join("IN-USE.txt");
std::fs::write(&marker, b"live session").expect("write marker");
let err = migrate_old_layout_aside(&base)
.expect_err("a dir holding live worktrees must be refused, not migrated");
assert!(
err.contains(".worktrees"),
"the refusal must name what it found, got: {err}"
);
assert!(
base.is_dir(),
"the project dir must stay exactly where it is"
);
assert_eq!(
std::fs::read(&marker).expect("the live worktree must survive untouched"),
b"live session",
"#4270: a live session worktree must not be disturbed, relocated, or deleted"
);
let siblings: Vec<_> = std::fs::read_dir(base.parent().unwrap())
.expect("read owner dir")
.filter_map(|e| e.ok().map(|e| e.file_name()))
.collect();
assert_eq!(
siblings.len(),
1,
"no backup sibling may be created, got {siblings:?}"
);
}
#[test]
fn try_inproject_spawn_returns_none_for_non_git_path() {
let tmp = std::env::temp_dir();
let result = try_inproject_spawn(&tmp);
assert!(
matches!(result, Ok(None)),
"non-git path should yield Ok(None), got {result:?}"
);
}
#[test]
fn get_origin_url_returns_none_for_non_git() {
let tmp = std::env::temp_dir();
let result = get_origin_url(&tmp);
assert!(
matches!(result, Ok(None)),
"non-git dir must be Ok(None), got {result:?}"
);
}
#[test]
fn get_origin_url_returns_none_for_repo_without_origin() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let init = std::process::Command::new("git")
.args(["init", "-q"])
.arg(tmp.path())
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
let result = get_origin_url(tmp.path());
assert!(
matches!(result, Ok(None)),
"repo without origin must be Ok(None), got {result:?}"
);
}
#[test]
fn get_origin_url_handles_a_multi_valued_origin() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let init = std::process::Command::new("git")
.args(["init", "-q"])
.arg(tmp.path())
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.args(args)
.output()
.expect("spawn git");
assert!(out.status.success(), "git {args:?} failed");
};
git(&[
"remote",
"add",
"origin",
"https://github.com/acme/widget.git",
]);
git(&[
"remote",
"set-url",
"--add",
"origin",
"https://gitlab.com/acme/widget.git",
]);
let result = get_origin_url(tmp.path());
assert!(
matches!(result, Ok(Some(_))),
"a multi-valued origin must still resolve, got {result:?}"
);
}
#[test]
fn get_origin_url_errors_when_the_git_dir_is_unreadable() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let init = std::process::Command::new("git")
.args(["init", "-q"])
.arg(tmp.path())
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
let git_dir = tmp.path().join(".git");
std::fs::set_permissions(
&git_dir,
std::os::unix::fs::PermissionsExt::from_mode(0o000),
)
.expect("chmod 000");
let result = get_origin_url(tmp.path());
std::fs::set_permissions(
&git_dir,
std::os::unix::fs::PermissionsExt::from_mode(0o755),
)
.expect("restore perms");
assert!(
result.is_err(),
"an unreadable .git must be Err — git answers exit 1 here, which is \
indistinguishable from 'no origin remote': {result:?}"
);
}
#[test]
fn get_origin_url_errors_when_discovery_escapes_to_a_parent_repo() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let parent = tmp.path().join("parent");
let child = parent.join("child");
for (dir, origin) in [
(&parent, "https://github.com/PARENT/parentrepo.git"),
(&child, "https://github.com/CHILD/childrepo.git"),
] {
std::fs::create_dir_all(dir).expect("mkdir");
let init = std::process::Command::new("git")
.args(["init", "-q"])
.arg(dir)
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
let remote = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["remote", "add", "origin", origin])
.output()
.expect("git remote add");
assert!(remote.status.success(), "git remote add failed");
}
let child_git = child.join(".git");
std::fs::set_permissions(
&child_git,
std::os::unix::fs::PermissionsExt::from_mode(0o000),
)
.expect("chmod 000");
let result = get_origin_url(&child);
std::fs::set_permissions(
&child_git,
std::os::unix::fs::PermissionsExt::from_mode(0o755),
)
.expect("restore perms");
assert!(
result.is_err(),
"must refuse rather than answer with the PARENT repo's origin: {result:?}"
);
if let Err(msg) = &result {
assert!(
!msg.contains("parentrepo"),
"the error must not hand back the wrong repo's URL: {msg}"
);
}
}
#[test]
fn get_origin_url_reads_a_linked_worktree() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let main = tmp.path().join("main");
std::fs::create_dir_all(&main).expect("mkdir");
let git = |dir: &std::path::Path, args: &[&str]| {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("spawn git");
assert!(out.status.success(), "git {args:?} failed");
};
let init = std::process::Command::new("git")
.args(["init", "-q"])
.arg(&main)
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
git(
&main,
&[
"remote",
"add",
"origin",
"https://github.com/acme/widget.git",
],
);
git(&main, &["config", "user.email", "test@example.com"]);
git(&main, &["config", "user.name", "Test"]);
std::fs::write(main.join("f.txt"), "v1\n").expect("write");
git(&main, &["add", "."]);
git(&main, &["commit", "-q", "-m", "initial"]);
let linked = tmp.path().join("linked");
git(
&main,
&[
"worktree",
"add",
"-q",
"--detach",
linked.to_str().expect("utf8"),
],
);
assert!(
linked.join(".git").is_file(),
"fixture: .git must be a file"
);
let result = get_origin_url(&linked);
assert_eq!(
result.as_ref().map(|o| o.as_deref()),
Ok(Some("https://github.com/acme/widget.git")),
"a linked worktree must resolve its repo's origin, got {result:?}"
);
}
#[test]
fn get_origin_url_errors_when_git_config_is_unreadable() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let init = std::process::Command::new("git")
.args(["init", "-q"])
.arg(tmp.path())
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
std::fs::write(tmp.path().join(".git/config"), "[remote \"origin\"\n")
.expect("write broken config");
let result = get_origin_url(tmp.path());
assert!(
result.is_err(),
"an unreadable git config must be Err, not a silent Ok(None): {result:?}"
);
}
#[test]
fn repos_root_default_ends_with_canonical_segment() {
if std::env::var(REPOS_ROOT_ENV).is_ok()
|| std::env::var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV).is_ok()
{
return;
}
let root = repos_root();
assert!(
root.ends_with(DEFAULT_REPOS_DIR),
"repos_root() default must end with '{DEFAULT_REPOS_DIR}', got {}",
root.display()
);
}
#[test]
fn base_clone_path_resolves_to_canonical_root() {
if std::env::var(REPOS_ROOT_ENV).is_ok()
|| std::env::var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV).is_ok()
{
return;
}
let base = base_clone_path("myorg", "myrepo");
assert!(
base.ends_with("trusty-mpm-projects/myorg/myrepo"),
"base_clone_path must resolve to …/trusty-mpm-projects/myorg/myrepo, got {}",
base.display()
);
}
#[test]
fn worktree_path_for_honours_configured_base() {
let _g = crate::core::trusty_tools_config::env_test_lock();
let base = std::path::Path::new("/tmp/some-checkout");
unsafe { std::env::set_var("TRUSTY_MPM_WORKTREES_DIRNAME", ".sessions") };
let configured = super::worktree_path_for(base, "tm-widget-01");
unsafe { std::env::remove_var("TRUSTY_MPM_WORKTREES_DIRNAME") };
let defaulted = super::worktree_path_for(base, "tm-widget-01");
assert_eq!(
configured,
std::path::PathBuf::from("/tmp/some-checkout/.sessions/tm-widget-01"),
"creation must nest under the configured base"
);
assert!(
!configured.starts_with("/tmp/some-checkout/.worktrees"),
"a configured base must actually replace `.worktrees`, got {}",
configured.display()
);
assert_eq!(
defaulted,
std::path::PathBuf::from("/tmp/some-checkout/.worktrees/tm-widget-01"),
"with nothing configured the default must be unchanged"
);
}
#[test]
fn session_worktree_path_uses_dot_prefix() {
let _g = crate::core::trusty_tools_config::env_test_lock();
let tmp = crate::test_support::hermetic_temp_dir();
let base = tmp.path();
let init = std::process::Command::new("git")
.args(["init", base.to_str().expect("base is utf8")])
.output()
.expect("git init");
assert!(
init.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&init.stderr)
);
for (k, v) in [("user.email", "test@example.com"), ("user.name", "Test")] {
let ok = std::process::Command::new("git")
.args(["-C", base.to_str().expect("base is utf8"), "config", k, v])
.status()
.expect("git config");
assert!(ok.success(), "git config {k} failed");
}
std::fs::write(base.join("README"), b"init").expect("write README");
let add = std::process::Command::new("git")
.args(["-C", base.to_str().expect("base is utf8"), "add", "."])
.status()
.expect("git add");
assert!(add.success(), "git add failed");
let commit = std::process::Command::new("git")
.args([
"-C",
base.to_str().expect("base is utf8"),
"commit",
"-m",
"init",
])
.status()
.expect("git commit");
assert!(commit.success(), "git commit failed");
let name = "tm-test-repo-01";
let worktree_path = create_session_worktree_unchecked(
base,
name,
&crate::session_manager::ManagedSessionId::new(),
)
.expect("create_session_worktree must succeed on a real git repo");
let expected_parent = base.join(".worktrees");
assert!(
worktree_path.starts_with(&expected_parent),
"worktree must be under <base>/.worktrees/, got {}",
worktree_path.display()
);
assert!(
worktree_path.ends_with(name),
"worktree path must end with worktree name {name}, got {}",
worktree_path.display()
);
assert!(
worktree_path.is_dir(),
"worktree directory must exist on disk, got {}",
worktree_path.display()
);
}
#[test]
fn worktree_name_collides_detects_existing_dir_and_branch() {
let tmp = crate::test_support::hermetic_temp_dir();
let base = tmp.path();
let init = std::process::Command::new("git")
.args(["init", base.to_str().expect("base is utf8")])
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
for (k, v) in [("user.email", "test@example.com"), ("user.name", "Test")] {
let ok = std::process::Command::new("git")
.args(["-C", base.to_str().expect("utf8"), "config", k, v])
.status()
.expect("git config");
assert!(ok.success(), "git config {k} failed");
}
std::fs::write(base.join("README"), b"init").expect("write README");
let add = std::process::Command::new("git")
.args(["-C", base.to_str().expect("utf8"), "add", "."])
.status()
.expect("git add");
assert!(add.success(), "git add failed");
let commit = std::process::Command::new("git")
.args(["-C", base.to_str().expect("utf8"), "commit", "-m", "init"])
.status()
.expect("git commit");
assert!(commit.success(), "git commit failed");
assert!(
!worktree_name_collides(base, "tm-fresh-01"),
"an unused name must not collide"
);
create_session_worktree_unchecked(
base,
"tm-fresh-01",
&crate::session_manager::ManagedSessionId::new(),
)
.expect("create_session_worktree");
assert!(
worktree_name_collides(base, "tm-fresh-01"),
"an in-use name (dir + branch both exist) must collide"
);
let worktree_dir = base.join(".worktrees").join("tm-fresh-01");
if worktree_dir.exists() {
std::fs::remove_dir_all(&worktree_dir).expect("remove the worktree directory");
}
let _ = std::process::Command::new("git")
.arg("-C")
.arg(base)
.args(["worktree", "prune"])
.output();
assert!(
!worktree_dir.exists(),
"worktree dir must be gone before the branch-only check"
);
assert!(
worktree_name_collides(base, "tm-fresh-01"),
"a name whose branch still exists must still collide even after the \
worktree dir was removed"
);
}
#[test]
fn create_session_worktree_rejects_existing_worktree_dir() {
let tmp = crate::test_support::hermetic_temp_dir();
let base = tmp.path();
let init = std::process::Command::new("git")
.args(["init", base.to_str().expect("base is utf8")])
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
for (k, v) in [("user.email", "test@example.com"), ("user.name", "Test")] {
let ok = std::process::Command::new("git")
.args(["-C", base.to_str().expect("utf8"), "config", k, v])
.status()
.expect("git config");
assert!(ok.success(), "git config {k} failed");
}
std::fs::write(base.join("README"), b"init").expect("write README");
let add = std::process::Command::new("git")
.args(["-C", base.to_str().expect("utf8"), "add", "."])
.status()
.expect("git add");
assert!(add.success(), "git add failed");
let commit = std::process::Command::new("git")
.args(["-C", base.to_str().expect("utf8"), "commit", "-m", "init"])
.status()
.expect("git commit");
assert!(commit.success(), "git commit failed");
let first = create_session_worktree_unchecked(
base,
"tm-dup-01",
&crate::session_manager::ManagedSessionId::new(),
)
.expect("first create must succeed");
assert!(first.is_dir(), "first worktree must exist");
let second = create_session_worktree_unchecked(
base,
"tm-dup-01",
&crate::session_manager::ManagedSessionId::new(),
);
assert!(
second.is_err(),
"creating a worktree for an already-existing name must error, not clobber"
);
assert!(
first.is_dir(),
"the original worktree must remain untouched after the rejected re-create"
);
}
#[test]
fn ensure_worktrees_gitignored_idempotent() {
let _env = crate::core::trusty_tools_config::env_test_lock();
let tmp = crate::test_support::hermetic_temp_dir();
let base = tmp.path();
std::fs::create_dir_all(base.join(".git")).expect("create .git");
ensure_worktrees_gitignored(base).expect("first call must succeed");
let exclude = base.join(".git").join("info").join("exclude");
let content1 = std::fs::read_to_string(&exclude).expect("exclude readable");
let count1 = content1
.lines()
.filter(|l| l.trim() == ".worktrees/")
.count();
assert_eq!(
count1, 1,
"must contain exactly one .worktrees/ entry after first call"
);
ensure_worktrees_gitignored(base).expect("second call must succeed (idempotent)");
let content2 = std::fs::read_to_string(&exclude).expect("exclude readable");
let count2 = content2
.lines()
.filter(|l| l.trim() == ".worktrees/")
.count();
assert_eq!(
count2, 1,
"second call must not add a duplicate .worktrees/ entry"
);
}
#[tokio::test]
async fn ensure_base_clone_emits_cloning_repo_only_on_fresh_clone() {
use crate::core::provisioning_stage::{ProvisioningStage, StageEmitter, scoped};
let src = crate::test_support::hermetic_temp_dir();
let src_path = src.path();
let init = std::process::Command::new("git")
.args(["init", src_path.to_str().expect("src utf8")])
.output()
.expect("git init src");
assert!(init.status.success(), "git init src failed");
for (k, v) in [("user.email", "t@example.com"), ("user.name", "T")] {
let ok = std::process::Command::new("git")
.args(["-C", src_path.to_str().expect("utf8"), "config", k, v])
.status()
.expect("git config");
assert!(ok.success(), "git config {k} failed");
}
std::fs::write(src_path.join("README"), b"src").expect("write README");
let add = std::process::Command::new("git")
.args(["-C", src_path.to_str().expect("utf8"), "add", "."])
.status()
.expect("git add");
assert!(add.success(), "git add failed");
let commit = std::process::Command::new("git")
.args([
"-C",
src_path.to_str().expect("utf8"),
"commit",
"-m",
"init",
])
.status()
.expect("git commit");
assert!(commit.success(), "git commit failed");
let parent = crate::test_support::hermetic_temp_dir();
let base = parent.path().join("owner").join("repo");
let url = src_path.to_str().expect("src url utf8").to_string();
let (tx1, mut rx1) = tokio::sync::broadcast::channel(8);
let emitter1 = StageEmitter::new("s-1", "https://example.com/owner/repo", tx1);
let base_for_clone = base.clone();
let url_for_clone = url.clone();
scoped(emitter1, async move {
ensure_base_clone(&url_for_clone, &base_for_clone, None)
.expect("first ensure_base_clone must succeed (fresh clone)");
})
.await;
let mut stages1 = Vec::new();
while let Ok(value) = rx1.try_recv() {
stages1.push(value["stage"].as_str().unwrap().to_string());
}
assert_eq!(
stages1,
vec![ProvisioningStage::CloningRepo.wire_name()],
"a fresh base clone must emit exactly one CloningRepo stage event"
);
let (tx2, mut rx2) = tokio::sync::broadcast::channel(8);
let emitter2 = StageEmitter::new("s-2", "https://example.com/owner/repo", tx2);
scoped(emitter2, async move {
ensure_base_clone(&url, &base, None)
.expect("second ensure_base_clone must succeed (reuse)");
})
.await;
let mut stages2 = Vec::new();
while let Ok(value) = rx2.try_recv() {
stages2.push(value["stage"].as_str().unwrap().to_string());
}
assert!(
stages2.is_empty(),
"reusing an existing base clone must NOT re-emit CloningRepo, got: {stages2:?}"
);
}
#[test]
fn create_session_worktree_sets_pull_upstream_and_worktree_scoped_push() {
let origin_dir = crate::test_support::hermetic_temp_dir();
let origin_path = origin_dir.path();
let init = std::process::Command::new("git")
.args([
"init",
"--bare",
"-b",
"main",
origin_path.to_str().expect("origin utf8"),
])
.output()
.expect("git init --bare origin");
assert!(
init.status.success(),
"git init --bare origin failed: {}",
String::from_utf8_lossy(&init.stderr)
);
let seed = crate::test_support::hermetic_temp_dir();
let seed_path = seed.path();
let clone_seed = std::process::Command::new("git")
.args([
"clone",
origin_path.to_str().expect("origin utf8"),
seed_path.to_str().expect("seed utf8"),
])
.output()
.expect("git clone seed");
assert!(
clone_seed.status.success(),
"git clone seed failed: {}",
String::from_utf8_lossy(&clone_seed.stderr)
);
for (k, v) in [("user.email", "t@example.com"), ("user.name", "T")] {
let ok = std::process::Command::new("git")
.args(["-C", seed_path.to_str().expect("seed utf8"), "config", k, v])
.status()
.expect("git config");
assert!(ok.success(), "git config {k} failed");
}
std::fs::write(seed_path.join("README"), b"seed").expect("write README");
let add = std::process::Command::new("git")
.args(["-C", seed_path.to_str().expect("seed utf8"), "add", "."])
.status()
.expect("git add");
assert!(add.success(), "git add failed");
let commit = std::process::Command::new("git")
.args([
"-C",
seed_path.to_str().expect("seed utf8"),
"commit",
"-m",
"init",
])
.status()
.expect("git commit");
assert!(commit.success(), "git commit failed");
let push_seed = std::process::Command::new("git")
.args([
"-C",
seed_path.to_str().expect("seed utf8"),
"push",
"origin",
"main",
])
.status()
.expect("git push seed");
assert!(push_seed.success(), "git push seed failed");
let base_parent = crate::test_support::hermetic_temp_dir();
let base = base_parent.path().join("base");
let clone_base = std::process::Command::new("git")
.args([
"clone",
origin_path.to_str().expect("origin utf8"),
base.to_str().expect("base utf8"),
])
.output()
.expect("git clone base");
assert!(
clone_base.status.success(),
"git clone base failed: {}",
String::from_utf8_lossy(&clone_base.stderr)
);
for (k, v) in [("user.email", "t@example.com"), ("user.name", "T")] {
let ok = std::process::Command::new("git")
.args(["-C", base.to_str().expect("base utf8"), "config", k, v])
.status()
.expect("git config");
assert!(ok.success(), "git config {k} failed");
}
let worktree_path = create_session_worktree_unchecked(
&base,
"tm-pull-01",
&crate::session_manager::ManagedSessionId::new(),
)
.expect("create_session_worktree must succeed");
let upstream = std::process::Command::new("git")
.arg("-C")
.arg(&worktree_path)
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
.output()
.expect("git rev-parse @{u}");
assert!(
upstream.status.success(),
"worktree branch must have an upstream set (git pull must work): {}",
String::from_utf8_lossy(&upstream.stderr)
);
assert_eq!(
String::from_utf8_lossy(&upstream.stdout).trim(),
"origin/main",
"worktree branch upstream must track origin/main"
);
let push_default = std::process::Command::new("git")
.arg("-C")
.arg(&worktree_path)
.args(["config", "--worktree", "push.default"])
.output()
.expect("git config --worktree push.default");
assert!(
push_default.status.success(),
"worktree-scoped push.default must be set: {}",
String::from_utf8_lossy(&push_default.stderr)
);
assert_eq!(
String::from_utf8_lossy(&push_default.stdout).trim(),
"current",
"push.default must be worktree-scoped to 'current' so push never targets main"
);
let ext = std::process::Command::new("git")
.arg("-C")
.arg(&base)
.args(["config", "extensions.worktreeConfig"])
.output()
.expect("git config extensions.worktreeConfig");
assert!(
ext.status.success(),
"extensions.worktreeConfig must be set on the base clone"
);
assert_eq!(
String::from_utf8_lossy(&ext.stdout).trim(),
"true",
"extensions.worktreeConfig must be true"
);
let effective = std::process::Command::new("git")
.arg("-C")
.arg(&worktree_path)
.args(["config", "--get", "push.default"])
.output()
.expect("git config --get push.default");
assert_eq!(
String::from_utf8_lossy(&effective.stdout).trim(),
"current",
"the EFFECTIVE push.default (not just the --worktree key) must be `current` \
whenever a foreign upstream is set (#2867)"
);
}
#[test]
fn push_pin_detection_matches_effective_config() {
let repo_dir = crate::test_support::hermetic_temp_dir();
let repo = repo_dir.path().to_path_buf();
let git = |args: &[&str], cwd: &std::path::Path| -> bool {
std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !std::process::Command::new("git")
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return; }
assert!(git(&["config", "user.email", "t@example.com"], &repo));
assert!(git(&["config", "user.name", "T"], &repo));
std::fs::write(repo.join("README"), b"seed").expect("write README");
assert!(git(&["add", "."], &repo));
assert!(git(&["commit", "-qm", "init"], &repo));
assert!(
!super::push_is_pinned_to_current(&repo),
"an unset push.default must NOT be reported as pinned"
);
assert!(git(&["config", "push.default", "simple"], &repo));
assert!(
!super::push_is_pinned_to_current(&repo),
"push.default=simple must NOT be reported as pinned"
);
let wt_parent = crate::test_support::hermetic_temp_dir();
let pinned = wt_parent.path().join("pinned");
let unpinned = wt_parent.path().join("unpinned");
assert!(git(
&[
"worktree",
"add",
"-q",
"-b",
"wt-a",
pinned.to_str().unwrap()
],
&repo
));
assert!(git(
&[
"worktree",
"add",
"-q",
"-b",
"wt-b",
unpinned.to_str().unwrap()
],
&repo
));
assert!(git(&["config", "extensions.worktreeConfig", "true"], &repo));
assert!(git(
&["config", "--worktree", "push.default", "current"],
&pinned
));
assert!(
super::push_is_pinned_to_current(&pinned),
"a worktree-scoped push.default=current must be reported as pinned"
);
assert!(
!super::push_is_pinned_to_current(&unpinned),
"a sibling worktree without the pin must NOT be reported as pinned — \
the detector must read worktree-scoped config, not the shared repo config"
);
}
fn g(cwd: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.output()
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
assert!(
out.status.success(),
"git {args:?} in {} failed: {}",
cwd.display(),
String::from_utf8_lossy(&out.stderr)
);
}
fn g_out(cwd: &std::path::Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(args)
.output()
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
assert!(
out.status.success(),
"git {args:?} in {} failed: {}",
cwd.display(),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn commit(cwd: &std::path::Path, name: &str) -> String {
std::fs::write(cwd.join(name), name.as_bytes()).expect("write file");
g(cwd, &["add", "."]);
g(cwd, &["commit", "-q", "-m", name]);
g_out(cwd, &["rev-parse", "HEAD"])
}
struct StaleBase {
base: std::path::PathBuf,
origin: std::path::PathBuf,
sha_a: String,
sha_b: String,
_guards: Vec<tempfile::TempDir>,
}
fn stale_base_fixture() -> StaleBase {
let origin_dir = crate::test_support::hermetic_temp_dir();
let origin = origin_dir.path().to_path_buf();
let init = std::process::Command::new("git")
.args(["init", "--bare", "-q", "-b", "main"])
.arg(&origin)
.output()
.expect("git init --bare");
assert!(
init.status.success(),
"git init --bare failed: {}",
String::from_utf8_lossy(&init.stderr)
);
let seed_parent = crate::test_support::hermetic_temp_dir();
let seed = seed_parent.path().join("seed");
let clone = std::process::Command::new("git")
.args(["clone", "-q"])
.arg(&origin)
.arg(&seed)
.output()
.expect("git clone seed");
assert!(
clone.status.success(),
"git clone seed failed: {}",
String::from_utf8_lossy(&clone.stderr)
);
g(&seed, &["config", "user.email", "t@example.com"]);
g(&seed, &["config", "user.name", "T"]);
let sha_a = commit(&seed, "A");
g(&seed, &["push", "-q", "origin", "main"]);
let base_parent = crate::test_support::hermetic_temp_dir();
let base = base_parent.path().join("base");
let clone_base = std::process::Command::new("git")
.args(["clone", "-q"])
.arg(&origin)
.arg(&base)
.output()
.expect("git clone base");
assert!(
clone_base.status.success(),
"git clone base failed: {}",
String::from_utf8_lossy(&clone_base.stderr)
);
g(&base, &["config", "user.email", "t@example.com"]);
g(&base, &["config", "user.name", "T"]);
let sha_b = commit(&seed, "B");
g(&seed, &["push", "-q", "origin", "main"]);
assert_eq!(
g_out(&base, &["rev-parse", "HEAD"]),
sha_a,
"fixture precondition: the base clone's local main must be STALE (at A)"
);
assert_ne!(sha_a, sha_b, "fixture precondition: A and B must differ");
StaleBase {
base,
origin,
sha_a,
sha_b,
_guards: vec![origin_dir, seed_parent, base_parent],
}
}
#[test]
fn session_worktree_branches_from_fetched_origin_not_stale_local_main() {
let fx = stale_base_fixture();
let worktree = create_session_worktree_unchecked(
&fx.base,
"tm-stale-4957",
&crate::session_manager::ManagedSessionId::new(),
)
.expect("create_session_worktree must succeed against a real base clone");
let head = g_out(&worktree, &["rev-parse", "HEAD"]);
assert_ne!(
head, fx.sha_a,
"the session worktree was cut from the STALE local main ({}) — #4957",
fx.sha_a
);
assert_eq!(
head, fx.sha_b,
"the session worktree must start at the freshly-fetched origin/main tip"
);
}
#[test]
fn session_worktree_falls_back_to_remote_tracking_ref_when_fetch_fails() {
let fx = stale_base_fixture();
let sha_c = commit(&fx.base, "C");
std::fs::remove_dir_all(&fx.origin).expect("remove origin to break the fetch");
let resolved = super::super::inproject_start_point::resolve(&fx.base).expect("resolves");
assert_eq!(
resolved.git_ref(),
Some("origin/main"),
"a failed fetch must still hand back the last-known remote-tracking ref"
);
let warning = resolved
.warning()
.expect("a failed fetch must warn — it must never read as a clean success");
assert!(
warning.contains("git fetch origin main failed"),
"the warning must name the failure: {warning}"
);
let worktree = create_session_worktree_unchecked(
&fx.base,
"tm-offline-4957",
&crate::session_manager::ManagedSessionId::new(),
)
.expect("an unreachable remote must not fail worktree creation");
let head = g_out(&worktree, &["rev-parse", "HEAD"]);
assert_ne!(
head, sha_c,
"an offline spawn must not silently fall back to the base checkout's local HEAD"
);
assert_eq!(
head, fx.sha_a,
"an offline spawn must start at the last fetched origin/main"
);
}
#[test]
fn session_worktree_without_a_remote_still_branches_from_head() {
let dir = crate::test_support::hermetic_temp_dir();
let repo = dir.path().join("repo");
std::fs::create_dir_all(&repo).expect("mkdir repo");
let init = std::process::Command::new("git")
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.output()
.expect("git init");
assert!(
init.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&init.stderr)
);
g(&repo, &["config", "user.email", "t@example.com"]);
g(&repo, &["config", "user.name", "T"]);
let sha = commit(&repo, "only");
let resolved = super::super::inproject_start_point::resolve(&repo).expect("resolves");
assert_eq!(
resolved.git_ref(),
Some("refs/heads/main"),
"a remote-less repo branches from its local default branch"
);
assert_eq!(
resolved.warning(),
None,
"a remote-less repo is not a degradation and must not warn"
);
let worktree = create_session_worktree_unchecked(
&repo,
"tm-local-4957",
&crate::session_manager::ManagedSessionId::new(),
)
.expect("a repo with no remote must still get a worktree");
assert_eq!(
g_out(&worktree, &["rev-parse", "HEAD"]),
sha,
"a remote-less repo's worktree must start at the local HEAD commit"
);
}
#[test]
fn session_worktree_in_a_local_only_repo_branches_from_its_default_branch() {
let dir = crate::test_support::hermetic_temp_dir();
let repo = dir.path().join("repo");
std::fs::create_dir_all(&repo).expect("mkdir repo");
g(&repo, &["init", "-q", "-b", "main"]);
g(&repo, &["config", "user.email", "t@example.com"]);
g(&repo, &["config", "user.name", "T"]);
let main_sha = commit(&repo, "on-main");
g(&repo, &["checkout", "-q", "-b", "feature-8934"]);
let feature_sha = commit(&repo, "on-feature");
let worktree = create_session_worktree_unchecked(
&repo,
"tm-local-8934",
&crate::session_manager::ManagedSessionId::new(),
)
.expect("a local-only repo with a main branch gets a worktree");
let head = g_out(&worktree, &["rev-parse", "HEAD"]);
assert_ne!(head, feature_sha, "cut from the checked-out feature branch");
assert_eq!(head, main_sha, "must start at the local default branch");
g(&repo, &["branch", "-q", "-m", "main", "zz-8934-other"]);
let err = super::super::inproject_start_point::resolve(&repo)
.expect_err("no default branch must refuse");
assert!(err.contains("no local default branch"), "{err}");
}
#[test]
fn worktrees_exclude_entry_protects_against_double_force_clean() {
let _env = crate::core::trusty_tools_config::env_test_lock();
let scratch = crate::test_support::hermetic_temp_dir();
let origin = scratch.path().join("origin");
std::fs::create_dir_all(&origin).expect("origin dir");
for args in [
vec!["init", "-q"],
vec!["config", "user.email", "test@example.com"],
vec!["config", "user.name", "Test"],
] {
let Ok(out) = std::process::Command::new("git")
.args(&args)
.current_dir(&origin)
.output()
else {
eprintln!("worktrees_exclude_entry_protects_against_double_force_clean: no git");
return;
};
assert!(out.status.success(), "git {args:?} failed");
}
std::fs::write(origin.join("README"), b"seed").expect("seed file");
for args in [vec!["add", "."], vec!["commit", "-q", "-m", "seed"]] {
let out = std::process::Command::new("git")
.args(&args)
.current_dir(&origin)
.output()
.expect("git");
assert!(out.status.success(), "git {args:?} failed");
}
let base = scratch.path().join("owner").join("repo");
let url = format!("file://{}", origin.display());
ensure_base_clone(&url, &base, None).expect("ensure_base_clone must succeed");
let worktree = create_session_worktree_unchecked(
&base,
"sess-1",
&crate::session_manager::ManagedSessionId::new(),
)
.expect("create_session_worktree must succeed");
let wip = worktree.join("WIP.txt");
std::fs::write(&wip, b"uncommitted session work").expect("write WIP");
let exclude = std::fs::read_to_string(base.join(".git").join("info").join("exclude"))
.expect("the base clone must have a .git/info/exclude");
let want = format!(
"{}/",
crate::session_manager::decommission::worktrees_dirname()
);
assert!(
exclude.lines().any(|l| l.trim() == want),
"ensure_base_clone must exclude {want}, got: {exclude}"
);
let out = std::process::Command::new("git")
.arg("-C")
.arg(&base)
.args(["clean", "-ffd"])
.output()
.expect("git clean -ffd");
let said = String::from_utf8_lossy(&out.stdout);
assert!(
wip.exists(),
"#4270: `clean -ffd` in the base DELETED a session worktree's \
uncommitted work — git said: {said}"
);
assert!(
!said.contains(&want),
"`clean -ffd` must not touch the worktrees directory at all, got: {said}"
);
}
#[test]
fn ensure_base_clone_with_no_account_is_the_pre_7166_shape() {
let src = crate::test_support::hermetic_temp_dir();
let src_path = src.path();
assert!(
std::process::Command::new("git")
.args(["init", src_path.to_str().expect("src utf8")])
.status()
.expect("git init src")
.success(),
"git init src failed"
);
for (k, v) in [("user.email", "t@example.com"), ("user.name", "T")] {
assert!(
std::process::Command::new("git")
.args(["-C", src_path.to_str().expect("utf8"), "config", k, v])
.status()
.expect("git config")
.success(),
"git config {k} failed"
);
}
std::fs::write(src_path.join("README"), b"src").expect("write README");
assert!(
std::process::Command::new("git")
.args(["-C", src_path.to_str().expect("utf8"), "add", "."])
.status()
.expect("git add")
.success(),
"git add failed"
);
assert!(
std::process::Command::new("git")
.args([
"-C",
src_path.to_str().expect("utf8"),
"commit",
"-m",
"init",
])
.status()
.expect("git commit")
.success(),
"git commit failed"
);
let parent = crate::test_support::hermetic_temp_dir();
let base = parent.path().join("owner").join("repo");
ensure_base_clone(src_path.to_str().expect("utf8"), &base, None)
.expect("an unselected account must not change ambient clone behaviour");
assert!(base.join(".git").exists());
}