#![cfg(unix)]
use std::path::Path;
use std::process::Command;
use grit_lib::fetch::NoProgress;
use grit_lib::objects::ObjectId;
use grit_lib::odb::Odb;
use grit_lib::push::push_remote;
use grit_lib::push_report::PushRefStatus;
use grit_lib::refs::resolve_ref;
use grit_lib::transfer::{PushOptions, PushRefSpec};
use grit_lib::transport::{ConnectOptions, Service, SshTransport, Transport};
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_AUTHOR_NAME", "T")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_AUTHOR_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_COMMITTER_NAME", "T")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.env("GIT_COMMITTER_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("utf8 git output")
}
fn rev_parse(dir: &Path, rev: &str) -> ObjectId {
ObjectId::from_hex(git(dir, &["rev-parse", rev]).trim()).expect("valid oid")
}
fn open_odb(git_dir: &Path) -> Odb {
Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.to_path_buf())
}
fn build_source(dir: &Path) {
git(dir, &["init", "-q", "-b", "main", "."]);
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
git(dir, &["add", "a.txt"]);
git(dir, &["commit", "-q", "-m", "c1"]);
std::fs::write(dir.join("b.txt"), "two\n").unwrap();
git(dir, &["add", "b.txt"]);
git(dir, &["commit", "-q", "-m", "c2"]);
}
fn write_fake_ssh(dir: &Path) -> Option<std::path::PathBuf> {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("fake-ssh.sh");
let body = r#"#!/bin/sh
# Fake ssh: ignore host/options, run the remote command locally.
# The remote command is always the last argument.
cmd=
for cmd in "$@"; do :; done
# Rewrite the dashed transport name to the `git <service>` subcommand form.
case "$cmd" in
"git-upload-pack "*) cmd="git upload-pack ${cmd#git-upload-pack }" ;;
"git-receive-pack "*) cmd="git receive-pack ${cmd#git-receive-pack }" ;;
esac
eval "exec $cmd" 2>/dev/null
"#;
std::fs::write(&script, body).ok()?;
let mut perms = std::fs::metadata(&script).ok()?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).ok()?;
Some(script)
}
fn spec_update(src: ObjectId, dst: &str, force: bool) -> PushRefSpec {
PushRefSpec {
src: Some(src),
dst: dst.to_owned(),
force,
delete: false,
expected_old: None,
expect_absent: false,
}
}
#[test]
fn push_over_ssh_lands_ref_and_objects_and_reports_nonff_rejection() {
if Command::new("sh").arg("-c").arg("exit 0").status().is_err() {
eprintln!("SKIP: no POSIX sh available");
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
build_source(&local);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "refs/heads/main");
let c1_oid = rev_parse(&local, "HEAD~1");
let bare = tmp.path().join("remote.git");
std::fs::create_dir_all(&bare).unwrap();
git(&bare, &["init", "-q", "--bare", "."]);
let Some(fake_ssh) = write_fake_ssh(tmp.path()) else {
eprintln!("SKIP: could not create executable fake-ssh script");
return;
};
let transport = SshTransport::with_program(fake_ssh.as_os_str());
let abs_path = bare.to_str().expect("utf8 path");
let url = format!("ssh://git@fakehost{abs_path}");
let mut conn = transport
.connect(&url, Service::ReceivePack, &ConnectOptions::default())
.expect("SshTransport::connect receive-pack");
assert!(
conn.protocol_version() < 2,
"receive-pack push must be v0/v1, got v{}",
conn.protocol_version()
);
assert!(
conn.advertised_refs().is_empty(),
"empty remote should advertise no refs, got {:?}",
conn.advertised_refs()
);
let outcome = push_remote(
&local_git,
&mut *conn,
&[spec_update(main_oid, "refs/heads/main", false)],
&PushOptions::default(),
&mut NoProgress,
)
.expect("push_remote over ssh");
drop(conn);
assert_eq!(outcome.results.len(), 1);
let r = &outcome.results[0];
assert_eq!(
r.status,
PushRefStatus::Ok,
"push of new ref should be accepted, got {:?} ({:?})",
r.status,
r.message
);
assert_eq!(r.new_oid, Some(main_oid));
assert!(r.old_oid.is_none(), "new ref has no old value");
let remote_main = resolve_ref(&bare, "refs/heads/main").expect("remote main written");
assert_eq!(remote_main, main_oid, "remote main oid mismatch");
let remote_odb = open_odb(&bare);
for oid in [main_oid, c1_oid] {
assert!(
remote_odb.exists(&oid),
"object {} missing from remote odb after ssh push",
oid.to_hex()
);
remote_odb
.read(&oid)
.unwrap_or_else(|e| panic!("read {}: {e}", oid.to_hex()));
}
let fsck = Command::new("git")
.current_dir(&bare)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed after ssh push: {}\n{}",
String::from_utf8_lossy(&fsck.stdout),
String::from_utf8_lossy(&fsck.stderr)
);
git(&local, &["checkout", "-q", "-b", "diverge", "HEAD~1"]);
std::fs::write(local.join("c.txt"), "three\n").unwrap();
git(&local, &["add", "c.txt"]);
git(&local, &["commit", "-q", "-m", "divergent"]);
let diverged = rev_parse(&local, "HEAD");
assert_ne!(diverged, main_oid);
let mut conn2 = transport
.connect(&url, Service::ReceivePack, &ConnectOptions::default())
.expect("reconnect for non-ff ssh push");
assert!(
conn2
.advertised_refs()
.iter()
.any(|(n, o)| n == "refs/heads/main" && *o == main_oid),
"advertisement should report remote main at {}",
main_oid.to_hex()
);
let outcome2 = push_remote(
&local_git,
&mut *conn2,
&[spec_update(diverged, "refs/heads/main", false)],
&PushOptions::default(),
&mut NoProgress,
)
.expect("non-ff push_remote over ssh completes");
drop(conn2);
assert_eq!(outcome2.results.len(), 1);
let r2 = &outcome2.results[0];
assert!(
r2.status.is_error(),
"non-fast-forward push must be rejected, got {:?}",
r2.status
);
assert_eq!(
r2.status,
PushRefStatus::RejectNonFastForward,
"client-side non-ff detection should reject before sending"
);
let remote_main_after = resolve_ref(&bare, "refs/heads/main").expect("remote main still set");
assert_eq!(
remote_main_after, main_oid,
"rejected non-ff ssh push must not move the remote ref"
);
let mut conn3 = transport
.connect(&url, Service::ReceivePack, &ConnectOptions::default())
.expect("reconnect for forced ssh push");
let outcome3 = push_remote(
&local_git,
&mut *conn3,
&[spec_update(diverged, "refs/heads/main", true)],
&PushOptions::default(),
&mut NoProgress,
)
.expect("forced push_remote over ssh completes");
drop(conn3);
assert_eq!(outcome3.results.len(), 1);
let r3 = &outcome3.results[0];
assert_eq!(
r3.status,
PushRefStatus::Ok,
"forced push should be accepted, got {:?} ({:?})",
r3.status,
r3.message
);
assert!(r3.forced, "forced update should be flagged forced");
assert_eq!(r3.new_oid, Some(diverged));
let remote_main_forced =
resolve_ref(&bare, "refs/heads/main").expect("remote main after force");
assert_eq!(
remote_main_forced, diverged,
"forced ssh push must advance the remote ref to the divergent tip"
);
let fsck2 = Command::new("git")
.current_dir(&bare)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck2.status.success(),
"git fsck failed after forced ssh push: {}",
String::from_utf8_lossy(&fsck2.stderr)
);
}