#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::{Path, PathBuf};
use std::time::Duration;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
#[cfg(feature = "cancellation")]
#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
pub use processkit::CancellationToken;
pub mod conflict;
mod parse;
pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
pub use vcs_diff::{
ChangeKind, DiffLine, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
};
pub use vcs_cli_support::{is_merge_conflict, is_nothing_to_commit, is_transient_fetch_error};
pub const BINARY: &str = "git";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DiffSpec {
WorkingTree,
Rev(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WorktreeAdd {
pub path: PathBuf,
pub new_branch: Option<String>,
pub commitish: Option<String>,
pub no_checkout: bool,
}
impl WorktreeAdd {
pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
Self {
path: path.into(),
new_branch: None,
commitish: Some(commitish.into()),
no_checkout: false,
}
}
pub fn create_branch(
path: impl Into<PathBuf>,
name: impl Into<String>,
commitish: impl Into<String>,
) -> Self {
Self {
path: path.into(),
new_branch: Some(name.into()),
commitish: Some(commitish.into()),
no_checkout: false,
}
}
pub fn no_checkout(mut self) -> Self {
self.no_checkout = true;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GitPush {
pub remote: String,
pub refspec: String,
pub set_upstream: bool,
}
impl GitPush {
pub fn branch(name: impl Into<String>) -> Self {
Self {
remote: "origin".to_string(),
refspec: name.into(),
set_upstream: false,
}
}
pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self {
Self {
remote: "origin".to_string(),
refspec: format!("{}:{}", local.as_ref(), remote_branch.as_ref()),
set_upstream: false,
}
}
pub fn remote(mut self, remote: impl Into<String>) -> Self {
self.remote = remote.into();
self
}
pub fn set_upstream(mut self) -> Self {
self.set_upstream = true;
self
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct CloneSpec {
pub branch: Option<String>,
pub depth: Option<u32>,
pub bare: bool,
}
impl CloneSpec {
pub fn new() -> Self {
Self::default()
}
pub fn branch(mut self, branch: impl Into<String>) -> Self {
self.branch = Some(branch.into());
self
}
pub fn depth(mut self, depth: u32) -> Self {
self.depth = Some(depth);
self
}
pub fn bare(mut self) -> Self {
self.bare = true;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CommitPaths {
pub paths: Vec<PathBuf>,
pub message: String,
pub amend: bool,
}
impl CommitPaths {
pub fn new(
paths: impl IntoIterator<Item = impl Into<PathBuf>>,
message: impl Into<String>,
) -> Self {
Self {
paths: paths.into_iter().map(Into::into).collect(),
message: message.into(),
amend: false,
}
}
pub fn amend(mut self) -> Self {
self.amend = true;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct MergeCommit {
pub branch: String,
pub no_ff: bool,
pub message: Option<String>,
}
impl MergeCommit {
pub fn branch(name: impl Into<String>) -> Self {
Self {
branch: name.into(),
no_ff: false,
message: None,
}
}
pub fn no_ff(mut self) -> Self {
self.no_ff = true;
self
}
pub fn message(mut self, m: impl Into<String>) -> Self {
self.message = Some(m.into());
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct MergeNoCommit {
pub branch: String,
pub squash: bool,
pub no_ff: bool,
}
impl MergeNoCommit {
pub fn branch(name: impl Into<String>) -> Self {
Self {
branch: name.into(),
squash: false,
no_ff: false,
}
}
pub fn squash(mut self) -> Self {
self.squash = true;
self
}
pub fn no_ff(mut self) -> Self {
self.no_ff = true;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AnnotatedTag {
pub name: String,
pub message: String,
pub rev: Option<String>,
}
impl AnnotatedTag {
pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
message: message.into(),
rev: None,
}
}
pub fn rev(mut self, r: impl Into<String>) -> Self {
self.rev = Some(r.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RefName(String);
impl RefName {
pub fn new(name: impl Into<String>) -> Result<Self> {
let name = name.into();
let bad = name.is_empty()
|| name.starts_with('-')
|| name.starts_with('.')
|| name.ends_with('/')
|| name.ends_with(".lock")
|| name.contains("..")
|| name
.chars()
.any(|c| c.is_control() || " ~^:?*[\\".contains(c));
if bad {
return Err(Error::Spawn {
program: BINARY.to_string(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid git reference name: {name:?}"),
),
});
}
Ok(RefName(name))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for RefName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RevSpec(String);
impl RevSpec {
pub fn new(rev: impl Into<String>) -> Result<Self> {
let rev = rev.into();
reject_flag_like("revision", &rev)?;
Ok(RevSpec(rev))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for RevSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct GitCapabilities {
pub version: GitVersion,
}
const MIN_SUPPORTED_MAJOR: u64 = 2;
impl GitCapabilities {
pub fn is_supported(&self) -> bool {
self.version.major >= MIN_SUPPORTED_MAJOR
}
pub fn ensure_supported(&self) -> Result<()> {
if self.is_supported() {
return Ok(());
}
Err(Error::Spawn {
program: BINARY.to_string(),
source: std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"vcs-git requires git >= {MIN_SUPPORTED_MAJOR} (validated on 2.54), \
found {}",
self.version
),
),
})
}
}
#[cfg_attr(feature = "mock", mockall::automock)]
#[async_trait::async_trait]
pub trait GitApi: Send + Sync {
async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
async fn version(&self) -> Result<String>;
async fn capabilities(&self) -> Result<GitCapabilities>;
async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
async fn status_text(&self, dir: &Path) -> Result<String>;
async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>>;
async fn current_branch(&self, dir: &Path) -> Result<String>;
async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>>;
async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
async fn init(&self, dir: &Path) -> Result<()>;
async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
async fn last_commit_message(&self, dir: &Path) -> Result<String>;
async fn is_unborn(&self, dir: &Path) -> Result<bool>;
async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
async fn fetch(&self, dir: &Path) -> Result<()>;
async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
async fn merge_abort(&self, dir: &Path) -> Result<()>;
async fn merge_continue(&self, dir: &Path) -> Result<()>;
async fn reset_merge(&self, dir: &Path) -> Result<()>;
async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
async fn rebase_abort(&self, dir: &Path) -> Result<()>;
async fn rebase_continue(&self, dir: &Path) -> Result<()>;
async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
async fn stash_pop(&self, dir: &Path) -> Result<()>;
async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
async fn worktree_prune(&self, dir: &Path) -> Result<()>;
async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;
async fn rebase_skip(&self, dir: &Path) -> Result<()>;
}
processkit::cli_client!(
pub struct Git => BINARY
);
#[async_trait::async_trait]
impl<R: ProcessRunner> GitApi for Git<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
async fn version(&self) -> Result<String> {
self.core.run(self.core.command(["--version"])).await
}
async fn capabilities(&self) -> Result<GitCapabilities> {
let raw = self.version().await?;
let version = parse::parse_git_version(&raw).ok_or_else(|| Error::Parse {
program: BINARY.to_string(),
message: format!("unrecognisable `git --version` output: {raw:?}"),
})?;
Ok(GitCapabilities { version })
}
async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
self.core
.parse(
self.core
.command_in(dir, ["status", "--porcelain=v1", "-z"]),
parse::parse_porcelain,
)
.await
}
async fn status_text(&self, dir: &Path) -> Result<String> {
self.core
.run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
.await
}
async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
self.core
.parse(
self.core
.command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
.env("GIT_OPTIONAL_LOCKS", "0"),
parse::parse_porcelain_v2,
)
.await
}
async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
self.core
.parse(
self.core.command_in(
dir,
["status", "--porcelain=v1", "-z", "--untracked-files=no"],
),
parse::parse_porcelain,
)
.await
}
async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>> {
self.core
.parse(
self.core
.command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
parse::parse_nul_paths,
)
.await
}
async fn current_branch(&self, dir: &Path) -> Result<String> {
self.core
.run(
self.core
.command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
)
.await
}
async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
self.core
.parse(
self.core.command_in(dir, ["branch", "--no-column"]),
parse::parse_branches,
)
.await
}
async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
let n = format!("-n{max}");
self.core
.parse(
self.core.command_in(
dir,
[
"log",
n.as_str(),
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
],
),
parse::parse_log,
)
.await
}
async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>> {
reject_flag_like("range", range)?;
let n = format!("-n{max}");
self.core
.parse(
self.core.command_in(
dir,
[
"log",
range,
n.as_str(),
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
],
),
parse::parse_log,
)
.await
}
async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
reject_flag_like("revision", rev)?;
self.core
.run(self.core.command_in(dir, ["rev-parse", rev]))
.await
}
async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
reject_flag_like("revision", rev)?;
self.core
.run(self.core.command_in(dir, ["rev-parse", "--short", rev]))
.await
}
async fn init(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(self.core.command_in(dir, ["init"]))
.await
}
async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
let mut command = self.core.command_in(dir, ["add", "--"]);
for path in paths {
command = command.arg(path);
}
self.core.run_unit(command).await
}
async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
self.core
.run_unit(c_locale(
self.core.command_in(dir, ["commit", "-m", message]),
))
.await
}
async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
reject_flag_like("branch name", name)?;
self.core
.run_unit(self.core.command_in(dir, ["branch", name]))
.await
}
async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
reject_flag_like("reference", reference)?;
self.core
.run_unit(self.core.command_in(dir, ["checkout", reference]))
.await
}
async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
reject_flag_like("commit", commit)?;
self.core
.run_unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
.await
}
async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
let mut command = c_locale(self.core.command_in(dir, ["commit"]));
if spec.amend {
command = command.arg("--amend");
}
command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
for path in &spec.paths {
command = command.arg(path);
}
self.core.run_unit(command).await
}
async fn last_commit_message(&self, dir: &Path) -> Result<String> {
self.core
.run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
.await
}
async fn is_unborn(&self, dir: &Path) -> Result<bool> {
Ok(!self
.core
.probe(
self.core
.command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
)
.await?)
}
async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
self.core
.probe(self.core.command_in(dir, ["diff", "--quiet"]))
.await
}
async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
Ok(PathBuf::from(
self.core
.run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
.await?,
))
}
async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
Ok(PathBuf::from(
self.core
.run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
.await?,
))
}
async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
reject_flag_like("revision", rev)?;
let spec = format!("{rev}^{{commit}}");
self.core
.run(
self.core
.command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
)
.await
}
async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
let res = self
.core
.output(
self.core
.command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
)
.await?;
if res.code() == Some(0) {
let out = res.stdout().trim();
Ok(Some(
out.strip_prefix("refs/remotes/origin/")
.unwrap_or(out)
.to_string(),
))
} else {
Ok(None)
}
}
async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
let refname = format!("refs/heads/{name}");
self.core
.probe(
self.core
.command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
)
.await
}
async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
let refname = format!("refs/heads/{name}");
let cmd = self
.core
.command_in(dir, ["ls-remote", "origin", refname.as_str()])
.env("GIT_TERMINAL_PROMPT", "0")
.timeout(Duration::from_secs(10));
let res = self.core.output(cmd).await?;
Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
}
async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
reject_flag_like("remote name", remote)?;
self.core
.run(self.core.command_in(dir, ["remote", "get-url", remote]))
.await
}
async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
match self
.core
.output(self.core.command_in(
dir,
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
))
.await?
{
res if res.code() == Some(0) => {
let name = res.stdout().trim();
Ok((!name.is_empty()).then(|| name.to_string()))
}
_ => Ok(None),
}
}
async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
reject_flag_like("remote name", remote)?;
let cmd = self
.core
.command_in(dir, ["ls-remote", "--heads", remote])
.env("GIT_TERMINAL_PROMPT", "0");
self.core.parse(cmd, parse::parse_ls_remote_heads).await
}
async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
reject_flag_like("branch", branch)?;
reject_flag_like("target", target)?;
let out = self
.core
.run(
self.core
.command_in(dir, ["branch", "--merged", target, "--no-column"]),
)
.await?;
Ok(out
.lines()
.filter_map(|line| line.get(2..))
.any(|b| b == branch))
}
async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
reject_flag_like("branch name", branch)?;
let flag = format!("--set-upstream-to={upstream}");
self.core
.run_unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
.await
}
async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
reject_flag_like("branch name", name)?;
let flag = if force { "-D" } else { "-d" };
self.core
.run_unit(self.core.command_in(dir, ["branch", flag, name]))
.await
}
async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
reject_flag_like("branch name", old)?;
reject_flag_like("branch name", new)?;
self.core
.run_unit(self.core.command_in(dir, ["branch", "-m", old, new]))
.await
}
async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
reject_flag_like("range", range)?;
self.core
.try_parse(
self.core.command_in(dir, ["rev-list", "--count", range]),
|s| {
s.trim().parse::<usize>().map_err(|e| Error::Parse {
program: BINARY.to_string(),
message: e.to_string(),
})
},
)
.await
}
async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
reject_flag_like("range", range)?;
self.core
.probe(self.core.command_in(dir, ["diff", "--quiet", range]))
.await
}
async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
reject_flag_like("range", range)?;
self.core
.parse(
self.core.command_in(dir, ["diff", "--shortstat", range]),
parse::parse_shortstat,
)
.await
}
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
let target = match spec {
DiffSpec::WorkingTree => {
if self.is_unborn(dir).await? {
EMPTY_TREE.to_string()
} else {
"HEAD".to_string()
}
}
DiffSpec::Rev(rev) => {
reject_flag_like("revision", &rev)?;
rev
}
};
self.core
.run(self.core.command_in(
dir,
[
"diff",
target.as_str(),
"--no-color",
"--no-ext-diff",
"-M",
"--src-prefix=a/",
"--dst-prefix=b/",
],
))
.await
}
async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
let text = self.diff_text(dir, spec).await?;
Ok(parse_diff(&text))
}
async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
self.core
.probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
.await
}
async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
let git_dir = self.resolved_git_dir(dir).await?;
Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
}
async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
Ok(self
.resolved_git_dir(dir)
.await?
.join("MERGE_HEAD")
.exists())
}
async fn fetch(&self, dir: &Path) -> Result<()> {
let cmd = c_locale(self.core.command_in(dir, ["fetch", "--quiet"]))
.env("GIT_TERMINAL_PROMPT", "0")
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
self.core.run_unit(cmd).await
}
async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
reject_flag_like("remote", remote)?;
let cmd = c_locale(self.core.command_in(dir, ["fetch", "--quiet", remote]))
.env("GIT_TERMINAL_PROMPT", "0")
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
self.core.run_unit(cmd).await
}
async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
let cmd = c_locale(
self.core
.command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()]),
)
.env("GIT_TERMINAL_PROMPT", "0")
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
self.core.run_unit(cmd).await
}
async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
reject_flag_like("remote", &spec.remote)?;
reject_flag_like("refspec", &spec.refspec)?;
let mut args: Vec<&str> = vec!["push"];
if spec.set_upstream {
args.push("-u");
}
args.push(spec.remote.as_str());
args.push(spec.refspec.as_str());
let cmd = self
.core
.command_in(dir, args)
.env("GIT_TERMINAL_PROMPT", "0");
self.core.run_unit(cmd).await
}
async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
reject_flag_like("branch", branch)?;
self.core
.run_unit(self.core.command_in(dir, ["merge", "--squash", branch]))
.await
}
async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
reject_flag_like("branch", &spec.branch)?;
let mut args: Vec<&str> = vec!["merge"];
if spec.no_ff {
args.push("--no-ff");
}
if let Some(msg) = spec.message.as_deref() {
args.push("-m");
args.push(msg);
} else {
args.push("--no-edit");
}
args.push(&spec.branch);
self.core
.run_unit(c_locale(self.core.command_in(dir, args)))
.await
}
async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
reject_flag_like("branch", &spec.branch)?;
let mut args: Vec<&str> = vec!["merge", "--no-commit"];
if spec.squash {
args.push("--squash");
} else if spec.no_ff {
args.push("--no-ff");
}
args.push(&spec.branch);
self.core
.run_unit(c_locale(self.core.command_in(dir, args)))
.await
}
async fn merge_abort(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
.await
}
async fn merge_continue(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["commit", "--no-edit"]),
)))
.await
}
async fn reset_merge(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(self.core.command_in(dir, ["reset", "--merge"]))
.await
}
async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
reject_flag_like("revision", rev)?;
self.core
.run_unit(self.core.command_in(dir, ["reset", "--hard", rev]))
.await
}
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
reject_flag_like("rebase target", onto)?;
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["rebase", onto]),
)))
.await
}
async fn rebase_abort(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
.await
}
async fn rebase_continue(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["rebase", "--continue"]),
)))
.await
}
async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
let mut command = self.core.command_in(dir, ["stash", "push"]);
if include_untracked {
command = command.arg("--include-untracked");
}
self.core.run_unit(command).await
}
async fn stash_pop(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(self.core.command_in(dir, ["stash", "pop"]))
.await
}
async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
self.core
.parse(
self.core
.command_in(dir, ["worktree", "list", "--porcelain"]),
parse::parse_worktree_porcelain,
)
.await
}
async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
if let Some(name) = spec.new_branch.as_deref() {
reject_flag_like("branch name", name)?;
}
if let Some(commitish) = spec.commitish.as_deref() {
reject_flag_like("commit-ish", commitish)?;
}
let mut command = self.core.command_in(dir, ["worktree", "add"]);
if let Some(name) = spec.new_branch.as_deref() {
command = command.arg("-b").arg(name);
}
if spec.no_checkout {
command = command.arg("--no-checkout");
}
command = command.arg(&spec.path);
if let Some(commitish) = spec.commitish.as_deref() {
command = command.arg(commitish);
}
self.core.run_unit(command).await
}
async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
let mut command = self.core.command_in(dir, ["worktree", "remove"]);
if force {
command = command.arg("--force");
}
command = command.arg(path);
self.core.run_unit(command).await
}
async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
let command = self
.core
.command_in(dir, ["worktree", "move"])
.arg(from)
.arg(to);
self.core.run_unit(command).await
}
async fn worktree_prune(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(self.core.command_in(dir, ["worktree", "prune"]))
.await
}
async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
reject_flag_like("url", url)?;
let mut command = self.core.command(["clone"]);
if let Some(branch) = spec.branch.as_deref() {
command = command.arg("--branch").arg(branch);
}
if let Some(depth) = spec.depth {
command = command.arg("--depth").arg(depth.to_string());
}
if spec.bare {
command = command.arg("--bare");
}
let command = command.arg(url).arg(dest).env("GIT_TERMINAL_PROMPT", "0");
self.core.run_unit(command).await
}
async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()> {
reject_flag_like("tag name", name)?;
if let Some(rev) = rev.as_deref() {
reject_flag_like("revision", rev)?;
}
let mut args = vec!["tag", name];
if let Some(rev) = rev.as_deref() {
args.push(rev);
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
reject_flag_like("tag name", &spec.name)?;
if let Some(rev) = spec.rev.as_deref() {
reject_flag_like("revision", rev)?;
}
let mut args = vec!["tag", "-a", &spec.name, "-m", &spec.message];
if let Some(rev) = spec.rev.as_deref() {
args.push(rev);
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
let out = self
.core
.run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
.await?;
Ok(out.lines().map(str::to_string).collect())
}
async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()> {
reject_flag_like("tag name", name)?;
self.core
.run_unit(self.core.command_in(dir, ["tag", "-d", name]))
.await
}
async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String> {
reject_flag_like("revision", rev)?;
#[cfg(windows)]
let path = path.replace('\\', "/");
let spec = format!("{rev}:{path}");
self.core
.run(self.core.command_in(dir, ["show", spec.as_str()]))
.await
}
async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
reject_flag_like("config key", key)?;
let res = self
.core
.output(self.core.command_in(dir, ["config", "--get", key]))
.await?;
match res.code() {
Some(1) => Ok(None),
Some(0) => Ok(Some(res.stdout().trim_end().to_string())),
_ => {
res.ensure_success()?;
Ok(None) }
}
}
async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
reject_flag_like("config key", key)?;
self.core
.run_unit(self.core.command_in(dir, ["config", key, value]))
.await
}
async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
reject_flag_like("remote name", name)?;
reject_flag_like("url", url)?;
self.core
.run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
.await
}
async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
reject_flag_like("remote name", name)?;
reject_flag_like("url", url)?;
self.core
.run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
.await
}
async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>> {
let mut args = vec!["blame", "--line-porcelain"];
if let Some(rev) = rev.as_deref() {
reject_flag_like("revision", rev)?;
args.push(rev);
}
args.push("--");
args.push(path);
self.core
.parse(
self.core.command_in(dir, args),
parse::parse_blame_porcelain,
)
.await
}
async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()> {
reject_flag_like("revision", rev)?;
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["cherry-pick", rev]),
)))
.await
}
async fn revert(&self, dir: &Path, rev: &str) -> Result<()> {
reject_flag_like("revision", rev)?;
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["revert", "--no-edit", rev]),
)))
.await
}
async fn rebase_skip(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["rebase", "--skip"]),
)))
.await
}
}
pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
fn no_editor(cmd: processkit::Command) -> processkit::Command {
cmd.env("GIT_EDITOR", "true")
.env("GIT_SEQUENCE_EDITOR", "true")
}
fn c_locale(cmd: processkit::Command) -> processkit::Command {
cmd.env("LC_ALL", "C")
}
fn reject_flag_like(what: &str, value: &str) -> Result<()> {
vcs_cli_support::reject_flag_like(BINARY, what, value)
}
impl<R: ProcessRunner> Git<R> {
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
GitAt { git: self, dir }
}
pub fn harden(self) -> Self {
let removed = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_NAMESPACE",
"GIT_CEILING_DIRECTORIES",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
];
let mut hardened = self;
for key in removed {
hardened = hardened.default_env_remove(key);
}
hardened
.default_env("GIT_CONFIG_NOSYSTEM", "1")
.default_env("GIT_TERMINAL_PROMPT", "0")
.default_env("GIT_CONFIG_COUNT", "2")
.default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
.default_env("GIT_CONFIG_VALUE_0", "/dev/null")
.default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
.default_env("GIT_CONFIG_VALUE_1", "false")
}
pub async fn switch_with_stash(&self, dir: &Path, branch: &str) -> Result<()> {
if self.status(dir).await?.is_empty() {
return self.checkout(dir, branch).await;
}
self.stash_push(dir, true).await?;
match self.checkout(dir, branch).await {
Ok(()) => self.stash_pop(dir).await,
Err(err) => {
let _ = self.stash_pop(dir).await;
Err(err)
}
}
}
async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
let git_dir = PathBuf::from(
self.core
.run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
.await?,
);
Ok(if git_dir.is_absolute() {
git_dir
} else {
dir.join(git_dir)
})
}
}
impl Git {
pub fn hardened() -> Self {
Self::new().harden()
}
}
pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
git: &'a Git<R>,
dir: &'a Path,
}
impl<R: ProcessRunner> Clone for GitAt<'_, R> {
fn clone(&self) -> Self {
*self
}
}
impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
macro_rules! git_at_forwarders {
(
bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
) => {
impl<'a, R: ProcessRunner> GitAt<'a, R> {
$(
#[doc = concat!("Bound form of [`Git`]'s `", stringify!($bn), "`.")]
pub async fn $bn(&self, $($ba: $bt),*) -> $br {
self.git.$bn($($ba),*).await
}
)*
$(
#[doc = concat!("Bound form of [`Git`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
pub async fn $dn(&self, $($da: $dt),*) -> $dr {
self.git.$dn(self.dir, $($da),*).await
}
)*
}
};
}
git_at_forwarders! {
bare {
fn run(args: &[String]) -> Result<String>;
fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
fn run_args(args: &[&str]) -> Result<String>;
fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
fn version() -> Result<String>;
fn capabilities() -> Result<GitCapabilities>;
fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
}
dir {
fn status() -> Result<Vec<StatusEntry>>;
fn status_text() -> Result<String>;
fn status_tracked() -> Result<Vec<StatusEntry>>;
fn branch_status() -> Result<BranchStatus>;
fn conflicted_files() -> Result<Vec<String>>;
fn current_branch() -> Result<String>;
fn branches() -> Result<Vec<Branch>>;
fn log(max: usize) -> Result<Vec<Commit>>;
fn log_range(range: &str, max: usize) -> Result<Vec<Commit>>;
fn rev_parse(rev: &str) -> Result<String>;
fn rev_parse_short(rev: &str) -> Result<String>;
fn init() -> Result<()>;
fn add(paths: &[PathBuf]) -> Result<()>;
fn commit(message: &str) -> Result<()>;
fn create_branch(name: &str) -> Result<()>;
fn checkout(reference: &str) -> Result<()>;
fn checkout_detach(commit: &str) -> Result<()>;
fn commit_paths(spec: CommitPaths) -> Result<()>;
fn last_commit_message() -> Result<String>;
fn is_unborn() -> Result<bool>;
fn diff_is_empty() -> Result<bool>;
fn common_dir() -> Result<PathBuf>;
fn git_dir() -> Result<PathBuf>;
fn resolve_commit(rev: &str) -> Result<String>;
fn remote_head_branch() -> Result<Option<String>>;
fn branch_exists(name: &str) -> Result<bool>;
fn remote_branch_exists(name: &str) -> Result<bool>;
fn remote_url(remote: &str) -> Result<String>;
fn upstream() -> Result<Option<String>>;
fn remote_branches(remote: &str) -> Result<Vec<String>>;
fn is_merged(branch: &str, target: &str) -> Result<bool>;
fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
fn delete_branch(name: &str, force: bool) -> Result<()>;
fn rename_branch(old: &str, new: &str) -> Result<()>;
fn rev_list_count(range: &str) -> Result<usize>;
fn diff_range_is_empty(range: &str) -> Result<bool>;
fn diff_stat(range: &str) -> Result<DiffStat>;
fn diff_text(spec: DiffSpec) -> Result<String>;
fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
fn staged_is_empty() -> Result<bool>;
fn is_rebase_in_progress() -> Result<bool>;
fn is_merge_in_progress() -> Result<bool>;
fn fetch() -> Result<()>;
fn fetch_from(remote: &str) -> Result<()>;
fn fetch_remote_branch(branch: &str) -> Result<()>;
fn push(spec: GitPush) -> Result<()>;
fn merge_squash(branch: &str) -> Result<()>;
fn merge_commit(spec: MergeCommit) -> Result<()>;
fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
fn merge_abort() -> Result<()>;
fn merge_continue() -> Result<()>;
fn reset_merge() -> Result<()>;
fn reset_hard(rev: &str) -> Result<()>;
fn rebase(onto: &str) -> Result<()>;
fn rebase_abort() -> Result<()>;
fn rebase_continue() -> Result<()>;
fn stash_push(include_untracked: bool) -> Result<()>;
fn stash_pop() -> Result<()>;
fn switch_with_stash(branch: &str) -> Result<()>;
fn worktree_list() -> Result<Vec<Worktree>>;
fn worktree_add(spec: WorktreeAdd) -> Result<()>;
fn worktree_remove(path: &Path, force: bool) -> Result<()>;
fn worktree_move(from: &Path, to: &Path) -> Result<()>;
fn worktree_prune() -> Result<()>;
fn tag_create(name: &str, rev: Option<String>) -> Result<()>;
fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
fn tag_list() -> Result<Vec<String>>;
fn tag_delete(name: &str) -> Result<()>;
fn show_file(rev: &str, path: &str) -> Result<String>;
fn config_get(key: &str) -> Result<Option<String>>;
fn config_set(key: &str, value: &str) -> Result<()>;
fn remote_add(name: &str, url: &str) -> Result<()>;
fn remote_set_url(name: &str, url: &str) -> Result<()>;
fn blame(path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
fn cherry_pick(rev: &str) -> Result<()>;
fn revert(rev: &str) -> Result<()>;
fn rebase_skip() -> Result<()>;
}
}
pub mod blocking {
use std::path::Path;
use std::process::Command;
pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
let mut cmd = Command::new(super::BINARY);
cmd.current_dir(dir).args(["worktree", "remove"]);
if force {
cmd.arg("--force");
}
cmd.arg(path);
let status = cmd.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other(format!(
"`git worktree remove` exited with {status}"
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn binary_name_is_git() {
assert_eq!(BINARY, "git");
}
#[allow(dead_code)]
fn bound_view_is_copy_for_default_runner() {
fn assert_copy<T: Copy>() {}
assert_copy::<GitAt<'static, processkit::JobRunner>>();
}
#[tokio::test]
async fn bound_view_matches_dir_taking_calls() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.merge_commit(dir, MergeCommit::branch("feat").no_ff())
.await
.unwrap();
git.at(dir)
.merge_commit(MergeCommit::branch("feat").no_ff())
.await
.unwrap();
git.worktree_remove(dir, Path::new("/wt"), true)
.await
.unwrap();
git.at(dir)
.worktree_remove(Path::new("/wt"), true)
.await
.unwrap();
git.conflicted_files(dir).await.unwrap();
git.at(dir).conflicted_files().await.unwrap();
git.tag_delete(dir, "v1").await.unwrap();
git.at(dir).tag_delete("v1").await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), calls[1].args_str());
assert_eq!(calls[2].args_str(), calls[3].args_str());
assert_eq!(calls[4].args_str(), calls[5].args_str());
assert_eq!(calls[6].args_str(), calls[7].args_str());
assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
assert_eq!(calls[3].cwd.as_deref(), Some(dir.as_os_str()));
}
#[tokio::test]
async fn status_parses_scripted_output() {
let git =
Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
let entries = git.status(Path::new(".")).await.expect("status");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].code, " M");
assert_eq!(entries[1].path, "b.rs");
}
#[tokio::test]
async fn status_tracked_excludes_untracked_flag() {
let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
let git = Git::with_runner(&rec);
let entries = git.status_tracked(Path::new(".")).await.expect("status");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].code, " M");
assert_eq!(
rec.only_call().args_str(),
["status", "--porcelain=v1", "-z", "--untracked-files=no"]
);
}
#[tokio::test]
async fn branch_status_builds_v2_branch_args_and_parses() {
let out = concat!(
"# branch.oid abc\0",
"# branch.head main\0",
"# branch.upstream origin/main\0",
"# branch.ab +1 -0\0",
"1 .M N... 100644 100644 100644 1 2 a.rs\0",
"? new.txt\0",
);
let rec = RecordingRunner::replying(Reply::ok(out));
let git = Git::with_runner(&rec);
let s = git
.branch_status(Path::new("."))
.await
.expect("branch_status");
assert_eq!(
rec.only_call().args_str(),
["status", "--porcelain=v2", "--branch", "-z"]
);
assert!(rec.only_call().envs.iter().any(|(k, v)| {
k.to_str() == Some("GIT_OPTIONAL_LOCKS")
&& v.as_deref().and_then(|o| o.to_str()) == Some("0")
}));
assert_eq!(s.branch.as_deref(), Some("main"));
assert_eq!(s.upstream.as_deref(), Some("origin/main"));
assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
assert_eq!(s.tracked_changes, 1);
assert_eq!(s.untracked, 1);
assert!(s.is_dirty());
}
#[tokio::test]
async fn conflicted_files_builds_args_and_parses_nul_list() {
let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
let git = Git::with_runner(&rec);
let paths = git
.conflicted_files(Path::new("."))
.await
.expect("conflicted_files");
assert_eq!(paths, ["a.rs", "sub/spaced name.rs"]);
assert_eq!(
rec.only_call().args_str(),
["diff", "--name-only", "--diff-filter=U", "-z"]
);
}
#[tokio::test]
async fn rev_parse_short_builds_short_flag() {
let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
let git = Git::with_runner(&rec);
let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
assert_eq!(out, "a1b2c3d");
assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
}
#[tokio::test]
async fn nonzero_exit_is_structured_error() {
let git = Git::with_runner(
ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
);
match git.status(Path::new(".")).await.unwrap_err() {
Error::Exit { code, stderr, .. } => {
assert_eq!(code, 128);
assert!(stderr.contains("not a git repository"), "{stderr}");
}
other => panic!("expected Exit, got {other:?}"),
}
}
#[tokio::test]
async fn diff_is_empty_maps_exit_codes() {
let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
let dirty =
Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
let broken = Git::with_runner(
ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
);
assert!(matches!(
broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
Error::Exit { code: 128, .. }
));
}
#[tokio::test]
async fn add_inserts_pathspec_separator() {
let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
git.add(Path::new("."), &[PathBuf::from("f.rs")])
.await
.expect("add should build `add -- <paths>`");
}
#[tokio::test]
async fn worktree_list_parses_porcelain() {
let git = Git::with_runner(ScriptedRunner::new().on(
["worktree", "list"],
Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
));
let wts = git.worktree_list(Path::new(".")).await.expect("list");
assert_eq!(wts.len(), 1);
assert_eq!(wts[0].branch.as_deref(), Some("main"));
assert_eq!(wts[0].head.as_deref(), Some("abc"));
}
#[tokio::test]
async fn worktree_add_builds_branch_path_and_base() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.worktree_add(
Path::new("/repo"),
WorktreeAdd::create_branch("/wt", "feature", "main"),
)
.await
.expect("worktree add");
assert_eq!(
rec.only_call().args_str(),
["worktree", "add", "-b", "feature", "/wt", "main"]
);
}
#[tokio::test]
async fn worktree_remove_passes_force_then_path() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
.await
.expect("remove");
assert_eq!(
rec.only_call().args_str(),
["worktree", "remove", "--force", "/wt"]
);
}
#[tokio::test]
async fn worktree_add_no_checkout_inserts_flag() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.worktree_add(
Path::new("/repo"),
WorktreeAdd::checkout("/wt", "main").no_checkout(),
)
.await
.expect("worktree add");
assert_eq!(
rec.only_call().args_str(),
["worktree", "add", "--no-checkout", "/wt", "main"]
);
}
#[tokio::test]
async fn checkout_detach_builds_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.checkout_detach(Path::new("."), "abc123")
.await
.expect("detach");
assert_eq!(
rec.only_call().args_str(),
["checkout", "--detach", "abc123"]
);
}
#[tokio::test]
async fn commit_paths_builds_only_amend_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.commit_paths(
Path::new("."),
CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
)
.await
.expect("commit_paths");
assert_eq!(
rec.only_call().args_str(),
[
"commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
]
);
}
#[tokio::test]
async fn is_unborn_maps_exit_codes() {
let born = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("abc\n")));
assert!(!born.is_unborn(Path::new(".")).await.unwrap());
let unborn = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(1, "")));
assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
let broken =
Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "boom")));
assert!(matches!(
broken.is_unborn(Path::new(".")).await.unwrap_err(),
Error::Exit { code: 128, .. }
));
}
#[tokio::test]
async fn log_range_builds_range_and_format() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.log_range(Path::new("."), "main..HEAD", 5)
.await
.expect("log_range");
assert_eq!(
rec.only_call().args_str(),
[
"log",
"main..HEAD",
"-n5",
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
]
);
}
#[tokio::test]
async fn stash_push_adds_include_untracked() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.stash_push(Path::new("."), true).await.expect("stash");
assert_eq!(
rec.only_call().args_str(),
["stash", "push", "--include-untracked"]
);
}
#[tokio::test]
async fn diff_text_builds_working_tree_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.diff_text(Path::new("."), DiffSpec::WorkingTree)
.await
.expect("diff_text");
assert_eq!(
rec.calls().last().unwrap().args_str(),
[
"diff",
"HEAD",
"--no-color",
"--no-ext-diff",
"-M",
"--src-prefix=a/",
"--dst-prefix=b/",
]
);
}
#[tokio::test]
async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
let git = Git::with_runner(
ScriptedRunner::new()
.on(["rev-parse"], Reply::fail(1, "")) .on(["diff", EMPTY_TREE], Reply::ok("EMPTY")),
);
let out = git
.diff_text(Path::new("."), DiffSpec::WorkingTree)
.await
.expect("diff_text");
assert_eq!(out, "EMPTY");
}
#[tokio::test]
async fn diff_parses_scripted_output() {
let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
let git = Git::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
let files = git
.diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
.await
.expect("diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "m");
assert_eq!(files[0].change, ChangeKind::Modified);
}
#[tokio::test]
async fn branch_exists_maps_exit_codes() {
let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
}
#[tokio::test]
async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
let simple = Git::with_runner(
ScriptedRunner::new().on(["symbolic-ref"], Reply::ok("refs/remotes/origin/main\n")),
);
assert_eq!(
simple
.remote_head_branch(Path::new("."))
.await
.unwrap()
.as_deref(),
Some("main")
);
let slashed = Git::with_runner(ScriptedRunner::new().on(
["symbolic-ref"],
Reply::ok("refs/remotes/origin/release/v2\n"),
));
assert_eq!(
slashed
.remote_head_branch(Path::new("."))
.await
.unwrap()
.as_deref(),
Some("release/v2")
);
let unset =
Git::with_runner(ScriptedRunner::new().on(["symbolic-ref"], Reply::fail(1, "")));
assert!(
unset
.remote_head_branch(Path::new("."))
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn remote_branch_exists_sets_env_and_reads_stdout() {
let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
let git = Git::with_runner(&rec);
assert!(
git.remote_branch_exists(Path::new("/repo"), "main")
.await
.unwrap()
);
let call = rec.only_call();
assert!(call.envs.iter().any(|(k, v)| {
k.to_str() == Some("GIT_TERMINAL_PROMPT")
&& v.as_deref().and_then(|o| o.to_str()) == Some("0")
}));
assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
assert!(
!empty
.remote_branch_exists(Path::new("."), "x")
.await
.unwrap()
);
}
#[tokio::test]
async fn diff_stat_parses_counts() {
let git = Git::with_runner(ScriptedRunner::new().on(
["diff", "--shortstat"],
Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
));
let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
assert_eq!(
(stat.files_changed, stat.insertions, stat.deletions),
(2, 5, 1)
);
}
#[tokio::test]
async fn status_text_returns_raw_porcelain() {
let git = Git::with_runner(ScriptedRunner::new().on(
["status", "--porcelain=v1"],
Reply::ok(" M a.rs\n?? b.rs\n"),
));
let text = git.status_text(Path::new(".")).await.expect("status_text");
assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
}
#[tokio::test]
async fn run_args_forwards_str_slices() {
let git = Git::with_runner(ScriptedRunner::new().on(["status", "-s"], Reply::ok("ok\n")));
assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
}
#[tokio::test]
async fn merge_commit_builds_no_ff_and_message() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.merge_commit(
Path::new("/r"),
MergeCommit::branch("feature").no_ff().message("merge it"),
)
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["merge", "--no-ff", "-m", "merge it", "feature"]
);
}
#[tokio::test]
async fn merge_commit_without_message_uses_no_edit() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.merge_commit(Path::new("/r"), MergeCommit::branch("feature"))
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["merge", "--no-edit", "feature"]
);
}
#[tokio::test]
async fn rebase_suppresses_editor() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.rebase(Path::new("/r"), "main").await.unwrap();
let call = rec.only_call();
assert_eq!(call.args_str(), ["rebase", "main"]);
assert!(call.envs.iter().any(|(k, v)| {
k.to_str() == Some("GIT_EDITOR")
&& v.as_deref().and_then(|o| o.to_str()) == Some("true")
}));
}
#[tokio::test]
async fn push_builds_set_upstream_remote_refspec() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.push(
Path::new("/r"),
GitPush::refspec("feat", "feature").set_upstream(),
)
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["push", "-u", "origin", "feat:feature"]
);
}
#[tokio::test]
async fn push_bare_branch_builds_origin_branch_prompt_off() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.push(Path::new("/r"), GitPush::branch("feature"))
.await
.unwrap();
let call = rec.only_call();
assert_eq!(call.args_str(), ["push", "origin", "feature"]);
assert!(call.envs.iter().any(|(k, v)| {
k.to_str() == Some("GIT_TERMINAL_PROMPT")
&& v.as_deref().and_then(|o| o.to_str()) == Some("0")
}));
}
#[tokio::test]
async fn push_remote_override_swaps_remote() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.push(
Path::new("/r"),
GitPush::branch("feature").remote("upstream"),
)
.await
.unwrap();
assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
}
#[tokio::test]
async fn upstream_maps_unset_to_none() {
let set =
Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("origin/main\n")));
assert_eq!(
set.upstream(Path::new(".")).await.unwrap().as_deref(),
Some("origin/main")
);
let unset = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "")));
assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
}
#[tokio::test]
async fn set_upstream_builds_branch_flag() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.set_upstream(Path::new("/r"), "feat", "origin/feature")
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["branch", "--set-upstream-to=origin/feature", "feat"]
);
}
#[tokio::test]
async fn remote_branches_parses_ls_remote() {
let git = Git::with_runner(ScriptedRunner::new().on(
["ls-remote"],
Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
));
let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
assert_eq!(branches, ["main", "feat/x"]);
}
#[tokio::test]
async fn delete_branch_force_uses_capital_d() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.delete_branch(Path::new("/r"), "old", true)
.await
.unwrap();
assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
}
#[tokio::test]
async fn is_merged_strips_branch_markers() {
let git = Git::with_runner(ScriptedRunner::new().on(
["branch", "--merged"],
Reply::ok(" main\n* feature\n+ wt-branch\n"),
));
for name in ["main", "feature", "wt-branch"] {
assert!(
git.is_merged(Path::new("."), name, "main").await.unwrap(),
"{name} should be reported merged"
);
}
assert!(
!git.is_merged(Path::new("."), "absent", "main")
.await
.unwrap()
);
}
#[tokio::test]
async fn fetch_disables_terminal_prompt() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.fetch(Path::new("/r")).await.unwrap();
let call = rec.only_call();
assert_eq!(call.args_str(), ["fetch", "--quiet"]);
assert!(call.envs.iter().any(|(k, v)| {
k.to_str() == Some("GIT_TERMINAL_PROMPT")
&& v.as_deref().and_then(|o| o.to_str()) == Some("0")
}));
}
#[tokio::test]
async fn fetch_retries_transient_failures() {
let rec = RecordingRunner::replying(Reply::fail(
128,
"fatal: unable to access: Could not resolve host: example.com",
));
let git = Git::with_runner(&rec);
assert!(git.fetch(Path::new("/r")).await.is_err());
assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
}
#[tokio::test]
async fn fetch_does_not_retry_permanent_failures() {
let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
let git = Git::with_runner(&rec);
assert!(git.fetch(Path::new("/r")).await.is_err());
assert_eq!(rec.calls().len(), 1);
}
#[cfg(feature = "cancellation")]
#[tokio::test(start_paused = true)]
async fn fetch_cancels_and_does_not_retry() {
use processkit::CancellationToken;
let token = CancellationToken::new();
let rec = RecordingRunner::new(ScriptedRunner::new().on(["fetch"], Reply::pending()));
let git = Git::with_runner(&rec).default_cancel_on(token.clone());
let call = git.fetch(Path::new("/r"));
tokio::pin!(call);
assert!(
tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
.await
.is_err(),
"fetch must park until the token fires"
);
token.cancel();
assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
assert_eq!(
rec.calls().len(),
1,
"cancellation is terminal — the fetch-retry must not replay it"
);
}
#[tokio::test]
async fn flag_like_positionals_are_rejected_before_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
let dir = Path::new("/r");
assert!(git.checkout(dir, "-evil").await.is_err());
assert!(git.create_branch(dir, "--force").await.is_err());
assert!(git.delete_branch(dir, "-D", false).await.is_err());
assert!(git.rename_branch(dir, "ok", "-bad").await.is_err());
assert!(
git.merge_commit(dir, MergeCommit::branch("-evil"))
.await
.is_err()
);
assert!(
git.merge_no_commit(dir, MergeNoCommit::branch("-evil").no_ff())
.await
.is_err()
);
assert!(git.merge_squash(dir, "-evil").await.is_err());
assert!(git.rebase(dir, "-i").await.is_err());
assert!(git.cherry_pick(dir, "-n").await.is_err());
assert!(git.revert(dir, "-evil").await.is_err());
assert!(git.tag_create(dir, "-d", None).await.is_err());
assert!(
git.tag_create(dir, "ok", Some("-evil".into()))
.await
.is_err()
);
assert!(git.tag_delete(dir, "-evil").await.is_err());
assert!(git.remote_add(dir, "-evil", "url").await.is_err());
assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
assert!(git.set_upstream(dir, "-evil", "origin/x").await.is_err());
assert!(git.log_range(dir, "-evil", 5).await.is_err());
assert!(git.rev_list_count(dir, "-evil").await.is_err());
assert!(git.diff_stat(dir, "-evil").await.is_err());
assert!(git.diff_range_is_empty(dir, "-evil").await.is_err());
assert!(
git.diff_text(dir, DiffSpec::Rev("-evil".into()))
.await
.is_err()
);
assert!(git.rev_parse(dir, "-evil").await.is_err());
assert!(git.rev_parse_short(dir, "-evil").await.is_err());
assert!(git.resolve_commit(dir, "-evil").await.is_err());
assert!(git.reset_hard(dir, "-evil").await.is_err());
assert!(git.checkout_detach(dir, "-evil").await.is_err());
assert!(git.config_set(dir, "-evil", "v").await.is_err());
assert!(
git.push(dir, GitPush::branch("-evil")).await.is_err(),
"refspec guard"
);
assert!(git.show_file(dir, "-evil", "f.txt").await.is_err());
assert!(git.blame(dir, "f.txt", Some("-s".into())).await.is_err());
assert!(git.remote_url(dir, "-evil").await.is_err());
assert!(git.remote_branches(dir, "-evil").await.is_err());
assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
assert!(
git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
.await
.is_err()
);
assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
assert!(git.is_merged(dir, "-evil", "main").await.is_err());
assert!(git.config_get(dir, "-evil").await.is_err());
assert!(
git.worktree_add(
dir,
WorktreeAdd::create_branch(Path::new("/wt"), "-evil", "HEAD")
)
.await
.is_err()
);
assert!(git.checkout(dir, "").await.is_err());
assert!(
rec.calls().is_empty(),
"nothing may spawn: {:?}",
rec.calls()
);
git.checkout(dir, "feature/x").await.expect("checkout");
assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x"]);
}
#[tokio::test]
async fn harden_applies_env_profile_to_every_command() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec).harden();
git.status(Path::new("/r")).await.expect("status");
git.fetch(Path::new("/r")).await.expect("fetch");
for call in rec.calls() {
let has = |k: &str, v: &str| {
call.envs.iter().any(|(key, val)| {
key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
})
};
let removed = |k: &str| {
call.envs
.iter()
.any(|(key, val)| key.to_str() == Some(k) && val.is_none())
};
assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
assert!(has("GIT_TERMINAL_PROMPT", "0"));
assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
}
}
#[test]
fn ref_name_and_rev_spec_validate() {
for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
assert!(RefName::new(ok).is_ok(), "{ok}");
}
for bad in [
"", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
"a\\b", "end/", "x.lock",
] {
assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
}
assert!(RevSpec::new("HEAD~2").is_ok());
assert!(RevSpec::new("main..feature").is_ok());
assert!(RevSpec::new("-evil").is_err());
assert!(RevSpec::new("").is_err());
}
#[tokio::test]
async fn capabilities_parse_and_gate_versions() {
let gh = Git::with_runner(
ScriptedRunner::new().on(["--version"], Reply::ok("git version 2.54.0.windows.1\n")),
);
let caps = gh.capabilities().await.expect("capabilities");
assert_eq!(caps.version.to_string(), "2.54.0");
assert!(caps.is_supported());
caps.ensure_supported().expect("supported");
let old = Git::with_runner(
ScriptedRunner::new().on(["--version"], Reply::ok("git version 1.9\n")),
);
let caps = old.capabilities().await.expect("capabilities");
assert_eq!(
caps.version,
GitVersion {
major: 1,
minor: 9,
patch: 0
}
);
let err = caps.ensure_supported().expect_err("unsupported");
let Error::Spawn { source, .. } = &err else {
panic!("expected Spawn, got {err:?}");
};
let message = source.to_string();
assert!(message.contains(">= 2"), "names the floor: {message}");
assert!(
message.contains("1.9.0"),
"names the found version: {message}"
);
let garbage =
Git::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("not a version")));
assert!(matches!(
garbage.capabilities().await.unwrap_err(),
Error::Parse { .. }
));
}
#[tokio::test]
async fn clone_repo_builds_flags_and_runs_dirless() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.clone_repo(
"https://example.com/r.git",
Path::new("/dest"),
CloneSpec::new().branch("main").depth(1).bare(),
)
.await
.expect("clone");
let call = rec.only_call();
assert_eq!(
call.args_str(),
[
"clone",
"--branch",
"main",
"--depth",
"1",
"--bare",
"https://example.com/r.git",
"/dest"
]
);
assert_eq!(call.cwd, None, "clone runs without a working directory");
let bare = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&bare);
git.clone_repo("u", Path::new("/d"), CloneSpec::new())
.await
.expect("clone");
assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
}
#[tokio::test]
async fn tag_methods_build_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.tag_create(Path::new("/r"), "v1", None).await.unwrap();
git.tag_create(Path::new("/r"), "v1", Some("abc".into()))
.await
.unwrap();
git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new("v2", "notes"))
.await
.unwrap();
git.tag_delete(Path::new("/r"), "v1").await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["tag", "v1"]);
assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
}
#[tokio::test]
async fn tag_list_splits_lines() {
let git =
Git::with_runner(ScriptedRunner::new().on(["tag", "--list"], Reply::ok("v1\nv2.0\n")));
assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
}
#[tokio::test]
async fn list_commands_disable_column_output() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.branches(Path::new(".")).await.unwrap();
git.is_merged(Path::new("."), "b", "main").await.unwrap();
git.tag_list(Path::new(".")).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["branch", "--no-column"]);
assert_eq!(
calls[1].args_str(),
["branch", "--merged", "main", "--no-column"]
);
assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
}
#[tokio::test]
async fn classified_commands_force_c_locale() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.commit(Path::new("."), "msg").await.unwrap();
git.merge_commit(Path::new("."), MergeCommit::branch("b"))
.await
.unwrap();
git.cherry_pick(Path::new("."), "abc").await.unwrap();
git.fetch(Path::new(".")).await.unwrap();
for call in rec.calls() {
assert!(
call.envs.iter().any(|(k, v)| {
k.to_str() == Some("LC_ALL")
&& v.as_deref().and_then(|o| o.to_str()) == Some("C")
}),
"{:?} should force LC_ALL=C",
call.args_str()
);
}
}
#[cfg(windows)]
#[tokio::test]
async fn show_file_normalises_path_separators() {
let rec = RecordingRunner::replying(Reply::ok("content\n"));
let git = Git::with_runner(&rec);
let out = git
.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
.await
.expect("show_file");
assert_eq!(out, "content");
assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
}
#[cfg(not(windows))]
#[tokio::test]
async fn show_file_keeps_backslashes_on_unix() {
let rec = RecordingRunner::replying(Reply::ok("content\n"));
let git = Git::with_runner(&rec);
git.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
.await
.expect("show_file");
assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
}
#[tokio::test]
async fn config_get_maps_exit_codes() {
let set =
Git::with_runner(ScriptedRunner::new().on(["config", "--get"], Reply::ok("Alice\n")));
assert_eq!(
set.config_get(Path::new("."), "user.name").await.unwrap(),
Some("Alice".to_string())
);
let unset =
Git::with_runner(ScriptedRunner::new().on(["config", "--get"], Reply::fail(1, "")));
assert_eq!(
unset.config_get(Path::new("."), "user.name").await.unwrap(),
None
);
let multi = Git::with_runner(
ScriptedRunner::new().on(["config", "--get"], Reply::fail(2, "multiple values")),
);
assert!(
multi
.config_get(Path::new("."), "remote.all")
.await
.is_err()
);
}
#[tokio::test]
async fn blame_builds_rev_before_pathspec_separator() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.blame(Path::new("/r"), "src/lib.rs", Some("HEAD~1".into()))
.await
.unwrap();
git.blame(Path::new("/r"), "src/lib.rs", None)
.await
.unwrap();
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
);
assert_eq!(
calls[1].args_str(),
["blame", "--line-porcelain", "--", "src/lib.rs"]
);
}
#[tokio::test]
async fn sequencer_methods_suppress_editors() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.revert(Path::new("/r"), "abc").await.unwrap();
git.cherry_pick(Path::new("/r"), "abc").await.unwrap();
git.rebase_skip(Path::new("/r")).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
for call in &calls {
assert!(
call.envs
.iter()
.any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
"editor suppressed on {:?}",
call.args_str()
);
}
}
#[tokio::test]
async fn remote_add_and_set_url_build_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.remote_add(Path::new("/r"), "up", "https://x/y.git")
.await
.unwrap();
git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
.await
.unwrap();
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
["remote", "add", "up", "https://x/y.git"]
);
assert_eq!(
calls[1].args_str(),
["remote", "set-url", "up", "https://x/z.git"]
);
}
#[tokio::test]
async fn switch_with_stash_round_trips_dirty_tree() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["status"], Reply::ok(" M a.rs\0"))
.on(["stash", "push"], Reply::ok(""))
.on(["checkout"], Reply::ok(""))
.on(["stash", "pop"], Reply::ok("")),
);
let git = Git::with_runner(&rec);
git.switch_with_stash(Path::new("/r"), "feature")
.await
.expect("switch");
let calls = rec.calls();
assert_eq!(calls.len(), 4);
assert_eq!(
calls[1].args_str(),
["stash", "push", "--include-untracked"]
);
assert_eq!(calls[2].args_str(), ["checkout", "feature"]);
assert_eq!(calls[3].args_str(), ["stash", "pop"]);
}
#[tokio::test]
async fn switch_with_stash_skips_stash_on_clean_tree() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["status"], Reply::ok(""))
.on(["checkout"], Reply::ok("")),
);
let git = Git::with_runner(&rec);
git.switch_with_stash(Path::new("/r"), "feature")
.await
.expect("switch");
let calls = rec.calls();
assert_eq!(calls.len(), 2);
assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
}
#[tokio::test]
async fn switch_with_stash_restores_on_checkout_failure() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["status"], Reply::ok(" M a.rs\0"))
.on(["stash", "push"], Reply::ok(""))
.on(["checkout"], Reply::fail(1, "error: pathspec 'nope'"))
.on(["stash", "pop"], Reply::ok("")),
);
let git = Git::with_runner(&rec);
let err = git
.switch_with_stash(Path::new("/r"), "nope")
.await
.expect_err("checkout error must surface");
assert!(matches!(err, Error::Exit { .. }));
let calls = rec.calls();
assert_eq!(calls.len(), 4);
assert_eq!(calls[3].args_str(), ["stash", "pop"], "restoring pop ran");
}
#[tokio::test]
async fn fetch_from_builds_args_and_retries() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.fetch_from(Path::new("/r"), "upstream")
.await
.expect("fetch_from");
let call = rec.only_call();
assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
assert!(call.envs.iter().any(|(k, v)| {
k.to_str() == Some("GIT_TERMINAL_PROMPT")
&& v.as_deref().and_then(|o| o.to_str()) == Some("0")
}));
let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
let git = Git::with_runner(&failing);
assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
}
#[cfg(feature = "mock")]
#[tokio::test]
async fn consumer_mocks_the_interface() {
async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
git.current_branch(Path::new(".")).await.unwrap() == want
}
let mut mock = MockGitApi::new();
mock.expect_current_branch()
.returning(|_| Ok("main".to_string()));
assert!(on_branch(&mock, "main").await);
}
}
#[doc = include_str!("../docs/git.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {
#[doc = include_str!("../docs/security.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod security {}
#[doc = include_str!("../docs/conflicts.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod conflicts {}
}