use std::path::{Path, PathBuf};
use std::process::Command;
use super::{
AutoInitOutcome, AutoInitPlan, AutoInitRefusal, RepoContext, classify_rev_parse_failure,
ensure_git_repo_with, initialized_message, missing_git_error, plan_auto_init, refusal_message,
stderr_means_no_repository,
};
use trusty_mpm::core::child_repo_scan::{ChildRepoScan, ScanIncomplete};
use crate::test_support::hermetic_temp_dir;
const ABSENT_GIT: &str = "tm-test-no-such-git-executable-6274";
fn git_ok(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git must be installed to run these tests");
assert!(
out.status.success(),
"git {args:?} failed in {}: {}",
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
}
fn toplevel(dir: &Path) -> Option<PathBuf> {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()
.filter(|o| o.status.success())?;
Some(PathBuf::from(
String::from_utf8_lossy(&out.stdout).trim().to_owned(),
))
}
#[test]
fn plan_initializes_a_plain_directory() {
let plan = plan_auto_init(
Path::new("/Users/someone/scratch/notes"),
Some(Path::new("/Users/someone")),
RepoContext::Absent,
|| ChildRepoScan::Clear,
);
assert_eq!(plan, AutoInitPlan::Init);
}
#[test]
fn plan_refuses_the_home_directory() {
let home = Path::new("/Users/someone");
assert_eq!(
plan_auto_init(home, Some(home), RepoContext::Absent, || {
ChildRepoScan::Clear
}),
AutoInitPlan::Refuse(AutoInitRefusal::HomeDirectory)
);
}
#[test]
fn plan_refuses_the_filesystem_root() {
assert_eq!(
plan_auto_init(
Path::new("/"),
Some(Path::new("/Users/someone")),
RepoContext::Absent,
|| ChildRepoScan::Clear
),
AutoInitPlan::Refuse(AutoInitRefusal::FilesystemRoot)
);
}
#[test]
fn plan_leaves_an_existing_repo_alone_even_in_the_home_directory() {
let home = Path::new("/Users/someone");
assert_eq!(
plan_auto_init(home, Some(home), RepoContext::Present, || {
ChildRepoScan::Clear
}),
AutoInitPlan::AlreadyGit
);
}
#[test]
fn plan_initializes_when_the_home_directory_is_unknown() {
assert_eq!(
plan_auto_init(Path::new("/srv/project"), None, RepoContext::Absent, || {
ChildRepoScan::Clear
}),
AutoInitPlan::Init
);
}
#[test]
fn plan_refuses_a_workspace_parent() {
let child = PathBuf::from("/Users/someone/workspace/packages/api");
let plan = plan_auto_init(
Path::new("/Users/someone/workspace"),
Some(Path::new("/Users/someone")),
RepoContext::Absent,
|| ChildRepoScan::Found(child.clone()),
);
assert_eq!(
plan,
AutoInitPlan::Refuse(AutoInitRefusal::WorkspaceParent(child))
);
}
#[test]
fn plan_refuses_an_unfinished_scan() {
let stop = ScanIncomplete::Unreadable {
path: PathBuf::from("/Users/someone/workspace/locked"),
error: "Permission denied (os error 13)".to_string(),
};
let plan = plan_auto_init(
Path::new("/Users/someone/workspace"),
Some(Path::new("/Users/someone")),
RepoContext::Absent,
|| ChildRepoScan::Incomplete(stop.clone()),
);
assert_eq!(
plan,
AutoInitPlan::Refuse(AutoInitRefusal::ScanIncomplete(stop))
);
}
#[test]
fn plan_does_not_scan_when_an_earlier_guard_already_decided() {
fn never() -> ChildRepoScan {
panic!("the downward scan must not run once an earlier guard decided");
}
let home = Path::new("/Users/someone");
assert_eq!(
plan_auto_init(home, Some(home), RepoContext::Present, never),
AutoInitPlan::AlreadyGit
);
assert_eq!(
plan_auto_init(Path::new("/"), None, RepoContext::Absent, never),
AutoInitPlan::Refuse(AutoInitRefusal::FilesystemRoot)
);
assert_eq!(
plan_auto_init(home, Some(home), RepoContext::Absent, never),
AutoInitPlan::Refuse(AutoInitRefusal::HomeDirectory)
);
}
#[test]
fn no_repo_stderr_matches_the_long_phrase() {
assert!(stderr_means_no_repository(
"fatal: not a git repository (or any of the parent directories): .git"
));
}
#[test]
fn no_repo_stderr_rejects_the_stale_worktree_pointer() {
assert!(!stderr_means_no_repository(
"fatal: not a git repository: (null)"
));
}
#[test]
fn no_repo_stderr_rejects_an_unrelated_failure() {
assert!(!stderr_means_no_repository(
"fatal: detected dubious ownership in repository at '/srv/project'"
));
}
#[test]
fn initialized_message_names_the_directory() {
let msg = initialized_message(Path::new("/srv/project"));
assert_eq!(msg, "tm: initialized git in /srv/project");
}
#[test]
fn refusal_message_names_the_home_directory() {
let msg = refusal_message(&AutoInitRefusal::HomeDirectory, Path::new("/Users/someone"));
assert!(msg.contains("/Users/someone"), "{msg}");
assert!(msg.contains("home directory"), "{msg}");
}
#[test]
fn refusal_message_names_the_filesystem_root() {
let msg = refusal_message(&AutoInitRefusal::FilesystemRoot, Path::new("/"));
assert!(msg.contains("filesystem root"), "{msg}");
}
#[test]
fn refusal_message_names_the_child_repository() {
let msg = refusal_message(
&AutoInitRefusal::WorkspaceParent(PathBuf::from("/Users/someone/ws/api")),
Path::new("/Users/someone/ws"),
);
assert!(msg.contains("/Users/someone/ws/api"), "{msg}");
assert!(msg.contains("ancestor"), "{msg}");
assert!(
msg.contains("run `git init` there yourself and retry"),
"{msg}"
);
assert!(!msg.contains("Run tm from a project directory."), "{msg}");
}
#[test]
fn refusal_message_says_what_stopped_the_scan() {
let msg = refusal_message(
&AutoInitRefusal::ScanIncomplete(ScanIncomplete::BudgetExhausted),
Path::new("/Users/someone/ws"),
);
assert!(msg.contains("could not rule out git repositories"), "{msg}");
assert!(msg.contains("the scan stopped after checking"), "{msg}");
assert!(
msg.contains(
"If /Users/someone/ws is a single project, run `git init` there yourself and \
retry; otherwise run tm from the actual project directory."
),
"{msg}"
);
assert!(!msg.contains("Run tm from a project directory."), "{msg}");
}
#[test]
fn refusal_message_keeps_the_site_trailer_for_home_and_root() {
for (refusal, dir) in [
(AutoInitRefusal::HomeDirectory, "/Users/someone"),
(AutoInitRefusal::FilesystemRoot, "/"),
] {
let msg = refusal_message(&refusal, Path::new(dir));
assert!(msg.ends_with("Run tm from a project directory."), "{msg}");
assert!(!msg.contains("git init"), "{msg}");
}
}
#[test]
fn missing_git_error_names_git_and_the_directory() {
let msg = missing_git_error(Path::new("/srv/project")).to_string();
assert!(msg.contains("`git` was not found on PATH"), "{msg}");
assert!(msg.contains("/srv/project"), "{msg}");
}
#[test]
fn auto_init_initializes_a_plain_directory() {
let tmp = hermetic_temp_dir();
let dir = tmp.path().join("fresh-project");
std::fs::create_dir(&dir).unwrap();
let outcome = ensure_git_repo_with(&dir, Some(tmp.path()), "git").unwrap();
assert_eq!(outcome, AutoInitOutcome::Initialized);
assert!(dir.join(".git").is_dir(), "a .git directory must exist now");
assert_eq!(
toplevel(&dir).map(|p| p.canonicalize().unwrap()),
Some(dir.canonicalize().unwrap()),
"the repository root must be the invocation directory, not a parent"
);
}
#[test]
fn non_git_directory_now_classifies_as_a_usable_project() {
use crate::commands::guided::{CwdProject, classify_cwd_project};
let tmp = hermetic_temp_dir();
let dir = tmp.path().join("plain");
std::fs::create_dir(&dir).unwrap();
assert!(
matches!(classify_cwd_project(&dir), CwdProject::NotGit),
"precondition: this is the directory tm used to refuse"
);
ensure_git_repo_with(&dir, Some(tmp.path()), "git").unwrap();
assert!(
matches!(classify_cwd_project(&dir), CwdProject::Usable(_)),
"after auto-init tm must see an ordinary project here"
);
assert_eq!(
trusty_mpm::daemon::managed_routes::inproject::get_origin_url(&dir).unwrap(),
None,
"a freshly initialized repo has no origin — the no-remote flow takes over"
);
}
#[test]
fn auto_init_leaves_a_repo_root_alone() {
let tmp = hermetic_temp_dir();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git_ok(&repo, &["init"]);
let outcome = ensure_git_repo_with(&repo, Some(tmp.path()), "git").unwrap();
assert_eq!(outcome, AutoInitOutcome::AlreadyGit);
}
#[test]
fn auto_init_does_not_init_inside_another_work_tree() {
let tmp = hermetic_temp_dir();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git_ok(&repo, &["init"]);
let nested = repo.join("untracked-notes");
std::fs::create_dir(&nested).unwrap();
let outcome = ensure_git_repo_with(&nested, Some(tmp.path()), "git").unwrap();
assert_eq!(outcome, AutoInitOutcome::AlreadyGit);
assert!(
!nested.join(".git").exists(),
"a nested repository must not be created inside another work tree"
);
}
#[test]
fn auto_init_leaves_a_bare_repository_alone() {
let tmp = hermetic_temp_dir();
let bare = tmp.path().join("origin.git");
std::fs::create_dir(&bare).unwrap();
git_ok(&bare, &["init", "--bare"]);
let outcome = ensure_git_repo_with(&bare, Some(tmp.path()), "git").unwrap();
assert_eq!(outcome, AutoInitOutcome::AlreadyGit);
}
#[test]
fn auto_init_refuses_the_home_directory() {
let tmp = hermetic_temp_dir();
let home = tmp.path().join("home");
std::fs::create_dir(&home).unwrap();
let outcome = ensure_git_repo_with(&home, Some(&home), "git").unwrap();
assert_eq!(
outcome,
AutoInitOutcome::Refused(AutoInitRefusal::HomeDirectory)
);
assert!(
!home.join(".git").exists(),
"the home directory must not become a repository"
);
}
#[test]
fn ensure_reports_a_missing_git_executable_without_writing() {
let tmp = hermetic_temp_dir();
let dir = tmp.path().join("plain");
std::fs::create_dir(&dir).unwrap();
let err = ensure_git_repo_with(&dir, Some(tmp.path()), ABSENT_GIT)
.expect_err("a missing git executable must be an error, not a silent skip");
assert!(
err.to_string().contains("`git` was not found on PATH"),
"{err}"
);
assert!(
!dir.join(".git").exists(),
"nothing may be written when git is unavailable"
);
}
#[test]
fn auto_init_refuses_a_workspace_parent_with_a_deep_child_repository() {
let tmp = hermetic_temp_dir();
let ws = tmp.path().join("workspace");
let child = ws.join("packages").join("api");
std::fs::create_dir_all(&child).unwrap();
git_ok(&child, &["init"]);
let outcome = ensure_git_repo_with(&ws, Some(tmp.path()), "git").unwrap();
assert_eq!(
outcome,
AutoInitOutcome::Refused(AutoInitRefusal::WorkspaceParent(child))
);
assert!(
!ws.join(".git").exists(),
"a workspace parent must not become a repository"
);
}
#[cfg(unix)]
#[test]
fn auto_init_refuses_when_the_downward_scan_cannot_finish() {
use std::os::unix::fs::PermissionsExt;
let tmp = hermetic_temp_dir();
let ws = tmp.path().join("workspace");
let locked = ws.join("locked");
std::fs::create_dir_all(&locked).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let _restore = RestoreMode(locked.clone());
if std::fs::read_dir(&locked).is_ok() {
eprintln!("#7749 test: running with permission overrides (root?), skipping");
return;
}
let outcome = ensure_git_repo_with(&ws, Some(tmp.path()), "git").unwrap();
match outcome {
AutoInitOutcome::Refused(AutoInitRefusal::ScanIncomplete(ScanIncomplete::Unreadable {
path,
..
})) => assert_eq!(path, locked),
other => panic!("an unreadable subtree must refuse the init, got {other:?}"),
}
assert!(
!ws.join(".git").exists(),
"nothing may be initialized while a child repository cannot be ruled out"
);
}
#[cfg(unix)]
struct RestoreMode(PathBuf);
#[cfg(unix)]
impl Drop for RestoreMode {
fn drop(&mut self) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
}
}
const CLASSIC_NO_REPO: &str =
"fatal: not a git repository (or any of the parent directories): .git";
const INIT_MARKER: &str = "fake-git-init-ran";
#[cfg(unix)]
fn fake_git(dir: &Path, stderr: &str) -> String {
use std::os::unix::fs::PermissionsExt;
const ETXTBSY: i32 = 26;
let shim = dir.join("fake-git");
let script = format!(
"#!/bin/sh\ncase \"$3\" in\n rev-parse) printf '%s\\n' '{stderr}' >&2; exit 128 ;;\n \
init) : > \"$2/{INIT_MARKER}\"; exit 0 ;;\nesac\nexit 0\n"
);
std::fs::write(&shim, script).unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
for _ in 0..100 {
match Command::new(&shim).arg("probe").output() {
Ok(_) => return shim.to_string_lossy().into_owned(),
Err(e) if e.raw_os_error() == Some(ETXTBSY) => {
std::thread::sleep(std::time::Duration::from_millis(20));
}
Err(e) => panic!("fake git at {} did not run: {e}", shim.display()),
}
}
panic!("fake git at {} stayed busy (ETXTBSY)", shim.display());
}
#[cfg(unix)]
#[test]
fn auto_init_refuses_the_classic_no_repo_wording_under_a_real_git() {
let tmp = hermetic_temp_dir();
let repo = tmp.path().join("real-repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
let dir = repo.join("sub");
std::fs::create_dir(&dir).unwrap();
let git = fake_git(tmp.path(), CLASSIC_NO_REPO);
let result = ensure_git_repo_with(&dir, Some(tmp.path()), &git);
assert!(
!dir.join(INIT_MARKER).exists(),
"git init must not run under a real .git, got {result:?}"
);
let err = result.expect_err("a .git above the directory must be an error, never an init");
let expected = repo.canonicalize().unwrap().join(".git");
assert!(
err.to_string().contains(&expected.display().to_string()),
"the error must name the .git it found: {err}"
);
}
const MOUNT_BOUNDARY_NO_REPO: &str = "fatal: not a git repository (or any parent up to mount point /)\n\
Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).";
#[test]
fn classify_accepts_the_mount_boundary_wording_with_no_git_above() {
let tmp = hermetic_temp_dir();
let dir = tmp.path().join("plain");
std::fs::create_dir(&dir).unwrap();
let context = classify_rev_parse_failure(&dir, MOUNT_BOUNDARY_NO_REPO);
assert_eq!(context.ok(), Some(RepoContext::Absent));
}
#[test]
fn classify_accepts_the_classic_wording_with_no_git_above() {
let tmp = hermetic_temp_dir();
let dir = tmp.path().join("plain");
std::fs::create_dir(&dir).unwrap();
let context = classify_rev_parse_failure(&dir, CLASSIC_NO_REPO);
assert_eq!(context.ok(), Some(RepoContext::Absent));
}
#[test]
fn classify_refuses_the_mount_boundary_wording_under_a_real_git() {
let tmp = hermetic_temp_dir();
let repo = tmp.path().join("real-repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
let dir = repo.join("sub");
std::fs::create_dir(&dir).unwrap();
let context = classify_rev_parse_failure(&dir, MOUNT_BOUNDARY_NO_REPO);
assert!(
context.is_err(),
"a .git above must not classify as Absent, got {context:?}"
);
}
#[test]
fn classify_keeps_every_other_failure_an_error() {
let tmp = hermetic_temp_dir();
let dir = tmp.path().join("plain");
std::fs::create_dir(&dir).unwrap();
for stderr in [
"fatal: not a git repository: (null)",
"fatal: not a git repository: '/srv/project/.git'",
"fatal: detected dubious ownership in repository at '/srv/project'",
"fatal: cannot change to '/srv/project': Permission denied",
"error: could not open '.git/HEAD': Permission denied",
] {
let context = classify_rev_parse_failure(&dir, stderr);
assert!(
context.is_err(),
"{stderr:?} must stay an error, got {context:?}"
);
}
}
#[test]
fn launch_route_after_a_scan_refusal_falls_through_to_the_live_checkout() {
use crate::commands::origin_plan::{OriginPlan, plan_for_origin};
use trusty_mpm::core::child_repo_scan::WORKSPACE_SCAN_BUDGET;
let tmp = hermetic_temp_dir();
let ws = tmp.path().join("wide-workspace");
std::fs::create_dir(&ws).unwrap();
for i in 0..=WORKSPACE_SCAN_BUDGET {
std::fs::create_dir(ws.join(format!("d{i}"))).unwrap();
}
let outcome = ensure_git_repo_with(&ws, Some(tmp.path()), "git").unwrap();
assert_eq!(
outcome,
AutoInitOutcome::Refused(AutoInitRefusal::ScanIncomplete(
ScanIncomplete::BudgetExhausted
))
);
assert!(!ws.join(".git").exists(), "nothing may be initialized");
let origin =
trusty_mpm::daemon::managed_routes::inproject::get_origin_url(&ws).unwrap_or_else(|e| {
panic!("git config outside a repository must not be an error, got: {e}")
});
assert_eq!(origin, None);
assert_eq!(plan_for_origin(origin.as_deref()), OriginPlan::LiveCheckout);
}
#[test]
fn auto_init_skips_a_path_that_is_not_a_directory() {
let tmp = hermetic_temp_dir();
let missing = tmp.path().join("does-not-exist");
let outcome = ensure_git_repo_with(&missing, Some(tmp.path()), "git").unwrap();
assert_eq!(outcome, AutoInitOutcome::NotADirectory);
assert!(!missing.exists(), "the path must not be created");
}