use super::*;
use crate::store::Store;
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;
fn raw_git(path: &Path, args: &[&str]) -> std::process::Output {
let mut cmd = Command::new("git");
cmd.current_dir(path).args(args);
for var in crate::git::GIT_ENV_VARS {
cmd.env_remove(var);
}
cmd.output().expect("spawn git")
}
fn init_repo(path: &Path) {
let run = |args: &[&str]| {
assert!(raw_git(path, args).status.success());
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "test"]);
run(&["config", "commit.gpgsign", "false"]);
run(&["commit", "--allow-empty", "-m", "init"]);
}
#[test]
fn add_user_changes_stages_normal_files() {
let td = tempdir().unwrap();
init_repo(td.path());
std::fs::write(td.path().join("a.txt"), "hi").unwrap();
add_user_changes(td.path()).unwrap();
let staged =
String::from_utf8(raw_git(td.path(), &["diff", "--cached", "--name-only"]).stdout).unwrap();
assert!(staged.contains("a.txt"), "got: {staged}");
}
#[test]
fn add_user_changes_excludes_balls_runtime_paths() {
let td = tempdir().unwrap();
init_repo(td.path());
std::fs::create_dir_all(td.path().join(".balls/local")).unwrap();
std::fs::create_dir_all(td.path().join(".balls/tasks")).unwrap();
std::fs::create_dir_all(td.path().join(".balls/worktree")).unwrap();
std::fs::write(td.path().join(".balls/local/lock"), "x").unwrap();
std::fs::write(td.path().join(".balls/tasks/t.json"), "x").unwrap();
std::fs::write(td.path().join(".balls/worktree/x"), "x").unwrap();
std::fs::write(td.path().join("user.txt"), "ok").unwrap();
add_user_changes(td.path()).unwrap();
let staged =
String::from_utf8(raw_git(td.path(), &["diff", "--cached", "--name-only"]).stdout).unwrap();
assert!(staged.contains("user.txt"), "got: {staged}");
for p in RUNTIME_PATHS {
assert!(
!staged.lines().any(|l| l.starts_with(p)),
"runtime path {p} leaked into staging: {staged}"
);
}
}
#[test]
fn commit_touches_runtime_flags_runtime_paths() {
let td = tempdir().unwrap();
init_repo(td.path());
std::fs::create_dir_all(td.path().join(".balls/local")).unwrap();
std::fs::write(td.path().join(".balls/local/lock"), "x").unwrap();
assert!(raw_git(td.path(), &["add", "-A"]).status.success());
assert!(raw_git(td.path(), &["commit", "-m", "bad"])
.status
.success());
let sha = String::from_utf8(raw_git(td.path(), &["rev-parse", "HEAD"]).stdout)
.unwrap()
.trim()
.to_string();
let hits = commit_touches_runtime(td.path(), &sha).unwrap();
assert_eq!(hits, vec![".balls/local/lock".to_string()]);
}
#[test]
fn commit_touches_runtime_empty_for_clean_commit() {
let td = tempdir().unwrap();
init_repo(td.path());
std::fs::write(td.path().join("ok.txt"), "x").unwrap();
assert!(raw_git(td.path(), &["add", "-A"]).status.success());
assert!(raw_git(td.path(), &["commit", "-m", "ok"]).status.success());
let sha = String::from_utf8(raw_git(td.path(), &["rev-parse", "HEAD"]).stdout)
.unwrap()
.trim()
.to_string();
let hits = commit_touches_runtime(td.path(), &sha).unwrap();
assert!(hits.is_empty(), "expected empty, got {hits:?}");
}
#[test]
fn is_bare_helper_distinguishes_layouts() {
let regular = tempdir().unwrap();
init_repo(regular.path());
assert!(!is_bare(regular.path()).unwrap());
let bare = tempdir().unwrap();
assert!(raw_git(bare.path(), &["init", "-q", "--bare"])
.status
.success());
assert!(is_bare(bare.path()).unwrap());
}
#[test]
fn commit_squash_and_flip_rewinds_main_when_squash_carries_runtime() {
let td = tempdir().unwrap();
init_repo(td.path());
let store = Store::init(td.path(), false, None).unwrap();
let pre_main = git::git_resolve_sha(td.path(), "HEAD").unwrap();
let run = |args: &[&str]| {
assert!(raw_git(td.path(), args).status.success(), "git {args:?}");
};
run(&["checkout", "-q", "-b", "work/bl-test"]);
std::fs::create_dir_all(td.path().join(".balls/local")).unwrap();
std::fs::write(td.path().join(".balls/local/x"), "x").unwrap();
run(&["add", "-f", ".balls/local/x"]);
run(&["commit", "-m", "runtime"]);
run(&["checkout", "-q", "main"]);
let err = commit_squash_and_flip(
&store,
"bl-test",
"work/bl-test",
"msg [bl-test]",
None,
"test",
&pre_main,
"main",
)
.unwrap_err();
assert!(
matches!(&err, BallError::Other(s) if s.contains(".balls/local")),
"{err:?}"
);
let post_main = git::git_resolve_sha(td.path(), "HEAD").unwrap();
assert_eq!(
pre_main, post_main,
"main should be rewound after rejection"
);
}
#[test]
fn rewind_main_uses_update_ref_on_bare_layout() {
let td = tempdir().unwrap();
init_repo(td.path());
let store = Store::init(td.path(), false, None).unwrap();
let pre = git::git_resolve_sha(td.path(), "HEAD").unwrap();
let run = |args: &[&str]| {
assert!(raw_git(td.path(), args).status.success(), "git {args:?}");
};
run(&["commit", "--allow-empty", "-m", "advance"]);
let advanced = git::git_resolve_sha(td.path(), "HEAD").unwrap();
assert_ne!(pre, advanced, "sanity: HEAD advanced");
run(&["config", "core.bare", "true"]);
rewind_main(&store, "main", &pre).unwrap();
let after = git::git_resolve_sha(td.path(), "refs/heads/main").unwrap();
assert_eq!(pre, after, "bare rewind must move main back via update-ref");
}
#[test]
fn runtime_in_squash_error_pluralizes_correctly() {
let one = runtime_in_squash_error("bl-aaaa", &[".balls/local".into()]);
assert!(
matches!(&one, BallError::Other(s) if s.contains("path .balls/local")),
"{one:?}"
);
let many = runtime_in_squash_error("bl-bbbb", &[".balls/local".into(), ".balls/tasks".into()]);
assert!(
matches!(&many, BallError::Other(s) if s.contains("paths .balls/local, .balls/tasks")),
"{many:?}"
);
}