use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("could not run git: {0}")]
Spawn(#[source] std::io::Error),
#[error("git {args} failed in {repo}: {stderr}")]
Failed {
repo: PathBuf,
args: String,
stderr: String,
},
#[error("{0} has no default branch and no checked-out branch")]
NoDefaultBranch(PathBuf),
}
pub type Result<T> = std::result::Result<T, Error>;
const FALLBACK_DEFAULTS: &[&str] = &["main", "master", "trunk", "develop"];
fn output<S: AsRef<OsStr>>(repo: &Path, args: &[S]) -> Result<(bool, String)> {
let result = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(Error::Spawn)?;
Ok((
result.status.success(),
String::from_utf8_lossy(&result.stdout).into_owned(),
))
}
pub fn run_raw<S: AsRef<OsStr>>(repo: &Path, args: &[S]) -> Result<String> {
let result = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(Error::Spawn)?;
if !result.status.success() {
return Err(Error::Failed {
repo: repo.to_path_buf(),
args: args
.iter()
.map(|a| a.as_ref().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" "),
stderr: String::from_utf8_lossy(&result.stderr).trim().to_string(),
});
}
Ok(String::from_utf8_lossy(&result.stdout).into_owned())
}
pub fn run<S: AsRef<OsStr>>(repo: &Path, args: &[S]) -> Result<String> {
Ok(run_raw(repo, args)?.trim().to_string())
}
fn succeeds<S: AsRef<OsStr>>(repo: &Path, args: &[S]) -> Result<bool> {
match run(repo, args) {
Ok(_) => Ok(true),
Err(Error::Failed { .. }) => Ok(false),
Err(other) => Err(other),
}
}
pub fn is_repo(path: &Path) -> bool {
run(path, &["rev-parse", "--git-dir"]).is_ok()
}
pub fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
succeeds(
repo,
&[
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
],
)
}
pub fn head_branch(repo: &Path) -> Result<Option<String>> {
match run(repo, &["symbolic-ref", "--short", "--quiet", "HEAD"]) {
Ok(branch) if !branch.is_empty() => Ok(Some(branch)),
Ok(_) => Ok(None),
Err(Error::Failed { .. }) => Ok(None),
Err(other) => Err(other),
}
}
pub fn default_branch(repo: &Path) -> Result<String> {
if let Ok(head) = run(
repo,
&["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
) && let Some(branch) = head.strip_prefix("origin/")
&& !branch.is_empty()
&& branch_exists(repo, branch)?
{
return Ok(branch.to_string());
}
for candidate in FALLBACK_DEFAULTS {
if branch_exists(repo, candidate)? {
return Ok((*candidate).to_string());
}
}
head_branch(repo)?.ok_or_else(|| Error::NoDefaultBranch(repo.to_path_buf()))
}
pub fn worktree_add(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
let mut args = vec![OsStr::new("worktree"), OsStr::new("add")];
if !branch_exists(repo, branch)? {
args.push(OsStr::new("-b"));
args.push(OsStr::new(branch));
args.push(path.as_os_str());
args.push(OsStr::new(base));
} else {
args.push(path.as_os_str());
args.push(OsStr::new(branch));
}
run(repo, &args)?;
Ok(())
}
pub fn worktree_remove(repo: &Path, path: &Path, force: bool) -> Result<()> {
let mut args = vec![OsStr::new("worktree"), OsStr::new("remove")];
if force {
args.push(OsStr::new("--force"));
}
args.push(path.as_os_str());
run(repo, &args)?;
Ok(())
}
pub fn worktree_prune(repo: &Path) -> Result<()> {
run(repo, &["worktree", "prune"])?;
Ok(())
}
pub fn worktree_list(repo: &Path) -> Result<Vec<PathBuf>> {
let out = run(repo, &["worktree", "list", "--porcelain"])?;
Ok(out
.lines()
.filter_map(|line| line.strip_prefix("worktree "))
.map(PathBuf::from)
.collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusEntry {
pub index: char,
pub worktree: char,
pub path: String,
pub original: Option<String>,
}
impl StatusEntry {
pub fn is_untracked(&self) -> bool {
self.index == '?' && self.worktree == '?'
}
pub fn is_staged(&self) -> bool {
!self.is_untracked() && self.index != ' '
}
pub fn is_unstaged(&self) -> bool {
self.is_untracked() || self.worktree != ' '
}
pub fn is_unmerged(&self) -> bool {
self.index == 'U'
|| self.worktree == 'U'
|| matches!((self.index, self.worktree), ('D', 'D') | ('A', 'A'))
}
}
pub fn status(repo: &Path) -> Result<Vec<StatusEntry>> {
let out = run_raw(repo, &["status", "--porcelain=v1", "-z", "-uall"])?;
Ok(parse_status(&out))
}
fn parse_status(raw: &str) -> Vec<StatusEntry> {
let mut entries = Vec::new();
let mut records = raw.split('\0').filter(|r| !r.is_empty());
while let Some(record) = records.next() {
let mut chars = record.chars();
let (Some(index), Some(worktree)) = (chars.next(), chars.next()) else {
continue;
};
let path = record.get(3..).unwrap_or_default().to_string();
let original = if matches!(index, 'R' | 'C') || matches!(worktree, 'R' | 'C') {
records.next().map(str::to_string)
} else {
None
};
entries.push(StatusEntry {
index,
worktree,
path,
original,
});
}
entries
}
fn literal(path: &str) -> String {
format!(":(literal){path}")
}
pub fn diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String> {
let mut args: Vec<String> = vec!["diff".into()];
if staged {
args.push("--cached".into());
}
args.extend(["--no-color".to_string(), "--no-ext-diff".to_string()]);
if let Some(path) = path {
args.push("--".into());
args.push(literal(path));
}
let args: Vec<&str> = args.iter().map(String::as_str).collect();
run_raw(repo, &args)
}
pub fn diff_untracked(repo: &Path, path: &str) -> Result<String> {
let (_, out) = output(
repo,
&["diff", "--no-color", "--no-index", "--", "/dev/null", path],
)?;
Ok(out)
}
pub fn stage(repo: &Path, path: &str) -> Result<()> {
run(repo, &["add", "--", &literal(path)])?;
Ok(())
}
pub fn stage_all(repo: &Path) -> Result<()> {
run(repo, &["add", "-A"])?;
Ok(())
}
pub fn unstage(repo: &Path, path: &str) -> Result<()> {
run(repo, &["restore", "--staged", "--", &literal(path)])?;
Ok(())
}
pub fn has_staged_changes(repo: &Path) -> Result<bool> {
Ok(!succeeds(repo, &["diff", "--cached", "--quiet"])?)
}
pub fn commit(repo: &Path, message: &str) -> Result<String> {
run(repo, &["commit", "-m", message])?;
run(repo, &["rev-parse", "--short", "HEAD"])
}
pub fn branch_delete(repo: &Path, branch: &str, force: bool) -> Result<()> {
let flag = if force { "-D" } else { "-d" };
run(repo, &["branch", flag, branch])?;
Ok(())
}
#[cfg(test)]
pub(crate) mod testing {
use super::*;
use std::path::Path;
pub fn init_repo(path: &Path, default_branch: &str) {
std::fs::create_dir_all(path).expect("create repo dir");
let init = Command::new("git")
.arg("-C")
.arg(path)
.args(["init", "-q", "-b", default_branch])
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
run(path, &["config", "user.email", "test@marver.invalid"]).unwrap();
run(path, &["config", "user.name", "marver tests"]).unwrap();
std::fs::write(path.join("README.md"), "# test\n").expect("write file");
run(path, &["add", "."]).unwrap();
run(path, &["commit", "-q", "-m", "initial"]).unwrap();
}
pub fn set_origin_head(path: &Path, branch: &str) {
run(
path,
&[
"update-ref",
&format!("refs/remotes/origin/{branch}"),
"HEAD",
],
)
.unwrap();
run(
path,
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
&format!("refs/remotes/origin/{branch}"),
],
)
.unwrap();
}
}
#[cfg(test)]
mod tests {
use super::testing::*;
use super::*;
use tempfile::TempDir;
#[test]
fn detects_a_repo() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("r");
init_repo(&repo, "main");
assert!(is_repo(&repo));
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
assert!(!is_repo(&plain));
}
#[test]
fn reads_the_checked_out_branch() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "trunk");
assert_eq!(head_branch(tmp.path()).unwrap().as_deref(), Some("trunk"));
}
#[test]
fn branch_existence_is_reported_not_errored() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
assert!(branch_exists(tmp.path(), "main").unwrap());
assert!(!branch_exists(tmp.path(), "nope").unwrap());
}
#[test]
fn default_branch_prefers_what_the_remote_advertises() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
run(tmp.path(), &["branch", "develop"]).unwrap();
set_origin_head(tmp.path(), "develop");
assert_eq!(default_branch(tmp.path()).unwrap(), "develop");
}
#[test]
fn default_branch_falls_back_to_conventional_names() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "master");
assert_eq!(default_branch(tmp.path()).unwrap(), "master");
}
#[test]
fn default_branch_falls_back_to_head_for_unconventional_names() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "shipping");
assert_eq!(default_branch(tmp.path()).unwrap(), "shipping");
}
#[test]
fn a_stale_origin_head_does_not_win() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
run(
tmp.path(),
&["update-ref", "refs/remotes/origin/gone", "HEAD"],
)
.unwrap();
run(
tmp.path(),
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/gone",
],
)
.unwrap();
run(
tmp.path(),
&["update-ref", "-d", "refs/remotes/origin/gone"],
)
.unwrap();
assert_eq!(
default_branch(tmp.path()).unwrap(),
"main",
"should skip a default that has no local branch"
);
}
#[test]
fn adds_and_removes_a_worktree() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
init_repo(&repo, "main");
let wt = tmp.path().join("wt");
worktree_add(&repo, &wt, "feature", "main").unwrap();
assert!(wt.join("README.md").exists());
assert!(branch_exists(&repo, "feature").unwrap());
assert!(
worktree_list(&repo)
.unwrap()
.iter()
.any(|p| p.ends_with("wt"))
);
worktree_remove(&repo, &wt, false).unwrap();
assert!(!wt.exists());
assert!(
branch_exists(&repo, "feature").unwrap(),
"removing a worktree must not delete its branch"
);
}
#[test]
fn a_dirty_worktree_needs_force() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
init_repo(&repo, "main");
let wt = tmp.path().join("wt");
worktree_add(&repo, &wt, "feature", "main").unwrap();
std::fs::write(wt.join("README.md"), "changed\n").unwrap();
assert!(worktree_remove(&repo, &wt, false).is_err());
worktree_remove(&repo, &wt, true).unwrap();
assert!(!wt.exists());
}
#[test]
fn a_duplicate_branch_is_an_error() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
init_repo(&repo, "main");
worktree_add(&repo, &tmp.path().join("a"), "feature", "main").unwrap();
let err = worktree_add(&repo, &tmp.path().join("b"), "feature", "main").unwrap_err();
assert!(matches!(err, Error::Failed { .. }));
}
#[test]
fn failures_carry_stderr() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
let err = run(tmp.path(), &["rev-parse", "does-not-exist"]).unwrap_err();
let Error::Failed { stderr, args, .. } = err else {
panic!("expected a command failure");
};
assert!(!stderr.is_empty(), "stderr should be captured");
assert!(args.contains("rev-parse"));
}
#[test]
fn status_keeps_the_leading_space_that_encodes_the_index() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
std::fs::write(tmp.path().join("README.md"), "changed\n").unwrap();
let entries = status(tmp.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].index, ' ');
assert_eq!(entries[0].worktree, 'M');
assert_eq!(entries[0].path, "README.md");
assert!(entries[0].is_unstaged() && !entries[0].is_staged());
}
#[test]
fn status_distinguishes_staged_untracked_and_renamed() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
std::fs::write(tmp.path().join("staged.txt"), "s\n").unwrap();
stage(tmp.path(), "staged.txt").unwrap();
std::fs::write(tmp.path().join("loose.txt"), "l\n").unwrap();
run(tmp.path(), &["mv", "README.md", "RENAMED.md"]).unwrap();
let entries = status(tmp.path()).unwrap();
let by_path = |p: &str| entries.iter().find(|e| e.path == p).cloned();
let staged = by_path("staged.txt").expect("staged.txt");
assert_eq!(staged.index, 'A');
assert!(staged.is_staged());
let loose = by_path("loose.txt").expect("loose.txt");
assert!(loose.is_untracked());
assert!(loose.is_unstaged() && !loose.is_staged());
let renamed = by_path("RENAMED.md").expect("RENAMED.md");
assert_eq!(renamed.index, 'R');
assert_eq!(
renamed.original.as_deref(),
Some("README.md"),
"a rename spends two NUL records; the second is where it came from"
);
assert_eq!(entries.len(), 3, "no phantom entries: {entries:?}");
}
#[test]
fn a_rename_in_the_work_tree_does_not_desync_the_parser() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
std::fs::write(tmp.path().join("old.txt"), "one\n").unwrap();
stage_all(tmp.path()).unwrap();
run(tmp.path(), &["commit", "-q", "-m", "seed"]).unwrap();
std::fs::rename(tmp.path().join("old.txt"), tmp.path().join("new.txt")).unwrap();
run(tmp.path(), &["add", "-N", "new.txt"]).unwrap();
let entries = status(tmp.path()).unwrap();
assert_eq!(entries.len(), 1, "one rename, one entry: {entries:?}");
assert_eq!(entries[0].path, "new.txt");
assert_eq!(entries[0].worktree, 'R');
assert_eq!(entries[0].original.as_deref(), Some("old.txt"));
}
#[test]
fn staged_changes_are_detectable_and_committable() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
assert!(!has_staged_changes(tmp.path()).unwrap());
std::fs::write(tmp.path().join("new.txt"), "n\n").unwrap();
stage(tmp.path(), "new.txt").unwrap();
assert!(has_staged_changes(tmp.path()).unwrap());
let hash = commit(tmp.path(), "add new").unwrap();
assert!(!hash.is_empty());
assert!(!has_staged_changes(tmp.path()).unwrap());
assert_eq!(
run(tmp.path(), &["log", "-1", "--pretty=%s"]).unwrap(),
"add new"
);
}
#[test]
fn unstaging_leaves_the_file_on_disk() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
std::fs::write(tmp.path().join("README.md"), "changed\n").unwrap();
stage(tmp.path(), "README.md").unwrap();
unstage(tmp.path(), "README.md").unwrap();
assert!(!has_staged_changes(tmp.path()).unwrap());
assert_eq!(
std::fs::read_to_string(tmp.path().join("README.md")).unwrap(),
"changed\n",
"unstaging must not discard the work"
);
}
#[test]
fn a_glob_in_a_filename_stages_only_that_file() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
for name in ["a?.txt", "ab.txt", "ax.txt"] {
std::fs::write(tmp.path().join(name), "content\n").unwrap();
}
stage(tmp.path(), "a?.txt").unwrap();
let staged: Vec<String> = status(tmp.path())
.unwrap()
.into_iter()
.filter(|e| e.is_staged())
.map(|e| e.path)
.collect();
assert_eq!(staged, ["a?.txt"], "only the named file may be staged");
}
#[test]
fn a_glob_in_a_filename_unstages_only_that_file() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
for name in ["a*.txt", "ab.txt"] {
std::fs::write(tmp.path().join(name), "content\n").unwrap();
}
stage_all(tmp.path()).unwrap();
unstage(tmp.path(), "a*.txt").unwrap();
let staged: Vec<String> = status(tmp.path())
.unwrap()
.into_iter()
.filter(|e| e.is_staged())
.map(|e| e.path)
.collect();
assert_eq!(staged, ["ab.txt"], "the neighbour must stay staged");
}
#[test]
fn a_glob_in_a_filename_diffs_only_that_file() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
for name in ["a?.txt", "a0.txt"] {
std::fs::write(tmp.path().join(name), "before\n").unwrap();
}
stage_all(tmp.path()).unwrap();
run(tmp.path(), &["commit", "-q", "-m", "seed"]).unwrap();
std::fs::write(tmp.path().join("a?.txt"), "QUESTION\n").unwrap();
std::fs::write(tmp.path().join("a0.txt"), "ZERO\n").unwrap();
let out = diff(tmp.path(), Some("a?.txt"), false).unwrap();
assert!(out.contains("QUESTION"), "wanted the named file: {out}");
assert!(
!out.contains("ZERO"),
"another file's changes must not appear: {out}"
);
}
#[test]
fn branches_can_be_deleted() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), "main");
run(tmp.path(), &["branch", "scratch"]).unwrap();
branch_delete(tmp.path(), "scratch", true).unwrap();
assert!(!branch_exists(tmp.path(), "scratch").unwrap());
}
}