use super::*;
use crate::test_support::{fixture, git, remove};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
const REMOTE_HOOK_MARKER: &str = "marker-from-receive-hook";
fn fixture_environment() -> Vec<(OsString, OsString)> {
let mut environment: Vec<(OsString, OsString)> = [
("PATH", "/usr/bin:/bin"),
("GIT_CONFIG_NOSYSTEM", "1"),
("GIT_CONFIG_GLOBAL", "/nonexistent-global-config"),
("GIT_AUTHOR_NAME", "Fixture"),
("GIT_AUTHOR_EMAIL", "fixture@example.invalid"),
("GIT_COMMITTER_NAME", "Fixture"),
("GIT_COMMITTER_EMAIL", "fixture@example.invalid"),
]
.iter()
.map(|(name, value)| {
return (OsString::from(name), OsString::from(value));
})
.collect();
environment.extend(crate::test_support::scratch_environment());
return environment;
}
fn push_git(root: &Path) -> PushGit {
return PushGit {
real_git: PathBuf::from("/usr/bin/git"),
cwd: root.to_path_buf(),
environment: fixture_environment(),
lfs: false,
};
}
fn head_oid(root: &Path) -> String {
return String::from_utf8_lossy(&git(root, &["rev-parse", "HEAD"]).stdout)
.trim()
.to_owned();
}
fn init_repo_with_commit(path: &Path) {
std::fs::create_dir_all(path).expect("repo directory");
git(path, &["init", "--quiet"]);
git(path, &["config", "user.email", "cli-git@example.invalid"]);
git(path, &["config", "user.name", "cli-git auto-push test"]);
std::fs::write(path.join("tracked.txt"), "initial\n").expect("tracked file");
git(path, &["add", "tracked.txt"]);
git(path, &["commit", "--quiet", "-m", "initial", "tracked.txt"]);
}
fn commit_change(path: &Path, text: &str) {
std::fs::write(path.join("tracked.txt"), text).expect("tracked file");
git(
path,
&["commit", "--quiet", "-m", text.trim(), "tracked.txt"],
);
}
fn bare_remote_with_hook(remote: &Path) {
git(
remote.parent().expect("parent"),
&["init", "--bare", "--quiet", remote.to_str().expect("utf-8")],
);
let hook = remote.join("hooks").join("post-receive");
std::fs::write(&hook, format!("#!/bin/sh\necho \"{REMOTE_HOOK_MARKER}\"\n")).expect("hook");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("mode");
}
fn repo_with_remote_and_pre_push(root: &Path, code: i32) -> (PathBuf, PathBuf) {
let repo = root.join("repo");
let remote = root.join("remote.git");
let hooks = root.join("hooks");
let log = root.join("pre-push.log");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(
&repo,
&["remote", "add", "origin", remote.to_str().expect("utf-8")],
);
std::fs::create_dir_all(&hooks).expect("hooks");
let hook = hooks.join("pre-push");
std::fs::write(
&hook,
format!(
"#!/bin/sh\necho pre-push >> '{}'\nexit {code}\n",
log.display()
),
)
.expect("pre-push hook");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("mode");
git(&repo, &["config", "hook.control.event", "pre-push"]);
git(&repo, &["config", "hook.control.command", hook.to_str().expect("utf-8")]);
return (repo, log);
}
fn read_log(log: &Path) -> String {
return std::fs::read_to_string(log).unwrap_or_default();
}
fn head_ref(repo: &Path) -> String {
return String::from_utf8_lossy(&git(repo, &["symbolic-ref", "HEAD"]).stdout).trim().to_owned();
}
fn run(repo: &Path, landed: &str) -> (Result<AutoPushResult, SingleFlightError>, String) {
let mut err: Vec<u8> = Vec::new();
let result = push_once(&push_git(repo), &head_ref(repo), landed, &mut err);
return (result, String::from_utf8_lossy(&err).into_owned());
}
#[test]
fn keeps_only_remote_lines_on_a_clean_push() {
assert_eq!(
filter_push_output("remote: hello\nTo origin\nremote: world\nbranch set up", 0),
"remote: hello\nremote: world"
);
}
#[test]
fn a_clean_push_without_remote_lines_surfaces_nothing() {
assert_eq!(
filter_push_output("To origin\nbranch main set up to track origin/main", 0),
""
);
}
#[test]
fn a_clean_push_keeps_the_reconcile_lines() {
assert_eq!(
filter_push_output(
"To origin\nremote: hello\ncli-git: auto-push: published a merge\nbranch set up",
0
),
"remote: hello\ncli-git: auto-push: published a merge"
);
}
#[test]
fn a_failed_push_surfaces_the_full_output() {
let output = "remote: rejected\nTo origin\n! [remote rejected] HEAD -> main";
assert_eq!(filter_push_output(output, 1), output);
}
#[test]
fn skips_when_no_origin_remote_exists() {
let root = fixture("ap-no-origin");
init_repo_with_commit(&root);
let (attempted, err) = run(&root, &head_oid(&root));
let result = attempted.expect("auto-push");
assert_eq!(result.outcome, AutoPushOutcome::Skipped);
assert_eq!(result.exit_code, 0);
assert_eq!(result.shown, "");
assert_eq!(err, "");
remove(&root);
}
#[test]
fn pushes_and_surfaces_only_remote_lines_on_success() {
let root = fixture("ap-success");
let remote = root.join("remote.git");
let repo = root.join("repo");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(
&repo,
&["remote", "add", "origin", remote.to_str().expect("utf-8")],
);
let landed = head_oid(&repo);
let (attempted, err) = run(&repo, &landed);
let result = attempted.expect("auto-push");
assert_eq!(result.outcome, AutoPushOutcome::Pushed);
assert_eq!(result.exit_code, 0);
assert!(
result.shown.contains(REMOTE_HOOK_MARKER),
"{}",
result.shown
);
assert!(
result
.shown
.split('\n')
.filter(|line| {
return !line.is_empty();
})
.all(|line| {
return line.starts_with("remote: ");
}),
"{}",
result.shown
);
assert_eq!(err, format!("{}\n", result.shown));
remove(&root);
}
#[test]
fn pushes_to_an_existing_upstream_without_re_pointing_it_to_origin() {
let root = fixture("ap-upstream");
let repo = root.join("repo");
let alt = root.join("alt.git");
bare_remote_with_hook(&alt);
init_repo_with_commit(&repo);
git(
&repo,
&["remote", "add", "alt", alt.to_str().expect("utf-8")],
);
git(&repo, &["push", "--quiet", "--set-upstream", "alt", "HEAD"]);
commit_change(&repo, "second\n");
let landed = head_oid(&repo);
let (attempted, _err) = run(&repo, &landed);
assert_eq!(
attempted.expect("auto-push").outcome,
AutoPushOutcome::Pushed
);
let branch = String::from_utf8_lossy(&git(&repo, &["branch", "--show-current"]).stdout)
.trim()
.to_owned();
let tracked = String::from_utf8_lossy(
&git(&repo, &["config", &format!("branch.{branch}.remote")]).stdout,
)
.trim()
.to_owned();
assert_eq!(tracked, "alt");
assert_eq!(
String::from_utf8_lossy(&git(&alt, &["rev-parse", &branch]).stdout).trim(),
landed
);
remove(&root);
}
#[test]
fn reports_failure_and_surfaces_full_output_when_the_push_is_rejected() {
let root = fixture("ap-rejected");
let repo = root.join("repo");
init_repo_with_commit(&repo);
git(
&repo,
&[
"remote",
"add",
"origin",
root.join("missing.git").to_str().expect("utf-8"),
],
);
let (attempted, err) = run(&repo, &head_oid(&repo));
let result = attempted.expect("auto-push");
assert_eq!(result.outcome, AutoPushOutcome::Failed);
assert_ne!(result.exit_code, 0);
assert!(!result.shown.is_empty());
assert!(err.contains(PUSH_FAILED_NOTE), "{err}");
remove(&root);
}
#[test]
fn surfaces_a_failed_pre_push_hook_as_a_failed_push_with_its_full_output() {
let root = fixture("ap-prepush-fails");
let (repo, log) = repo_with_remote_and_pre_push(&root, 1);
let (attempted, _err) = run(&repo, &head_oid(&repo));
let result = attempted.expect("auto-push");
assert_eq!(result.outcome, AutoPushOutcome::Failed);
assert_ne!(result.exit_code, 0);
assert!(result.shown.contains("failed to push"), "{}", result.shown);
assert_eq!(read_log(&log), "pre-push\n");
remove(&root);
}
#[test]
fn a_stalled_push_is_stopped_and_reported_as_a_failed_push() {
let root = fixture("ap-stalled");
let (repo, log) = repo_with_remote_and_pre_push(&root, 0);
let hooks = root.join("hooks");
let hook = hooks.join("pre-push");
std::fs::write(&hook, "#!/bin/sh\necho stalling\nexec sleep 8\n").expect("stalling hook");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("mode");
let mut git = push_git(&repo);
git.environment.push((
OsString::from(crate::bounded_push::PUSH_DEADLINE_VARIABLE),
OsString::from("1"),
));
let mut err: Vec<u8> = Vec::new();
let started = std::time::Instant::now();
let result = push_once(&git, &head_ref(&repo), &head_oid(&repo), &mut err).expect("auto-push");
let elapsed = started.elapsed();
let shown = String::from_utf8_lossy(&err).into_owned();
assert!(elapsed >= Duration::from_secs(1), "{elapsed:?}");
assert!(elapsed < Duration::from_secs(30), "{elapsed:?}");
assert_eq!(result.outcome, AutoPushOutcome::Failed);
assert_eq!(result.exit_code, crate::bounded_push::STOPPED_EXIT_CODE);
assert!(
result.shown.contains("made no progress for 1 seconds"),
"{}",
result.shown
);
assert!(shown.contains(PUSH_FAILED_NOTE), "{shown}");
assert!(shown.contains(crate::bounded_push::PUSH_QUEUED_LINE), "the command's output says the push is queued: {shown}");
assert_eq!(read_log(&log), "", "the replaced hook logs nothing");
remove(&root);
}
#[test]
fn a_push_runs_without_a_low_speed_abort_of_the_wrappers_own() {
let root = fixture("ap-low-speed");
let (repo, _log) = repo_with_remote_and_pre_push(&root, 0);
let hook = root.join("hooks").join("pre-push");
let seen = root.join("seen.txt");
std::fs::write(
&hook,
format!(
"#!/bin/sh\necho \"$GIT_HTTP_LOW_SPEED_LIMIT $GIT_HTTP_LOW_SPEED_TIME\" > '{}'\n",
seen.display()
),
)
.expect("hook");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("mode");
let (attempted, _err) = run(&repo, &head_oid(&repo));
assert_eq!(attempted.expect("auto-push").outcome, AutoPushOutcome::Pushed);
assert_eq!(read_log(&seen), " \n");
remove(&root);
}
#[test]
fn a_push_keeps_the_low_speed_variables_the_person_set() {
let root = fixture("ap-low-speed-set");
let (repo, _log) = repo_with_remote_and_pre_push(&root, 0);
let hook = root.join("hooks").join("pre-push");
let seen = root.join("seen.txt");
std::fs::write(
&hook,
format!(
"#!/bin/sh\necho \"$GIT_HTTP_LOW_SPEED_LIMIT $GIT_HTTP_LOW_SPEED_TIME\" > '{}'\n",
seen.display()
),
)
.expect("hook");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("mode");
let mut git = push_git(&repo);
git.environment.push((
OsString::from("GIT_HTTP_LOW_SPEED_LIMIT"),
OsString::from("5"),
));
let mut err: Vec<u8> = Vec::new();
let result = push_once(&git, &head_ref(&repo), &head_oid(&repo), &mut err).expect("auto-push");
assert_eq!(result.outcome, AutoPushOutcome::Pushed);
assert_eq!(read_log(&seen), "5 \n");
remove(&root);
}
#[test]
fn a_first_push_rejected_for_a_moved_remote_branch_publishes_no_merge() {
let root = fixture("ap-first-push-stale");
let repo = root.join("repo");
let remote = root.join("remote.git");
let other = root.join("other");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
let remote_text = remote.to_str().expect("utf-8");
git(&repo, &["remote", "add", "origin", remote_text]);
git(&repo, &["push", "--quiet", "origin", "HEAD"]);
git(&root, &["clone", "--quiet", remote_text, "other"]);
git(&other, &["config", "user.email", "cli-git@example.invalid"]);
git(&other, &["config", "user.name", "cli-git auto-push test"]);
commit_change(&other, "remote side\n");
git(&other, &["push", "--quiet", "origin", "HEAD"]);
let branch = String::from_utf8_lossy(&git(&repo, &["branch", "--show-current"]).stdout)
.trim()
.to_owned();
let remote_tip = head_oid(&other);
std::fs::write(repo.join("local.txt"), "local side\n").expect("local file");
git(&repo, &["add", "local.txt"]);
git(&repo, &["commit", "--quiet", "-m", "local side", "local.txt"]);
git(&repo, &["config", "remote.pushDefault", "origin"]);
git(&repo, &["config", "push.default", "current"]);
let (attempted, err) = run(&repo, &head_oid(&repo));
let result = attempted.expect("auto-push");
assert_eq!(result.outcome, AutoPushOutcome::Failed, "{err}");
assert_eq!(
String::from_utf8_lossy(&git(&remote, &["rev-parse", &branch]).stdout).trim(),
remote_tip
);
remove(&root);
}
fn current_branch(repo: &Path) -> String {
return String::from_utf8_lossy(&git(repo, &["branch", "--show-current"]).stdout)
.trim()
.to_owned();
}
fn repo_with_differently_named_upstream(root: &Path) -> (PathBuf, PathBuf, String) {
let repo = root.join("repo");
let remote = root.join("remote.git");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(
&repo,
&["remote", "add", "origin", remote.to_str().expect("utf-8")],
);
let default = current_branch(&repo);
git(&repo, &["push", "--quiet", "--set-upstream", "origin", "HEAD"]);
git(
&repo,
&["checkout", "--quiet", "-b", "feature", "--track", &format!("origin/{default}")],
);
return (repo, remote, default);
}
#[test]
fn a_branch_with_a_differently_named_upstream_is_pushed_to_the_tracked_branch() {
let root = fixture("ap-renamed-upstream");
let (repo, remote, default) = repo_with_differently_named_upstream(&root);
commit_change(&repo, "feature work\n");
let landed = head_oid(&repo);
let (attempted, err) = run(&repo, &landed);
let result = attempted.expect("auto-push");
assert_eq!(result.outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(
String::from_utf8_lossy(&git(&remote, &["rev-parse", &default]).stdout).trim(),
landed
);
let remote_refs = String::from_utf8_lossy(
&git(&remote, &["for-each-ref", "--format=%(refname)"]).stdout,
)
.into_owned();
assert!(
!remote_refs.contains("refs/heads/feature"),
"no branch named after the local one is created on the remote: {remote_refs}"
);
remove(&root);
}
#[test]
fn a_named_branch_with_a_differently_named_upstream_is_pushed_to_the_tracked_branch() {
let root = fixture("ap-named-renamed-upstream");
let (repo, remote, default) = repo_with_differently_named_upstream(&root);
commit_change(&repo, "feature work\n");
let landed = head_oid(&repo);
let (attempted, err) = run_for_branch(&repo, "refs/heads/feature", &landed);
assert_eq!(attempted.expect("push").outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(remote_tip(&remote, &default).as_deref(), Some(landed.as_str()), "the tracked branch received the commit: {err}");
assert_eq!(remote_tip(&remote, "feature"), None, "no branch named after the local one is created on the remote");
let tracked = String::from_utf8_lossy(&git(&repo, &["rev-parse", "--abbrev-ref", "feature@{upstream}"]).stdout).trim().to_owned();
assert_eq!(tracked, format!("origin/{default}"), "the upstream is unchanged");
remove(&root);
}
#[test]
fn a_differently_named_upstream_that_moved_is_reconciled_on_the_tracked_branch() {
let root = fixture("ap-renamed-upstream-moved");
let (repo, remote, default) = repo_with_differently_named_upstream(&root);
let other = root.join("other");
git(&root, &["clone", "--quiet", remote.to_str().expect("utf-8"), "other"]);
git(&other, &["config", "user.email", "cli-git@example.invalid"]);
git(&other, &["config", "user.name", "cli-git auto-push test"]);
std::fs::write(other.join("remote.txt"), "remote side\n").expect("remote file");
git(&other, &["add", "remote.txt"]);
git(&other, &["commit", "--quiet", "-m", "remote side", "remote.txt"]);
git(&other, &["push", "--quiet", "origin", "HEAD"]);
let remote_tip = head_oid(&other);
commit_change(&repo, "feature work\n");
let landed = head_oid(&repo);
let (attempted, err) = run(&repo, &landed);
let result = attempted.expect("auto-push");
assert_eq!(result.outcome, AutoPushOutcome::Pushed, "{err}");
let published = String::from_utf8_lossy(&git(&remote, &["rev-parse", &default]).stdout)
.trim()
.to_owned();
for ancestor in [&landed, &remote_tip] {
git(&remote, &["merge-base", "--is-ancestor", ancestor, &published]);
}
remove(&root);
}
#[test]
fn a_named_branch_whose_tracked_remote_branch_moved_is_reconciled() {
let root = fixture("ap-named-moved");
let (repo, remote, default) = repo_with_differently_named_upstream(&root);
let other = root.join("other");
git(&root, &["clone", "--quiet", remote.to_str().expect("utf-8"), "other"]);
git(&other, &["config", "user.email", "cli-git@example.invalid"]);
git(&other, &["config", "user.name", "cli-git auto-push test"]);
std::fs::write(other.join("remote.txt"), "remote side\n").expect("remote file");
git(&other, &["add", "remote.txt"]);
git(&other, &["commit", "--quiet", "-m", "remote side", "remote.txt"]);
git(&other, &["push", "--quiet", "origin", "HEAD"]);
let remote_tip_before = head_oid(&other);
commit_change(&repo, "feature work\n");
let landed = head_oid(&repo);
let (attempted, err) = run_for_branch(&repo, "refs/heads/feature", &landed);
assert_eq!(attempted.expect("push").outcome, AutoPushOutcome::Pushed, "{err}");
let published = remote_tip(&remote, &default).expect("the tracked branch exists");
for ancestor in [&landed, &remote_tip_before] {
git(&remote, &["merge-base", "--is-ancestor", ancestor, &published]);
}
remove(&root);
}
fn run_for_branch(repo: &Path, branch_ref: &str, landed: &str) -> (Result<AutoPushResult, SingleFlightError>, String) {
let mut err: Vec<u8> = Vec::new();
let result = push_once(&push_git(repo), branch_ref, landed, &mut err);
return (result, String::from_utf8_lossy(&err).into_owned());
}
fn remote_tip(remote: &Path, branch: &str) -> Option<String> {
let output = crate::test_support::git_output(remote, &["rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")]);
return output.status.success().then(|| { return String::from_utf8_lossy(&output.stdout).trim().to_owned(); });
}
#[test]
fn a_push_of_a_named_branch_does_not_follow_the_checked_out_branch() {
let root = fixture("ap-named-tracked");
let remote = root.join("remote.git");
let repo = root.join("repo");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf-8")]);
let default = current_branch(&repo);
git(&repo, &["push", "--quiet", "--set-upstream", "origin", "HEAD"]);
git(&repo, &["checkout", "--quiet", "-b", "topic"]);
git(&repo, &["push", "--quiet", "--set-upstream", "origin", "topic"]);
commit_change(&repo, "topic work\n");
let landed = head_oid(&repo);
git(&repo, &["checkout", "--quiet", &default]);
commit_change(&repo, "default work\n");
let default_tip = head_oid(&repo);
let (attempted, err) = run_for_branch(&repo, "refs/heads/topic", &landed);
let result = attempted.expect("push");
assert_eq!(result.outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(remote_tip(&remote, "topic").as_deref(), Some(landed.as_str()), "the named branch reached the remote");
assert_ne!(remote_tip(&remote, &default).as_deref(), Some(default_tip.as_str()), "the checked-out branch was not pushed");
assert_eq!(current_branch(&repo), default, "the worktree stayed where it was");
remove(&root);
}
#[test]
fn a_first_push_of_a_named_branch_creates_it_on_origin() {
let root = fixture("ap-named-first");
let remote = root.join("remote.git");
let repo = root.join("repo");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf-8")]);
let default = current_branch(&repo);
git(&repo, &["checkout", "--quiet", "-b", "topic"]);
commit_change(&repo, "topic work\n");
let landed = head_oid(&repo);
git(&repo, &["checkout", "--quiet", &default]);
let (attempted, err) = run_for_branch(&repo, "refs/heads/topic", &landed);
assert_eq!(attempted.expect("push").outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(remote_tip(&remote, "topic").as_deref(), Some(landed.as_str()));
assert_eq!(remote_tip(&remote, &default), None, "the checked-out branch was not published");
let tracked = String::from_utf8_lossy(&git(&repo, &["rev-parse", "--abbrev-ref", "topic@{upstream}"]).stdout).trim().to_owned();
assert_eq!(tracked, "origin/topic");
remove(&root);
}
#[test]
fn a_named_branch_is_pushed_from_a_detached_worktree() {
let root = fixture("ap-named-detached");
let remote = root.join("remote.git");
let repo = root.join("repo");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf-8")]);
let default = current_branch(&repo);
commit_change(&repo, "default work\n");
let landed = head_oid(&repo);
git(&repo, &["checkout", "--quiet", "--detach"]);
let (attempted, err) = run_for_branch(&repo, &format!("refs/heads/{default}"), &landed);
assert_eq!(attempted.expect("push").outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(remote_tip(&remote, &default).as_deref(), Some(landed.as_str()));
remove(&root);
}
#[test]
fn a_tracking_reference_locked_for_a_moment_ends_up_to_date() {
let root = fixture("ap-tracking-locked");
let remote = root.join("remote.git");
let repo = root.join("repo");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf-8")]);
let default = current_branch(&repo);
git(&repo, &["push", "--quiet", "--set-upstream", "origin", "HEAD"]);
commit_change(&repo, "work\n");
let landed = head_oid(&repo);
let lock = repo.join(format!(".git/refs/remotes/origin/{default}.lock"));
std::fs::write(&lock, b"").expect("another worktree's service holds the lock");
let releasing = lock.clone();
let holder = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
std::fs::remove_file(releasing).expect("the other service lets go");
});
let (attempted, err) = run_for_branch(&repo, &format!("refs/heads/{default}"), &landed);
holder.join().expect("holder");
assert_eq!(attempted.expect("push").outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(remote_tip(&remote, &default).as_deref(), Some(landed.as_str()));
let tracking = String::from_utf8_lossy(&git(&repo, &["rev-parse", &format!("origin/{default}")]).stdout).trim().to_owned();
assert_eq!(tracking, landed, "the remote-tracking reference agrees with the remote");
remove(&root);
}
fn switching_git(root: &Path, other: &str) -> PathBuf {
let program = root.join("switching-git");
let mark = root.join("switched");
let text = format!(
"#!/bin/sh\n/usr/bin/git \"$@\"\nstatus=$?\nif [ ! -e '{mark}' ]; then\n : > '{mark}'\n /usr/bin/git checkout --quiet {other}\nfi\nexit $status\n",
mark = mark.display(),
);
std::fs::write(&program, text).expect("switching git");
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).expect("mode");
return program;
}
#[test]
fn a_branch_switch_between_the_check_and_the_push_does_not_change_what_is_pushed() {
let root = fixture("ap-switch-window");
let remote = root.join("remote.git");
let repo = root.join("repo");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf-8")]);
let default = current_branch(&repo);
git(&repo, &["push", "--quiet", "--set-upstream", "origin", "HEAD"]);
git(&repo, &["checkout", "--quiet", "-b", "other"]);
git(&repo, &["push", "--quiet", "--set-upstream", "origin", "other"]);
commit_change(&repo, "other work\n");
let other_tip = head_oid(&repo);
git(&repo, &["checkout", "--quiet", &default]);
commit_change(&repo, "default work\n");
let landed = head_oid(&repo);
let mut pushing = push_git(&repo);
pushing.real_git = switching_git(&root, "other");
let mut err: Vec<u8> = Vec::new();
let result = push_once(&pushing, &format!("refs/heads/{default}"), &landed, &mut err).expect("push");
let shown = String::from_utf8_lossy(&err).into_owned();
assert!(root.join("switched").exists(), "the switch happened inside the window");
assert_eq!(result.outcome, AutoPushOutcome::Pushed, "{shown}");
assert_eq!(remote_tip(&remote, &default).as_deref(), Some(landed.as_str()), "the recorded branch reached the remote: {shown}");
assert_ne!(remote_tip(&remote, "other").as_deref(), Some(other_tip.as_str()), "the branch switched to was not published");
remove(&root);
}
#[test]
fn a_recorded_branch_with_an_unnamed_destination_is_pushed_to_its_tracked_branch() {
let root = fixture("ap-unnamed-destination");
let remote = root.join("remote.git");
let repo = root.join("repo");
bare_remote_with_hook(&remote);
init_repo_with_commit(&repo);
git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf-8")]);
let default = current_branch(&repo);
git(&repo, &["push", "--quiet", "--set-upstream", "origin", "HEAD"]);
git(&repo, &["config", "push.default", "matching"]);
commit_change(&repo, "default work\n");
let landed = head_oid(&repo);
let mut err: Vec<u8> = Vec::new();
let result = push_once(&push_git(&repo), &format!("refs/heads/{default}"), &landed, &mut err).expect("push");
let shown = String::from_utf8_lossy(&err).into_owned();
assert_eq!(result.outcome, AutoPushOutcome::Pushed, "{shown}");
assert_eq!(remote_tip(&remote, &default).as_deref(), Some(landed.as_str()), "the tracked branch reached the remote: {shown}");
remove(&root);
}
const TRIANGULAR_BRANCH: &str = "feature";
const TRIANGULAR_TRACKED: &str = "main";
fn triangular_repo(root: &Path) -> (PathBuf, PathBuf, PathBuf, String) {
let origin = root.join("origin.git");
let upstream = root.join("upstream.git");
let repo = root.join("repo");
bare_remote_with_hook(&origin);
bare_remote_with_hook(&upstream);
init_repo_with_commit(&repo);
git(&repo, &["remote", "add", "origin", origin.to_str().expect("utf-8")]);
git(&repo, &["remote", "add", "upstream", upstream.to_str().expect("utf-8")]);
git(&repo, &["push", "--quiet", "upstream", &format!("HEAD:refs/heads/{TRIANGULAR_TRACKED}")]);
git(&repo, &["fetch", "--quiet", "upstream"]);
git(&repo, &["checkout", "--quiet", "-b", TRIANGULAR_BRANCH, "--track", &format!("upstream/{TRIANGULAR_TRACKED}")]);
git(&repo, &["config", &format!("branch.{TRIANGULAR_BRANCH}.pushRemote"), "origin"]);
commit_change(&repo, "feature work\n");
let landed = head_oid(&repo);
return (repo, origin, upstream, landed);
}
fn remote_branches(remote: &Path) -> String {
return String::from_utf8_lossy(&git(remote, &["for-each-ref", "--format=%(refname)=%(objectname)", "refs/heads"]).stdout).into_owned();
}
fn push_triangular(repo: &Path, landed: &str) -> (AutoPushResult, String) {
let (attempted, err) = run_for_branch(repo, &format!("refs/heads/{TRIANGULAR_BRANCH}"), landed);
return (attempted.expect("push"), err);
}
#[test]
fn a_configured_push_refspec_decides_where_a_named_branch_goes() {
let root = fixture("ap-refspec-destination");
let (repo, origin, upstream, landed) = triangular_repo(&root);
let upstream_before = remote_branches(&upstream);
git(&repo, &["config", "remote.origin.push", &format!("refs/heads/{TRIANGULAR_BRANCH}:refs/heads/review/{TRIANGULAR_BRANCH}")]);
let (result, err) = push_triangular(&repo, &landed);
assert_eq!(result.outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(remote_tip(&origin, &format!("review/{TRIANGULAR_BRANCH}")).as_deref(), Some(landed.as_str()), "{err}");
assert_eq!(remote_branches(&origin), format!("refs/heads/review/{TRIANGULAR_BRANCH}={landed}\n"), "nothing else was published");
assert_eq!(remote_branches(&upstream), upstream_before, "the tracked remote is untouched");
remove(&root);
}
#[test]
fn a_glob_push_refspec_maps_a_named_branch_the_way_git_does() {
let root = fixture("ap-refspec-glob");
let (repo, origin, _upstream, landed) = triangular_repo(&root);
git(&repo, &["config", "remote.origin.push", "refs/heads/*:refs/heads/mirror/*"]);
let (result, err) = push_triangular(&repo, &landed);
assert_eq!(result.outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(remote_tip(&origin, &format!("mirror/{TRIANGULAR_BRANCH}")).as_deref(), Some(landed.as_str()), "{err}");
remove(&root);
}
#[test]
fn a_push_refspec_that_does_not_cover_the_branch_pushes_nothing_and_says_so() {
let root = fixture("ap-refspec-uncovered");
let (repo, origin, upstream, landed) = triangular_repo(&root);
let upstream_before = remote_branches(&upstream);
git(&repo, &["config", "remote.origin.push", "HEAD"]);
let (result, err) = push_triangular(&repo, &landed);
assert_eq!(result.outcome, AutoPushOutcome::Skipped, "{err}");
assert!(err.contains("`remote.origin.push` has `HEAD`"), "the note names the refspec it does not interpret: {err}");
assert_eq!(remote_branches(&origin), "", "nothing was published on the push remote");
assert_eq!(remote_branches(&upstream), upstream_before, "the tracked remote is untouched");
remove(&root);
}
#[test]
fn a_matching_push_of_a_branch_the_push_remote_lacks_publishes_nothing() {
let root = fixture("ap-matching-absent");
let (repo, origin, upstream, landed) = triangular_repo(&root);
let upstream_before = remote_branches(&upstream);
git(&repo, &["config", "push.default", "matching"]);
let (result, err) = push_triangular(&repo, &landed);
assert_eq!(result.outcome, AutoPushOutcome::Failed, "{err}");
assert!(err.contains("No refs in common"), "{err}");
assert_eq!(remote_branches(&origin), "", "the push remote gained no branch, least of all one named after the tracked branch");
assert_eq!(remote_branches(&upstream), upstream_before, "the tracked remote is untouched");
remove(&root);
}
#[test]
fn a_matching_push_of_a_branch_the_push_remote_has_goes_to_the_same_name() {
let root = fixture("ap-matching-present");
let (repo, origin, upstream, landed) = triangular_repo(&root);
let upstream_before = remote_branches(&upstream);
git(&repo, &["push", "--quiet", "origin", &format!("HEAD~1:refs/heads/{TRIANGULAR_BRANCH}")]);
git(&repo, &["fetch", "--quiet", "origin"]);
git(&repo, &["config", "push.default", "matching"]);
let (result, err) = push_triangular(&repo, &landed);
assert_eq!(result.outcome, AutoPushOutcome::Pushed, "{err}");
assert_eq!(remote_branches(&origin), format!("refs/heads/{TRIANGULAR_BRANCH}={landed}\n"), "{err}");
assert_eq!(remote_branches(&upstream), upstream_before, "the tracked remote is untouched");
remove(&root);
}
#[test]
fn a_setting_under_which_git_refuses_to_push_the_branch_publishes_nothing() {
for mode in ["nothing", "upstream"] {
let root = fixture(&format!("ap-refused-{mode}"));
let (repo, origin, upstream, landed) = triangular_repo(&root);
let upstream_before = remote_branches(&upstream);
git(&repo, &["config", "push.default", mode]);
let (result, err) = push_triangular(&repo, &landed);
assert_eq!(result.outcome, AutoPushOutcome::Skipped, "{mode}: {err}");
assert!(err.contains(&format!("`push.default` is `{mode}`")), "{mode}: {err}");
assert_eq!(remote_branches(&origin), "", "{mode}: the push remote gained no branch");
assert_eq!(remote_branches(&upstream), upstream_before, "{mode}: the tracked remote is untouched");
remove(&root);
}
}