mod common;
use common::Repo;
fn with_origin() -> Repo {
let r = Repo::new();
r.stage("a.txt", "x\n");
r.commit("init");
let origin = r.path(".git/test-origin.git");
r.git(&["init", "-q", "--bare", origin.to_str().unwrap()]);
r.git(&["remote", "add", "origin", origin.to_str().unwrap()]);
assert!(
String::from_utf8_lossy(&r.git(&["status", "--porcelain"]).stdout)
.trim()
.is_empty(),
"the fixture itself must leave a CLEAN tree, or these tests prove nothing"
);
r
}
#[test]
fn skips_a_branch_with_no_upstream() {
let r = with_origin();
r.git(&["checkout", "-q", "-b", "feat/brand-new"]);
assert!(r.hook("pre-push-pull-rebase", &[]).passed());
}
#[test]
fn announces_the_skip_on_a_dirty_tree() {
let r = with_origin();
r.write("scratch.txt", "dirty\n");
let run = r.hook("pre-push-pull-rebase", &[]);
assert!(run.passed());
assert!(run.says("Uncommitted changes"), "the guard did not fire");
assert!(
r.path("scratch.txt").exists(),
"work must not be stashed away"
);
}
#[test]
fn passes_when_in_sync_with_its_own_upstream() {
let r = with_origin();
r.git(&["checkout", "-q", "-b", "feat/synced"]);
r.git(&["push", "-q", "--no-verify", "-u", "origin", "feat/synced"]);
let run = r.hook("pre-push-pull-rebase", &[]);
assert!(run.passed());
assert!(run.says("in sync"));
}
#[test]
fn divergence_offers_both_readings_and_prescribes_neither() {
let r = with_origin();
r.git(&["checkout", "-q", "-b", "feat/diverged"]);
r.git(&["push", "-q", "--no-verify", "-u", "origin", "feat/diverged"]);
r.stage("a.txt", "one\n");
r.commit("first");
r.git(&["push", "-q", "--no-verify", "origin", "feat/diverged"]);
r.git(&["reset", "-q", "--hard", "HEAD~1"]);
r.stage("a.txt", "one, rewritten\n");
r.commit("first, amended");
let run = r.hook("pre-push-pull-rebase", &[]);
assert!(
run.passed(),
"divergence never blocked a push:\n{}",
run.output()
);
assert!(run.says("diverged"), "{}", run.output());
assert!(
run.says("1 ahead, 1 behind"),
"the counts must be in the message:\n{}",
run.output()
);
assert!(
run.says("--force-with-lease"),
"the rebase reading is missing:\n{}",
run.output()
);
assert!(
run.says("pull --rebase"),
"the someone-else-pushed reading is missing:\n{}",
run.output()
);
}
#[test]
fn skips_when_the_upstream_was_deleted_on_the_remote() {
let r = with_origin();
r.git(&["checkout", "-q", "-b", "feat/merged-away"]);
r.git(&[
"push",
"-q",
"--no-verify",
"-u",
"origin",
"feat/merged-away",
]);
let origin = r.path(".git/test-origin.git");
std::process::Command::new("git")
.args([
"-C",
origin.to_str().unwrap(),
"update-ref",
"-d",
"refs/heads/feat/merged-away",
])
.status()
.expect("delete the remote branch");
let run = r.hook("pre-push-pull-rebase", &[]);
assert!(run.passed());
assert!(run.says("no longer exists"));
}
#[test]
fn a_branch_tracking_a_local_branch_is_not_silently_synced_as_origin() {
let r = with_origin();
r.git(&["checkout", "-q", "-b", "feat/from-local"]);
r.stage("b.txt", "extra work\n");
r.commit("extra work");
r.git(&["branch", "--set-upstream-to=main", "feat/from-local"]);
let before = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
let run = r.hook("pre-push-pull-rebase", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
run.says("not a remote-tracking branch"),
"a local-branch upstream must be named and skipped, not guessed at as origin: {}",
run.output()
);
let after = String::from_utf8_lossy(&r.git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
assert_eq!(
before, after,
"the branch must not be touched at all when its upstream is not a real remote"
);
}
#[test]
fn the_default_branch_advisory_survives_a_worktree_marker() {
let r = with_origin();
r.stage("a.txt", "one\n");
r.commit("chore: seed");
r.git(&["push", "-q", "--no-verify", "-u", "origin", "main"]);
let wt = r.worktree("feat/adv");
let mut push = std::process::Command::new("git");
push.args(["push", "-q", "--no-verify", "-u", "origin", "feat/adv"])
.current_dir(&wt);
Repo::strip_git_env_impl(&mut push);
assert!(push.status().expect("push").success());
r.stage("a.txt", "two\n");
r.commit("chore: move main on");
r.git(&["push", "-q", "--no-verify", "origin", "main"]);
let listed = String::from_utf8_lossy(
&std::process::Command::new("git")
.args(["branch"])
.current_dir(&wt)
.output()
.expect("git branch")
.stdout,
)
.into_owned();
assert!(
listed.contains("+ main"),
"fixture: expected the worktree marker, got {listed:?}"
);
let run = r.hook_at(&wt, "pre-push-pull-rebase", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
run.says("is ahead by 1 commit"),
"the advisory never fired past the worktree marker:\n{}",
run.output()
);
}
#[test]
fn the_advisory_uses_the_branch_s_own_remote() {
let r = Repo::new();
r.stage("a.txt", "one\n");
r.commit("chore: seed");
let remote = r.path(".git/test-upstream.git");
r.git(&["init", "-q", "--bare", remote.to_str().unwrap()]);
r.git(&["remote", "add", "upstream", remote.to_str().unwrap()]);
r.git(&["push", "-q", "--no-verify", "-u", "upstream", "main"]);
r.git(&["checkout", "-q", "-b", "test/zz"]);
r.git(&["push", "-q", "--no-verify", "-u", "upstream", "test/zz"]);
r.git(&["checkout", "-q", "main"]);
r.stage("a.txt", "two\n");
r.commit("chore: move main on");
r.git(&["push", "-q", "--no-verify", "upstream", "main"]);
r.git(&["checkout", "-q", "test/zz"]);
let listed = String::from_utf8_lossy(&r.git(&["branch"]).stdout).into_owned();
assert!(
listed.starts_with(" main"),
"fixture: main must sort first for this case to bite, got {listed:?}"
);
let run = r.hook("pre-push-pull-rebase", &[]);
assert!(run.passed(), "{}", run.output());
assert!(
run.says("upstream/main is ahead by 1 commit"),
"the advisory looked for a remote this repo does not have:\n{}",
run.output()
);
}