use std::io;
use std::path::Path;
use std::time::Duration;
use crate::error::Error;
use crate::shared::{run_git, run_git_within};
fn path_str(path: &Path) -> Result<&str, Error> {
path.to_str().ok_or_else(|| Error::Io {
path: path.to_owned(),
source: io::Error::other("path is not valid UTF-8"),
})
}
pub fn init_bare(git_dir: &Path) -> Result<(), Error> {
std::fs::create_dir_all(git_dir).map_err(Error::at(git_dir))?;
run_git(git_dir, &["init", "-q", "--bare"]).map(|_| ())
}
pub fn remote_url(checkout: &Path) -> Result<String, Error> {
run_git(checkout, &["remote", "get-url", "origin"]).map(|stdout| stdout.trim().to_owned())
}
pub fn current_branch(checkout: &Path) -> Result<String, Error> {
match run_git(checkout, &["symbolic-ref", "--short", "HEAD"]) {
Ok(stdout) => Ok(stdout.trim().to_owned()),
Err(Error::Git {
command,
status,
stderr,
}) if stderr.contains("not a symbolic ref") => Err(Error::Git {
command,
status,
stderr: format!(
"HEAD is detached, not on a branch - there is nothing to track: {stderr}"
),
}),
Err(other) => Err(other),
}
}
pub fn fetch(git_dir: &Path, remote: &str, tracked: &str, budget: Duration) -> Result<(), Error> {
let tracked_ref = refname(tracked);
let tracked_refspec = format!("+{tracked_ref}:{tracked_ref}");
let mut args = vec![
"-c",
"protocol.ext.allow=never",
"fetch",
"--prune",
"--",
remote,
"+refs/heads/*:refs/heads/*",
];
if !tracked_ref.starts_with("refs/heads/") {
args.push(&tracked_refspec);
}
run_git_within(git_dir, &args, budget).map(|_| ())
}
#[must_use]
pub fn refname(tracked: &str) -> String {
if tracked.starts_with("refs/") {
tracked.to_owned()
} else {
format!("refs/heads/{tracked}")
}
}
pub fn remote_head(git_dir: &Path, branch: &str) -> Result<String, Error> {
let commit = format!("{}^{{commit}}", refname(branch));
run_git(git_dir, &["rev-parse", "--verify", &commit]).map(|stdout| stdout.trim().to_owned())
}
pub fn changed(git_dir: &Path, from: &str, to: &str, paths: &[String]) -> Result<bool, Error> {
let mut args = vec!["diff", "--name-only", from, to, "--"];
args.extend(paths.iter().map(String::as_str));
run_git(git_dir, &args).map(|stdout| !stdout.trim().is_empty())
}
pub fn worktree_add(git_dir: &Path, at: &Path, sha: &str) -> Result<(), Error> {
let at = path_str(at)?;
run_git(git_dir, &["worktree", "add", "--detach", "--", at, sha]).map(|_| ())
}
pub fn worktree_remove(git_dir: &Path, at: &Path) -> Result<(), Error> {
let at = path_str(at)?;
run_git(git_dir, &["worktree", "remove", "--force", "--", at]).map(|_| ())
}
pub fn worktree_prune(git_dir: &Path) -> Result<(), Error> {
run_git(git_dir, &["worktree", "prune"]).map(|_| ())
}
#[cfg(test)]
mod tests {
use crate::fixtures;
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn fixture_repo_with_commits(commits: u32) -> TempDir {
let dir = fixtures::empty_checkout();
for n in 0..commits {
let name = format!("file-{n}.txt");
fixtures::commit(dir.path(), &[(&name, "x")], &format!("commit {n}"));
}
dir
}
fn detach_head(repo: &TempDir) {
fixtures::run_git(repo.path(), &["checkout", "-q", "--detach", "HEAD"]);
}
fn bare_git_dir() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
fixtures::run_git(dir.path(), &["init", "-q", "--bare"]);
dir
}
#[test]
fn a_detached_head_is_refused_by_name() {
let repo = fixture_repo_with_commits(2);
detach_head(&repo);
let err = current_branch(repo.path()).expect_err("refuses");
assert!(err.to_string().to_lowercase().contains("detached"));
}
#[test]
fn a_non_repository_failure_is_not_mislabelled_as_detached() {
let dir = tempfile::tempdir().expect("tempdir, deliberately never `git init`-ed");
let err = current_branch(dir.path()).expect_err("refuses");
assert!(!err.to_string().to_lowercase().contains("detached"));
}
#[test]
fn current_branch_names_the_checked_out_branch() {
let repo = fixture_repo_with_commits(1);
assert_eq!(current_branch(repo.path()).expect("on a branch"), "main");
}
#[test]
fn remote_url_reads_the_origin_remote() {
let repo = fixture_repo_with_commits(1);
fixtures::run_git(
repo.path(),
&["remote", "add", "origin", "https://example.com/x.git"],
);
assert_eq!(
remote_url(repo.path()).expect("origin is configured"),
"https://example.com/x.git"
);
}
#[test]
fn remote_url_refuses_a_checkout_with_no_origin() {
let repo = fixture_repo_with_commits(1);
let err = remote_url(repo.path()).expect_err("no origin configured");
assert!(matches!(err, Error::Git { .. }));
}
#[test]
fn fetch_mirrors_the_remotes_branches_onto_refs_heads() {
let origin = fixture_repo_with_commits(1);
let git_dir = bare_git_dir();
fetch(
git_dir.path(),
origin.path().to_str().expect("utf-8 path"),
"main",
fixtures::TEST_BUDGET,
)
.expect("fetches");
let expected = origin.path();
let sha = String::from_utf8(
Command::new("git")
.current_dir(expected)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse origin HEAD")
.stdout,
)
.expect("utf-8 sha");
assert_eq!(
remote_head(git_dir.path(), "main").expect("resolves"),
sha.trim()
);
}
#[test]
fn a_second_fetch_moves_an_already_mirrored_branch_forward() {
let origin = fixture_repo_with_commits(1);
let git_dir = bare_git_dir();
let url = origin.path().to_str().expect("utf-8 path");
fetch(git_dir.path(), url, "main", fixtures::TEST_BUDGET).expect("first fetch");
let first = remote_head(git_dir.path(), "main").expect("resolves after first fetch");
fs::write(origin.path().join("second.txt"), "y").expect("write second commit's file");
fixtures::run_git(origin.path(), &["add", "."]);
fixtures::run_git(origin.path(), &["commit", "-q", "-m", "second"]);
fetch(git_dir.path(), url, "main", fixtures::TEST_BUDGET).expect("second fetch");
let second = remote_head(git_dir.path(), "main").expect("resolves after second fetch");
assert_ne!(first, second);
}
#[test]
fn a_branch_deleted_upstream_stops_resolving_after_a_pruning_fetch() {
let origin = fixture_repo_with_commits(1);
fixtures::run_git(origin.path(), &["branch", "feature"]);
let git_dir = bare_git_dir();
let url = origin.path().to_str().expect("utf-8 path");
fetch(git_dir.path(), url, "main", fixtures::TEST_BUDGET)
.expect("first fetch sees feature");
remote_head(git_dir.path(), "feature").expect("feature resolves before deletion");
fixtures::run_git(origin.path(), &["branch", "-D", "feature"]);
fetch(git_dir.path(), url, "main", fixtures::TEST_BUDGET)
.expect("second fetch prunes feature");
let err =
remote_head(git_dir.path(), "feature").expect_err("a deleted branch must not resolve");
assert!(matches!(err, Error::Git { .. }));
}
fn publish_ref(origin: &TempDir, refname: &str) {
fixtures::run_git(origin.path(), &["update-ref", refname, "HEAD"]);
}
#[test]
fn a_pull_ref_is_fetched_without_every_other_pull_ref() {
let origin = fixture_repo_with_commits(1);
publish_ref(&origin, "refs/pull/1/head");
publish_ref(&origin, "refs/pull/2/head");
let git_dir = bare_git_dir();
let url = origin.path().to_str().expect("utf-8 path");
fetch(
git_dir.path(),
url,
"refs/pull/1/head",
fixtures::TEST_BUDGET,
)
.expect("fetches");
assert_eq!(
remote_head(git_dir.path(), "refs/pull/1/head").expect("the tracked ref lands"),
fixtures::head_of(origin.path())
);
let err = remote_head(git_dir.path(), "refs/pull/2/head")
.expect_err("a ref nobody tracks must not land");
assert!(matches!(err, Error::Git { .. }), "{err}");
}
#[test]
fn a_pull_ref_follows_a_force_push() {
let origin = fixture_repo_with_commits(2);
fixtures::run_git(origin.path(), &["update-ref", "refs/pull/1/head", "HEAD~1"]);
let git_dir = bare_git_dir();
let url = origin.path().to_str().expect("utf-8 path");
fetch(
git_dir.path(),
url,
"refs/pull/1/head",
fixtures::TEST_BUDGET,
)
.expect("first");
let first = remote_head(git_dir.path(), "refs/pull/1/head").expect("resolves");
fixtures::run_git(origin.path(), &["checkout", "-q", "-b", "other", "HEAD~1"]);
fixtures::commit(origin.path(), &[("other.txt", "y")], "rewritten");
fixtures::run_git(origin.path(), &["update-ref", "refs/pull/1/head", "HEAD"]);
fetch(
git_dir.path(),
url,
"refs/pull/1/head",
fixtures::TEST_BUDGET,
)
.expect("second");
let second = remote_head(git_dir.path(), "refs/pull/1/head").expect("resolves");
assert_ne!(first, second);
assert_eq!(second, fixtures::head_of(origin.path()));
}
#[test]
fn a_tag_ref_resolves_to_the_commit_it_tags() {
let origin = fixture_repo_with_commits(1);
fixtures::run_git(origin.path(), &["tag", "-a", "-m", "v1", "v1"]);
let git_dir = bare_git_dir();
let url = origin.path().to_str().expect("utf-8 path");
fetch(git_dir.path(), url, "refs/tags/v1", fixtures::TEST_BUDGET).expect("fetches");
assert_eq!(
remote_head(git_dir.path(), "refs/tags/v1").expect("resolves"),
fixtures::head_of(origin.path()),
"an annotated tag must peel to its commit, not name the tag object"
);
}
#[test]
fn a_branch_name_still_resolves_under_refs_heads() {
assert_eq!(refname("main"), "refs/heads/main");
assert_eq!(refname("feature/x"), "refs/heads/feature/x");
}
#[test]
fn a_full_ref_resolves_as_given() {
assert_eq!(refname("refs/pull/1/head"), "refs/pull/1/head");
assert_eq!(refname("refs/tags/v1"), "refs/tags/v1");
}
#[test]
fn a_ref_the_remote_lacks_fails_the_fetch_with_gits_own_message() {
let origin = fixture_repo_with_commits(1);
let git_dir = bare_git_dir();
let url = origin.path().to_str().expect("utf-8 path");
let err = fetch(
git_dir.path(),
url,
"refs/pull/9/head",
fixtures::TEST_BUDGET,
)
.expect_err("the remote has no such ref");
assert!(
matches!(&err, Error::Git { stderr, .. } if stderr.contains("couldn't find remote ref refs/pull/9/head")),
"{err}"
);
}
#[test]
fn a_remote_beginning_with_a_dash_is_a_url_not_an_option() {
let git_dir = bare_git_dir();
let marker = git_dir.path().join("injected");
let remote = format!("--upload-pack=touch {}", marker.display());
let err = fetch(git_dir.path(), &remote, "main", fixtures::TEST_BUDGET)
.expect_err("no such repository");
assert!(matches!(err, Error::Git { .. }), "{err}");
assert!(
!marker.exists(),
"the remote must never be run as a command"
);
}
#[test]
fn the_ext_transport_never_runs_a_command() {
let git_dir = bare_git_dir();
fixtures::run_git(git_dir.path(), &["config", "protocol.ext.allow", "always"]);
let marker = git_dir.path().join("ext-ran");
let remote = format!("ext::sh -c 'touch {}'", marker.display());
let err =
fetch(git_dir.path(), &remote, "main", fixtures::TEST_BUDGET).expect_err("refused");
assert!(
matches!(&err, Error::Git { stderr, .. } if stderr.contains("ext")),
"{err}"
);
assert!(!marker.exists(), "the transport must never be run");
}
#[test]
fn a_worktree_is_added_detached_even_when_given_a_branch_name() {
let repo = fixture_repo_with_commits(1);
let first = repo.path().join("wt-first");
let second = repo.path().join("wt-second");
worktree_add(repo.path(), &first, "main").expect("first add");
worktree_add(repo.path(), &second, "main").expect("a second add of the same branch");
worktree_remove(repo.path(), &first).expect("cleans up");
worktree_remove(repo.path(), &second).expect("cleans up");
}
#[test]
fn remote_head_refuses_an_unknown_branch() {
let origin = fixture_repo_with_commits(1);
let git_dir = bare_git_dir();
fetch(
git_dir.path(),
origin.path().to_str().expect("utf-8 path"),
"main",
fixtures::TEST_BUDGET,
)
.expect("fetches");
let err = remote_head(git_dir.path(), "no-such-branch").expect_err("no such branch");
assert!(matches!(err, Error::Git { .. }));
}
#[test]
fn worktree_add_checks_out_the_given_sha() {
let repo = fixture_repo_with_commits(1);
let at = repo.path().join("wt-checkout");
let sha = String::from_utf8(
Command::new("git")
.current_dir(repo.path())
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse HEAD")
.stdout,
)
.expect("utf-8 sha");
worktree_add(repo.path(), &at, sha.trim()).expect("adds");
assert!(at.join("file-0.txt").exists());
worktree_remove(repo.path(), &at).expect("cleans up");
}
#[test]
fn worktree_removal_forces_because_built_trees_are_dirty() {
let repo = fixture_repo_with_commits(1);
let at = repo.path().join("rel-abc");
worktree_add(repo.path(), &at, "HEAD").expect("adds");
std::fs::write(at.join("build-output.txt"), "x").expect("writes");
worktree_remove(repo.path(), &at).expect("removes a dirty tree");
assert!(!at.exists());
}
#[test]
fn changed_is_false_for_equal_trees_and_true_for_a_touched_path() {
let repo = fixtures::checkout(&[("cmd/a/main.go", "1"), ("cmd/b/main.go", "1")]);
let first = fixtures::head_of(repo.path());
fixtures::commit(repo.path(), &[("cmd/b/main.go", "2")], "touch b");
let second = fixtures::head_of(repo.path());
let a = vec!["cmd/a".to_owned()];
let b = vec!["cmd/b".to_owned()];
assert!(!changed(repo.path(), &first, &first, &b).expect("equal trees"));
assert!(!changed(repo.path(), &first, &second, &a).expect("a is untouched"));
assert!(changed(repo.path(), &first, &second, &b).expect("b is touched"));
let missing = "0".repeat(40);
let err = changed(repo.path(), &missing, &second, &b).expect_err("no such commit");
assert!(matches!(err, Error::Git { .. }), "{err}");
}
#[test]
fn worktree_prune_clears_a_worktree_whose_directory_is_already_gone() {
let repo = fixture_repo_with_commits(1);
let at = repo.path().join("wt-vanished");
worktree_add(repo.path(), &at, "HEAD").expect("adds");
fs::remove_dir_all(&at).expect("simulate the directory vanishing on its own");
let before = run_git(repo.path(), &["worktree", "list"]).expect("lists");
assert!(before.contains("wt-vanished"));
worktree_prune(repo.path()).expect("prunes");
let after = run_git(repo.path(), &["worktree", "list"]).expect("lists");
assert!(!after.contains("wt-vanished"));
}
}