use super::commit::{assert_nothing_left, git_text, keep_maintenance_in_the_foreground, remove_settled, wrapped_commit};
use super::support::{Fixture, Observed, executable, fixture, git, observe, repository, stderr_of};
use std::ffi::OsStr;
use std::fs::File;
use std::io::Read;
use std::net::{TcpListener, TcpStream};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread::JoinHandle;
use std::time::Duration;
const TRIPWIRE_DESCRIPTOR: RawFd = 9;
const DEADLINE_MILLISECONDS: libc::c_int = 20_000;
struct Tripwire {
read_end: File,
write_end: Option<OwnedFd>,
}
impl Tripwire {
fn new() -> Tripwire {
let mut ends: [libc::c_int; 2] = [0, 0];
let made: libc::c_int = unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC) };
assert_eq!(made, 0, "pipe2 failed: {}", std::io::Error::last_os_error());
let (read_end, write_end): (OwnedFd, OwnedFd) = unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
return Tripwire { read_end: File::from(read_end), write_end: Some(write_end) };
}
fn arm(&self, command: &mut Command) {
let held: RawFd = self.write_end.as_ref().expect("the write end is open").as_raw_fd();
unsafe {
command.pre_exec(move || {
if libc::dup2(held, TRIPWIRE_DESCRIPTOR) < 0 {
return Err(std::io::Error::last_os_error());
}
return Ok(());
});
}
}
fn released(mut self) -> bool {
self.write_end = None;
let mut watched: libc::pollfd = libc::pollfd { fd: self.read_end.as_raw_fd(), events: libc::POLLIN, revents: 0 };
let ready: libc::c_int = unsafe { libc::poll(&raw mut watched, 1, DEADLINE_MILLISECONDS) };
let mut byte: [u8; 1] = [0];
return ready == 1 && self.read_end.read(&mut byte).is_ok_and(|count| { return count == 0; });
}
}
#[derive(Clone, Copy, Debug)]
enum Behavior {
HangUp,
Silent,
}
struct Server {
port: u16,
handled: JoinHandle<Option<bool>>,
}
fn serve(behavior: Behavior) -> Server {
let listener: TcpListener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let port: u16 = listener.local_addr().expect("local address").port();
let handled: JoinHandle<Option<bool>> = std::thread::spawn(move || {
let mut watched: libc::pollfd = libc::pollfd { fd: listener.as_raw_fd(), events: libc::POLLIN, revents: 0 };
let ready: libc::c_int = unsafe { libc::poll(&raw mut watched, 1, DEADLINE_MILLISECONDS) };
if ready != 1 {
return None;
}
let (mut connection, _peer): (TcpStream, _) = listener.accept().expect("accept");
if matches!(behavior, Behavior::HangUp) {
return Some(true);
}
connection.set_read_timeout(Some(Duration::from_millis(u64::try_from(DEADLINE_MILLISECONDS).expect("positive")))).expect("read timeout");
let mut chunk: [u8; 4096] = [0; 4096];
loop {
match connection.read(&mut chunk) {
Ok(0) => return Some(true),
Ok(_) => {}
Err(_) => return Some(false),
}
}
});
return Server { port, handled };
}
fn world(fixture: &Fixture, url: &str, hanging_lfs: bool) -> PathBuf {
let repo: PathBuf = repository(fixture, OsStr::new("repo"));
git(fixture, repo.as_path(), &["remote", "add", "origin", url]);
keep_maintenance_in_the_foreground(fixture, repo.as_path());
if hanging_lfs {
git(fixture, repo.as_path(), &["config", "lfs.url", "http://127.0.0.1:9/never-contacted"]);
executable(fixture.root.join("bin/git-lfs").as_path(), b"#!/bin/sh\nexec sleep 600\n");
}
std::fs::write(repo.join("a.txt"), b"one\n").expect("a.txt");
git(fixture, repo.as_path(), &["add", "--", "a.txt"]);
return repo;
}
fn locks_left(repo: &Path) -> Vec<String> {
let mut found: Vec<String> = Vec::new();
let mut pending: Vec<PathBuf> = std::fs::read_dir(repo.join(".git"))
.expect("git directory")
.flatten()
.map(|entry| { return entry.path(); })
.filter(|path| { return path.file_name().is_some_and(|name| { return name.to_string_lossy().starts_with("cli-git"); }); })
.collect();
while let Some(path) = pending.pop() {
if path.file_name().is_some_and(|name| { return name.to_string_lossy().contains(".lock"); }) {
found.push(path.display().to_string());
}
if path.is_dir()
&& let Ok(entries) = std::fs::read_dir(&path)
{
pending.extend(entries.flatten().map(|entry| { return entry.path(); }));
}
}
return found;
}
fn commit_with_tripwire(fixture: &Fixture, repo: &Path, deadline: &str) -> (Observed, Tripwire) {
let tripwire: Tripwire = Tripwire::new();
let mut command: Command = wrapped_commit(fixture, repo, &["commit", "--quiet", "--message=one", "--", "a.txt"]);
command.env("CLI_GIT_PUSH_DEADLINE_SECONDS", deadline);
tripwire.arm(&mut command);
let landed: Observed = observe(&mut command, b"");
return (landed, tripwire);
}
fn assert_push_failure_is_contained(fixture: &Fixture, repo: &Path, landed: &Observed, tripwire: Tripwire, expected: &[&str]) {
let stderr: String = stderr_of(landed);
assert_eq!(landed.code, Some(0), "the commit itself succeeds: {stderr}");
assert!(stderr.contains("auto-push to origin failed"), "the failure is reported: {stderr}");
assert!(
expected.iter().any(|words| { return stderr.contains(words); }),
"the report names the cause (one of {expected:?}): {stderr}"
);
assert_eq!(git_text(fixture, repo, &["rev-list", "--count", "HEAD"]).trim(), "2", "the commit is saved locally");
assert!(tripwire.released(), "a process the push started was still running after the command returned");
assert_eq!(locks_left(repo), Vec::<String>::new(), "no lock stays held");
assert_nothing_left(fixture, repo);
std::fs::write(repo.join("b.txt"), b"two\n").expect("b.txt");
git(fixture, repo, &["add", "--", "b.txt"]);
let mut next: Command = wrapped_commit(fixture, repo, &["commit", "--quiet", "--message=two", "--", "b.txt"]);
next.env("CLI_GIT_PUSH_DEADLINE_SECONDS", "1");
let again: Observed = observe(&mut next, b"");
assert_eq!(again.code, Some(0), "the next commit lands: {}", stderr_of(&again));
assert!(!stderr_of(&again).contains("waiting for"), "the next commit waited for a lock: {}", stderr_of(&again));
}
#[test]
fn an_auto_push_to_a_refused_connection_keeps_the_commit_and_holds_nothing() {
let fixture: Fixture = fixture("remote-refused");
let repo: PathBuf = world(&fixture, "http://127.0.0.1:9/never.git", false);
let (landed, tripwire) = commit_with_tripwire(&fixture, repo.as_path(), "30");
assert_push_failure_is_contained(&fixture, repo.as_path(), &landed, tripwire, &["Failed to connect", "Connection refused", "couldn't connect"]);
remove_settled(&fixture);
}
#[test]
fn an_auto_push_to_an_unresolvable_host_keeps_the_commit_and_holds_nothing() {
let fixture: Fixture = fixture("remote-dns");
let repo: PathBuf = world(&fixture, "https://cli-git-fault.invalid/never.git", false);
let (landed, tripwire) = commit_with_tripwire(&fixture, repo.as_path(), "30");
assert_push_failure_is_contained(
&fixture,
repo.as_path(),
&landed,
tripwire,
&["Could not resolve host", "resolve", "did not finish within"],
);
remove_settled(&fixture);
}
#[test]
fn an_auto_push_to_a_server_that_hangs_up_keeps_the_commit_and_holds_nothing() {
let fixture: Fixture = fixture("remote-hangup");
let server: Server = serve(Behavior::HangUp);
let repo: PathBuf = world(&fixture, format!("http://127.0.0.1:{}/never.git", server.port).as_str(), false);
let (landed, tripwire) = commit_with_tripwire(&fixture, repo.as_path(), "30");
assert_push_failure_is_contained(
&fixture,
repo.as_path(),
&landed,
tripwire,
&["Empty reply", "RPC failed", "Connection reset", "unable to access", "did not finish within"],
);
remove_settled(&fixture);
}
#[test]
fn an_auto_push_to_a_server_that_never_answers_is_stopped_at_the_deadline() {
let fixture: Fixture = fixture("remote-silent");
let server: Server = serve(Behavior::Silent);
let repo: PathBuf = world(&fixture, format!("http://127.0.0.1:{}/never.git", server.port).as_str(), false);
let (landed, tripwire) = commit_with_tripwire(&fixture, repo.as_path(), "1");
assert_push_failure_is_contained(&fixture, repo.as_path(), &landed, tripwire, &["did not finish within 1 seconds and was stopped"]);
assert_eq!(server.handled.join().expect("server thread"), Some(true), "the client closed the connection when it was stopped");
remove_settled(&fixture);
}
#[test]
fn an_auto_push_with_a_git_lfs_that_never_exits_is_stopped_with_everything_it_started() {
let fixture: Fixture = fixture("remote-lfs-silent");
let bare: PathBuf = fixture.root.join("origin.git");
git(&fixture, fixture.root.as_path(), &["init", "--quiet", "--bare", "--initial-branch=main", "origin.git"]);
let repo: PathBuf = world(&fixture, bare.to_str().expect("UTF-8"), true);
let (landed, tripwire) = commit_with_tripwire(&fixture, repo.as_path(), "1");
assert_push_failure_is_contained(&fixture, repo.as_path(), &landed, tripwire, &["did not finish within 1 seconds and was stopped"]);
remove_settled(&fixture);
}
fn explicit_push_fails_cleanly(name: &str, url: &str) {
let fixture: Fixture = fixture(name);
let repo: PathBuf = world(&fixture, url, false);
git(&fixture, repo.as_path(), &["commit", "--quiet", "--message=one", "--", "a.txt"]);
let tripwire: Tripwire = Tripwire::new();
let mut command: Command = wrapped_commit(&fixture, repo.as_path(), &["push", "origin", "main"]);
tripwire.arm(&mut command);
let pushed: Observed = observe(&mut command, b"");
assert_ne!(pushed.code, Some(0), "an unreachable remote cannot be pushed to");
assert!(pushed.code.is_some(), "the push was ended by a signal: {}", stderr_of(&pushed));
assert!(!stderr_of(&pushed).trim().is_empty(), "a failed push says why");
assert!(tripwire.released(), "a process the push started was still running after the command returned");
assert_eq!(locks_left(repo.as_path()), Vec::<String>::new(), "no lock stays held");
assert_nothing_left(&fixture, repo.as_path());
remove_settled(&fixture);
}
#[test]
fn an_explicit_push_to_a_refused_connection_fails_cleanly() {
explicit_push_fails_cleanly("remote-push-refused", "http://127.0.0.1:9/never.git");
}
#[test]
fn an_explicit_push_to_an_unresolvable_host_fails_cleanly() {
explicit_push_fails_cleanly("remote-push-dns", "https://cli-git-fault.invalid/never.git");
}
#[test]
fn an_explicit_push_to_a_server_that_hangs_up_fails_cleanly() {
let server: Server = serve(Behavior::HangUp);
explicit_push_fails_cleanly("remote-push-hangup", format!("http://127.0.0.1:{}/never.git", server.port).as_str());
}