use std::io;
use std::path::Path;
use crate::error::Error;
use crate::shared::run_git;
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(|source| Error::Io {
path: git_dir.to_owned(),
source,
})?;
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) -> Result<(), Error> {
run_git(
git_dir,
&["fetch", "--prune", remote, "+refs/heads/*:refs/heads/*"],
)
.map(|_| ())
}
pub fn remote_head(git_dir: &Path, branch: &str) -> Result<String, Error> {
let refname = format!("refs/heads/{branch}");
run_git(git_dir, &["rev-parse", "--verify", &refname]).map(|stdout| stdout.trim().to_owned())
}
pub fn worktree_add(git_dir: &Path, at: &Path, sha: &str) -> Result<(), Error> {
let at = path_str(at)?;
run_git(git_dir, &["worktree", "add", 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 super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn run(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn fixture_repo_with_commits(commits: u32) -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
run(dir.path(), &["init", "-q", "-b", "main"]);
run(dir.path(), &["config", "user.email", "test@example.com"]);
run(dir.path(), &["config", "user.name", "test"]);
for n in 0..commits {
fs::write(dir.path().join(format!("file-{n}.txt")), "x").expect("write fixture file");
run(dir.path(), &["add", "."]);
run(dir.path(), &["commit", "-q", "-m", &format!("commit {n}")]);
}
dir
}
fn detach_head(repo: &TempDir) {
run(repo.path(), &["checkout", "-q", "--detach", "HEAD"]);
}
fn bare_git_dir() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
run(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);
run(
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")).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).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");
run(origin.path(), &["add", "."]);
run(origin.path(), &["commit", "-q", "-m", "second"]);
fetch(git_dir.path(), url).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);
run(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).expect("first fetch sees feature");
remote_head(git_dir.path(), "feature").expect("feature resolves before deletion");
run(origin.path(), &["branch", "-D", "feature"]);
fetch(git_dir.path(), url).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 { .. }));
}
#[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")).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 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"));
}
}