#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use processkit::Command;
pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
pub use processkit::CancellationToken;
pub mod conflict;
mod parse;
pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
pub use vcs_diff::{
ChangeKind, DiffLine, DiffSpec, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
};
use vcs_cli_support::git_credential_helper;
pub use vcs_cli_support::{
Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, RetryPolicy,
Secret, StaticCredential, is_lock_contention, is_merge_conflict, is_nothing_to_commit,
is_transient_fetch_error, provider_fn,
};
pub const BINARY: &str = "git";
#[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)]
pub struct MergeCheckPartial {
branch: String,
}
impl MergeCheckPartial {
pub fn into_base(self, base: impl Into<String>) -> MergeCheck {
MergeCheck {
branch: self.branch,
base: base.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MergeCheck {
pub branch: String,
pub base: String,
}
impl MergeCheck {
pub fn branch(name: impl Into<String>) -> MergeCheckPartial {
MergeCheckPartial {
branch: name.into(),
}
}
}
#[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<Option<String>>;
async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
async fn log(&self, dir: &Path, revspec: &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, spec: MergeCheck) -> 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 is_am_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_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 am_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<()>;
}
vcs_cli_support::managed_client! {
pub struct Git => BINARY, scrub_env = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_NAMESPACE",
]
}
impl<R: ProcessRunner> Git<R> {
pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
self.core = self.core.with_retry(policy);
self
}
#[must_use]
pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
self.core = self.core.with_credentials(provider);
self
}
#[must_use]
pub fn with_token(self, token: impl Into<Secret>) -> Self {
self.with_credentials(Arc::new(StaticCredential::token(token)))
}
#[must_use]
pub fn with_env_token(self, var: impl Into<String>) -> Self {
self.with_credentials(Arc::new(EnvToken::new(var)))
}
async fn remote_credentials(
&self,
expect_host: Option<&str>,
) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
match self
.core
.resolve_credential(CredentialService::Git, None)
.await?
{
Some(cred) => {
let helper = git_credential_helper(&cred, expect_host);
Ok((helper.config_args, helper.env))
}
None => Ok((Vec::new(), Vec::new())),
}
}
}
fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
envs.iter()
.fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
}
#[async_trait::async_trait]
impl<R: ProcessRunner> GitApi for Git<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.run(args).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.output_string(args).await
}
async fn version(&self) -> Result<String> {
self.core.run(["--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<Option<String>> {
let res = self
.core
.output_string(
self.core
.command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
)
.await?;
match res.code() {
Some(0) => Ok(Some(res.stdout().trim().to_string())),
Some(1) => Ok(None), _ => {
let _ = res.ensure_success()?;
Ok(None) }
}
}
async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
self.core
.parse(
self.core
.command_in(dir, ["branch", "--no-column", "--no-color"]),
parse::parse_branches,
)
.await
}
async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>> {
reject_flag_like("revspec", revspec)?;
let n = format!("-n{max}");
self.core
.parse(
self.core.command_in(
dir,
[
"log",
revspec,
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", "--verify", 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_string(
self.core
.command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
)
.await?;
match res.code() {
Some(0) => {
let out = res.stdout().trim();
Ok(Some(
out.strip_prefix("refs/remotes/origin/")
.unwrap_or(out)
.to_string(),
))
}
Some(1) => Ok(None), _ => {
let _ = res.ensure_success()?;
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 (pre, envs) = self.remote_credentials(None).await?;
let mut args: Vec<String> = pre;
args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
let cmd = apply_secret_env(
self.core
.command_in(dir, &args)
.env("GIT_TERMINAL_PROMPT", "0")
.timeout(Duration::from_secs(10)),
&envs,
);
let res = self.core.output_string(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>> {
let res = self
.core
.output_string(self.core.command_in(
dir,
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
))
.await?;
match res.code() {
Some(0) => {
let name = res.stdout().trim();
Ok((!name.is_empty()).then(|| name.to_string()))
}
Some(_) => Ok(None), None => {
let _ = res.ensure_success()?; Ok(None) }
}
}
async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
reject_flag_like("remote name", remote)?;
let (pre, envs) = self.remote_credentials(None).await?;
let mut args: Vec<String> = pre;
args.extend(["ls-remote", "--heads", remote].map(String::from));
let cmd = apply_secret_env(
self.core
.command_in(dir, &args)
.env("GIT_TERMINAL_PROMPT", "0"),
&envs,
);
self.core.parse(cmd, parse::parse_ls_remote_heads).await
}
async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool> {
reject_flag_like("branch", &spec.branch)?;
reject_flag_like("base", &spec.base)?;
let out = self
.core
.run(self.core.command_in(
dir,
[
"branch",
"--merged",
spec.base.as_str(),
"--no-column",
"--no-color",
],
))
.await?;
Ok(out
.lines()
.filter_map(|line| line.get(2..))
.any(|b| b == spec.branch.as_str()))
}
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(
c_locale(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_untrimmed(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?;
let rebase_apply = git_dir.join("rebase-apply");
let is_rebase_apply = rebase_apply.exists() && !rebase_apply.join("applying").exists();
Ok(git_dir.join("rebase-merge").exists() || is_rebase_apply)
}
async fn is_am_in_progress(&self, dir: &Path) -> Result<bool> {
Ok(self
.resolved_git_dir(dir)
.await?
.join("rebase-apply")
.join("applying")
.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 (pre, envs) = self.remote_credentials(None).await?;
let mut args: Vec<String> = pre;
args.extend(["fetch", "--quiet"].map(String::from));
let cmd = apply_secret_env(
c_locale(self.core.command_in(dir, &args))
.env("GIT_TERMINAL_PROMPT", "0")
.timeout_grace(FETCH_TIMEOUT_GRACE)
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
&envs,
);
self.core.run_unit(cmd).await
}
async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
reject_flag_like("remote", remote)?;
let (pre, envs) = self.remote_credentials(None).await?;
let mut args: Vec<String> = pre;
args.extend(["fetch", "--quiet", remote].map(String::from));
let cmd = apply_secret_env(
c_locale(self.core.command_in(dir, &args))
.env("GIT_TERMINAL_PROMPT", "0")
.timeout_grace(FETCH_TIMEOUT_GRACE)
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
&envs,
);
self.core.run_unit(cmd).await
}
async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
let (pre, envs) = self.remote_credentials(None).await?;
let mut args: Vec<String> = pre;
args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
let cmd = apply_secret_env(
c_locale(self.core.command_in(dir, &args))
.env("GIT_TERMINAL_PROMPT", "0")
.timeout_grace(FETCH_TIMEOUT_GRACE)
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
&envs,
);
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 sides: Vec<&str> = spec.refspec.split(':').collect();
if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
return Err(processkit::Error::Spawn {
program: BINARY.to_string(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
metacharacter — pass a plain branch or `local:remote`, or use \
`run([\"push\", …])` for a force-push",
spec.refspec
),
),
});
}
let (pre, envs) = self.remote_credentials(None).await?;
let mut args: Vec<String> = pre;
args.push("push".to_string());
if spec.set_upstream {
args.push("-u".to_string());
}
args.push(spec.remote.clone());
args.push(spec.refspec.clone());
let cmd = apply_secret_env(
self.core
.command_in(dir, &args)
.env("GIT_TERMINAL_PROMPT", "0")
.timeout_grace(FETCH_TIMEOUT_GRACE),
&envs,
);
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(c_locale(
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 am_abort(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(c_locale(self.core.command_in(dir, ["am", "--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(c_locale(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 (pre, envs) = self
.remote_credentials(vcs_cli_support::https_host(url).as_deref())
.await?;
let mut initial: Vec<String> = pre;
initial.push("clone".to_string());
let mut command = self.core.command(&initial);
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 = apply_secret_env(
command
.arg(url)
.arg(dest)
.env("GIT_TERMINAL_PROMPT", "0")
.timeout_grace(FETCH_TIMEOUT_GRACE),
&envs,
);
let cleanable = match std::fs::read_dir(dest) {
Err(_) => true, Ok(mut entries) => entries.next().is_none(), };
let result = self.core.run_unit(command).await;
if result.is_err() && cleanable {
let _ = std::fs::remove_dir_all(dest);
}
result
}
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_untrimmed(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_string(self.core.command_in(dir, ["config", "--get", key]))
.await?;
match res.code() {
Some(1) => Ok(None),
Some(0) => Ok(Some(
res.stdout().trim_end_matches(['\r', '\n']).to_string(),
)),
_ => {
let _ = 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;
const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
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(args).await
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.output_string(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_COMMON_DIR",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_NAMESPACE",
"GIT_CEILING_DIRECTORIES",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_SSH_COMMAND",
"GIT_SSH",
"GIT_ASKPASS",
"GIT_EXTERNAL_DIFF",
"GIT_PAGER",
"GIT_EDITOR",
"GIT_SEQUENCE_EDITOR",
"GIT_PROXY_COMMAND",
"GIT_EXEC_PATH",
"GIT_TEMPLATE_DIR",
"GIT_LITERAL_PATHSPECS",
"GIT_GLOB_PATHSPECS",
"GIT_NOGLOB_PATHSPECS",
"GIT_ICASE_PATHSPECS",
];
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", "3")
.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")
.default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
.default_env("GIT_CONFIG_VALUE_2", "")
}
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;
}
let depth_before = self.stash_depth(dir).await?;
self.stash_push(dir, true).await?;
if self.stash_depth(dir).await? <= depth_before {
return self.checkout(dir, branch).await;
}
match self.checkout(dir, branch).await {
Ok(()) => self.stash_pop_index(dir).await,
Err(err) => {
let _ = self.stash_pop_index(dir).await;
Err(err)
}
}
}
async fn stash_depth(&self, dir: &Path) -> Result<usize> {
let out = self
.core
.run(self.core.command_in(dir, ["stash", "list"]))
.await?;
Ok(out.lines().filter(|l| !l.is_empty()).count())
}
async fn stash_pop_index(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(c_locale(
self.core.command_in(dir, ["stash", "pop", "--index"]),
))
.await
}
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> {}
vcs_cli_support::at_forwarders! {
GitAt, git, "Git",
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<Option<String>>;
fn branches() -> Result<Vec<Branch>>;
fn log(revspec: &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(spec: MergeCheck) -> 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 is_am_in_progress() -> Result<bool>;
fn fetch() -> Result<()>;
fn fetch_from(remote: &str) -> Result<()>;
fn fetch_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 am_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::testing::{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));
assert_eq!(calls[3].cwd.as_deref(), Some(dir));
}
#[tokio::test]
async fn status_parses_scripted_output() {
let git = Git::with_runner(
ScriptedRunner::new().on(["git", "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 rev_parse_verifies_the_revision() {
let rec = RecordingRunner::replying(Reply::ok("deadbeef\n"));
let git = Git::with_runner(&rec);
let out = git.rev_parse(Path::new("/r"), "HEAD").await.unwrap();
assert_eq!(out, "deadbeef");
assert_eq!(
rec.only_call().args_str(),
["rev-parse", "--verify", "HEAD"]
);
}
#[tokio::test]
async fn distinguishes_git_am_from_an_apply_backend_rebase() {
use vcs_testkit::TempDir;
let gd = TempDir::new("m20-am");
let git = Git::with_runner(ScriptedRunner::new().on(
["git", "rev-parse", "--git-dir"],
Reply::ok(gd.path().to_str().unwrap()),
));
let apply = gd.path().join("rebase-apply");
std::fs::create_dir_all(&apply).unwrap();
std::fs::write(apply.join("applying"), b"").unwrap();
assert!(
git.is_am_in_progress(Path::new("/r")).await.unwrap(),
"am detected"
);
assert!(
!git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
"a git am is NOT reported as a rebase"
);
std::fs::remove_file(apply.join("applying")).unwrap();
assert!(!git.is_am_in_progress(Path::new("/r")).await.unwrap());
assert!(
git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
"a bare rebase-apply dir is a rebase"
);
}
#[tokio::test]
async fn nonzero_exit_is_structured_error() {
let git = Git::with_runner(
ScriptedRunner::new().on(["git", "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(["git", "diff", "--quiet"], Reply::ok("")));
assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
let dirty = Git::with_runner(
ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
);
assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
let broken = Git::with_runner(ScriptedRunner::new().on(
["git", "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(["git", "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(
["git", "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 current_branch_reads_symbolic_ref_with_exit_mapping() {
let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
let on_branch = Git::with_runner(&rec);
assert_eq!(
on_branch.current_branch(Path::new(".")).await.unwrap(),
Some("feature/x".to_string())
);
assert_eq!(
rec.only_call().args_str(),
["symbolic-ref", "--quiet", "--short", "HEAD"]
);
let unborn = Git::with_runner(
ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
);
assert_eq!(
unborn.current_branch(Path::new(".")).await.unwrap(),
Some("main".to_string())
);
let detached =
Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
let not_repo = Git::with_runner(ScriptedRunner::new().on(
["git", "symbolic-ref"],
Reply::fail(128, "fatal: not a git repository"),
));
assert!(not_repo.current_branch(Path::new(".")).await.is_err());
}
#[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(["git", "rev-parse"], Reply::ok("abc\n")));
assert!(!born.is_unborn(Path::new(".")).await.unwrap());
let unborn =
Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
let broken = Git::with_runner(
ScriptedRunner::new().on(["git", "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_builds_revspec_and_format() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.log(Path::new("."), "main..HEAD", 5).await.expect("log");
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(["git", "rev-parse"], Reply::fail(1, "")) .on(["git", "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(["git", "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(["git", "show-ref"], Reply::ok("")));
assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
let no =
Git::with_runner(ScriptedRunner::new().on(["git", "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(
["git", "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(
["git", "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(["git", "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(["git", "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(
["git", "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(
["git", "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(["git", "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_rejects_force_and_multiref_metacharacters() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
for bad in ["+main", "+main:main", "a:b:c"] {
assert!(
git.push(Path::new("/r"), GitPush::branch(bad))
.await
.is_err(),
"{bad:?} must be rejected"
);
}
assert!(
git.push(Path::new("/r"), GitPush::refspec("main", "prod"))
.await
.is_ok()
);
assert!(
rec.calls()
.iter()
.all(|c| c.args_str().last().unwrap() != "+main")
);
}
#[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 with_credentials_injects_helper_and_secret_env_for_remote_ops() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec)
.with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
git.push(Path::new("/r"), GitPush::branch("feature"))
.await
.unwrap();
let call = rec.only_call();
let args = call.args_str();
assert_eq!(args[0], "-c", "config flag leads the argv");
assert!(
args.iter().any(|a| a == "credential.helper="),
"inherited helpers are cleared first: {args:?}"
);
assert!(
args.iter()
.any(|a| a.contains("credential.helper=!f()")
&& a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
"inline helper references the secret by env-var name: {args:?}"
);
assert!(
args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
"the real subcommand still runs: {args:?}"
);
assert!(
!args.iter().any(|a| a.contains("ghp_secret123")),
"secret leaked into argv: {args:?}"
);
let pw = call
.envs
.iter()
.find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
.and_then(|(_, v)| v.as_ref())
.and_then(|v| v.to_str());
assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
}
#[tokio::test]
async fn default_client_injects_no_credential_helper() {
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"],
"no credential `-c` args without a provider"
);
assert!(
!call
.envs
.iter()
.any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
"no secret env without a provider"
);
}
#[tokio::test]
async fn with_credentials_clone_puts_config_flags_before_subcommand() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git =
Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
git.clone_repo(
"https://example.com/r.git",
Path::new("/dest"),
CloneSpec::default().branch("main"),
)
.await
.unwrap();
let call = rec.only_call();
let args = call.args_str();
assert_eq!(args[0], "-c", "config flags lead the clone argv");
let clone_at = args
.iter()
.position(|a| a == "clone")
.expect("clone present");
assert!(
args[..clone_at]
.iter()
.all(|a| a == "-c" || a.starts_with("credential.helper")),
"only credential -c flags precede `clone`: {args:?}"
);
let tail = &args[clone_at..];
assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
assert!(
!args.iter().any(|a| a.contains("s3cr3t")),
"secret not in argv"
);
let host = call
.envs
.iter()
.find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_HOST"))
.and_then(|(_, v)| v.as_ref())
.and_then(|v| v.to_str());
assert_eq!(
host,
Some("example.com"),
"the clone URL's host scopes the credential helper"
);
assert!(
args[..clone_at].iter().all(|a| !a.contains("example.com")),
"host stays in env, not the credential config args: {:?}",
&args[..clone_at]
);
}
#[tokio::test]
async fn with_credentials_userpass_threads_username_through_env() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
Credential::userpass("alice", "s3cr3t"),
)));
git.fetch(Path::new("/r")).await.unwrap();
let call = rec.only_call();
let user = call
.envs
.iter()
.find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
.and_then(|(_, v)| v.as_ref())
.and_then(|v| v.to_str());
assert_eq!(user, Some("alice"), "userpass username reaches the env");
assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
assert!(call.args_str().contains(&"fetch".to_string()));
}
#[tokio::test]
async fn default_client_no_helper_on_fetch_and_clone() {
let rec = RecordingRunner::replying(Reply::ok(""));
Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
assert_eq!(
rec.only_call().args_str(),
["fetch", "--quiet"],
"fetch unchanged without a provider"
);
let rec = RecordingRunner::replying(Reply::ok(""));
Git::with_runner(&rec)
.clone_repo(
"https://example.com/r.git",
Path::new("/dest"),
CloneSpec::default(),
)
.await
.unwrap();
assert_eq!(
rec.only_call().args_str()[0],
"clone",
"clone leads with the subcommand (no `-c`) without a provider"
);
}
#[tokio::test]
async fn with_token_convenience_authenticates_https_remote() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec).with_token("ghp_conv");
git.fetch(Path::new("/r")).await.unwrap();
let call = rec.only_call();
assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
let pw = call
.envs
.iter()
.find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
.and_then(|(_, v)| v.as_ref())
.and_then(|v| v.to_str());
assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
assert!(
!call.args_str().iter().any(|a| a.contains("ghp_conv")),
"secret not in argv"
);
}
#[tokio::test]
async fn upstream_maps_unset_to_none() {
let set = Git::with_runner(
ScriptedRunner::new().on(["git", "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(["git", "rev-parse"], Reply::fail(128, "")));
assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
let timed_out =
Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::timeout()));
assert!(timed_out.upstream(Path::new(".")).await.is_err());
}
#[tokio::test]
async fn remote_head_branch_maps_exit_codes() {
let set = Git::with_runner(ScriptedRunner::new().on(
["git", "symbolic-ref"],
Reply::ok("refs/remotes/origin/release/v2\n"),
));
assert_eq!(
set.remote_head_branch(Path::new("."))
.await
.unwrap()
.as_deref(),
Some("release/v2"),
"the full ref prefix is stripped, slashes preserved"
);
let unset =
Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
assert!(
unset
.remote_head_branch(Path::new("."))
.await
.unwrap()
.is_none()
);
let err = Git::with_runner(ScriptedRunner::new().on(
["git", "symbolic-ref"],
Reply::fail(128, "fatal: not a git repository"),
));
assert!(err.remote_head_branch(Path::new(".")).await.is_err());
let timed_out =
Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
}
#[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(
["git", "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(
["git", "branch", "--merged"],
Reply::ok(" main\n* feature\n+ wt-branch\n"),
));
for name in ["main", "feature", "wt-branch"] {
assert!(
git.is_merged(Path::new("."), MergeCheck::branch(name).into_base("main"))
.await
.unwrap(),
"{name} should be reported merged"
);
}
assert!(
!git.is_merged(
Path::new("."),
MergeCheck::branch("absent").into_base("main")
)
.await
.unwrap()
);
}
#[tokio::test]
async fn merge_check_names_branch_and_base_without_transposition() {
use processkit::testing::RecordingRunner;
let spec = MergeCheck::branch("feature").into_base("main");
assert_eq!(spec.branch, "feature");
assert_eq!(spec.base, "main");
let rec = RecordingRunner::replying(Reply::ok(" feature\n* main\n"));
let merged = Git::with_runner(&rec)
.is_merged(
Path::new("/repo"),
MergeCheck::branch("feature").into_base("main"),
)
.await
.unwrap();
assert!(merged, "feature is listed as merged into main");
assert_eq!(
rec.only_call().args_str(),
["branch", "--merged", "main", "--no-column", "--no-color"]
);
}
#[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 with_retry_retries_lock_contention_on_a_mutation() {
let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
["git", "commit"],
[
Reply::fail(
128,
"fatal: Unable to create '/r/.git/index.lock': File exists.",
),
Reply::ok(""),
],
));
let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
git.commit(Path::new("/r"), "msg")
.await
.expect("retried past the lock");
assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
}
#[tokio::test]
async fn default_client_does_not_retry_lock_contention() {
let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
["git", "commit"],
[
Reply::fail(
128,
"fatal: Unable to create '/r/.git/index.lock': File exists.",
),
Reply::ok(""),
],
));
let git = Git::with_runner(&rec);
assert!(git.commit(Path::new("/r"), "msg").await.is_err());
assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
}
#[tokio::test]
async fn with_retry_does_not_retry_a_real_failure() {
let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
["git", "commit"],
[
Reply::fail(1, "error: pathspec 'x' did not match"),
Reply::ok(""),
],
));
let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
assert!(git.commit(Path::new("/r"), "msg").await.is_err());
assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
}
#[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);
}
#[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(["git", "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(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, MergeCheck::branch("-evil").into_base("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_COUNT", "3"));
assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
assert!(has("GIT_CONFIG_VALUE_2", ""));
assert!(has("GIT_TERMINAL_PROMPT", "0"));
assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
assert!(removed("GIT_PROXY_COMMAND"), "GIT_PROXY_COMMAND scrubbed");
assert!(removed("GIT_EXEC_PATH"), "GIT_EXEC_PATH scrubbed");
assert!(removed("GIT_TEMPLATE_DIR"), "GIT_TEMPLATE_DIR scrubbed");
assert!(
removed("GIT_ICASE_PATHSPECS"),
"GIT_ICASE_PATHSPECS scrubbed"
);
}
}
#[tokio::test]
async fn default_client_scrubs_repo_redirector_env() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec); git.status(Path::new("/r")).await.expect("status");
let call = rec.only_call();
let removed = |k: &str| {
call.envs
.iter()
.any(|(key, val)| key.to_str() == Some(k) && val.is_none())
};
let has_key = |k: &str| call.envs.iter().any(|(key, _)| key.to_str() == Some(k));
for var in [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_NAMESPACE",
] {
assert!(removed(var), "{var} must be scrubbed on the default client");
}
assert!(
!has_key("GIT_SSH_COMMAND"),
"command-hook scrub is harden()-only"
);
assert!(
!has_key("GIT_CONFIG_NOSYSTEM"),
"config pins are harden()-only"
);
}
#[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(
["git", "--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(["git", "--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(["git", "--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 clone_failure_cleans_only_a_dest_it_could_have_created() {
use vcs_testkit::TempDir;
let tmp = TempDir::new("r7-clone");
let git = Git::with_runner(ScriptedRunner::new().on(
["git", "clone"],
Reply::fail(
128,
"fatal: could not read Username for 'https://x': prompts disabled",
),
));
let occupied = tmp.path().join("occupied");
std::fs::create_dir(&occupied).unwrap();
std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
assert!(
git.clone_repo("https://x/r", &occupied, CloneSpec::new())
.await
.is_err()
);
assert!(
occupied.join("keep.txt").exists(),
"a non-empty caller dir must survive a failed clone"
);
let empty = tmp.path().join("empty");
std::fs::create_dir(&empty).unwrap();
assert!(
git.clone_repo("https://x/r", &empty, CloneSpec::new())
.await
.is_err()
);
assert!(
!empty.exists(),
"an empty dest is cleaned so a retry isn't blocked"
);
let file_dest = tmp.path().join("a-file");
std::fs::write(&file_dest, b"caller file").unwrap();
assert!(
git.clone_repo("https://x/r", &file_dest, CloneSpec::new())
.await
.is_err()
);
assert!(
file_dest.exists() && std::fs::read(&file_dest).unwrap() == b"caller file",
"a caller's file at dest must survive a failed clone"
);
#[cfg(unix)]
{
let target = tmp.path().join("link-target"); std::fs::create_dir(&target).unwrap();
let sentinel = tmp.path().join("sibling.txt");
std::fs::write(&sentinel, b"untouched").unwrap();
let link = tmp.path().join("a-symlink");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(
git.clone_repo("https://x/r", &link, CloneSpec::new())
.await
.is_err()
);
assert!(
target.exists() && sentinel.exists(),
"a failed clone must unlink at most the symlink, never delete through it"
);
}
}
#[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(["git", "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_and_color() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.branches(Path::new(".")).await.unwrap();
git.is_merged(Path::new("."), MergeCheck::branch("b").into_base("main"))
.await
.unwrap();
git.tag_list(Path::new(".")).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["branch", "--no-column", "--no-color"]);
assert_eq!(
calls[1].args_str(),
["branch", "--merged", "main", "--no-column", "--no-color"]
);
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.merge_squash(Path::new("."), "b").await.unwrap();
git.merge_no_commit(Path::new("."), MergeNoCommit::branch("b"))
.await
.unwrap();
git.cherry_pick(Path::new("."), "abc").await.unwrap();
git.stash_pop(Path::new(".")).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\n");
assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
}
#[tokio::test]
async fn content_verbs_preserve_exact_trailing_bytes() {
for raw in ["a\nb\n\n", "no-final-newline", "trailing spaces \n"] {
let rec = RecordingRunner::replying(Reply::ok(raw));
let git = Git::with_runner(&rec);
let out = git
.show_file(Path::new("/r"), "HEAD", "f.txt")
.await
.expect("show_file");
assert_eq!(out, raw, "show_file returns bytes verbatim");
}
let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
let rec = RecordingRunner::replying(Reply::ok(diff));
let git = Git::with_runner(&rec);
assert_eq!(
git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
.await
.expect("diff_text"),
diff
);
}
#[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(["git", "config", "--get"], Reply::ok("Alice\n")),
);
assert_eq!(
set.config_get(Path::new("."), "user.name").await.unwrap(),
Some("Alice".to_string())
);
let spaced = Git::with_runner(
ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix: \r\n")),
);
assert_eq!(
spaced.config_get(Path::new("."), "x.y").await.unwrap(),
Some("prefix: ".to_string())
);
let unset = Git::with_runner(
ScriptedRunner::new().on(["git", "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(
["git", "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 hardened_sequencer_keeps_its_no_op_editor() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec).harden();
git.revert(Path::new("/r"), "abc").await.unwrap();
let call = rec.only_call();
let effective = |var: &str| {
call.envs
.iter()
.rfind(|(k, _)| k.to_str() == Some(var))
.and_then(|(_, v)| v.as_deref())
.and_then(|v| v.to_str())
};
assert_eq!(
effective("GIT_EDITOR"),
Some("true"),
"the per-command no-op editor must survive harden()'s scrub"
);
assert_eq!(
effective("GIT_SEQUENCE_EDITOR"),
Some("true"),
"the per-command no-op sequence editor must survive harden()'s scrub"
);
}
#[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(["git", "status"], Reply::ok(" M a.rs\0"))
.on_sequence(
["git", "stash", "list"],
[Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
)
.on(["git", "stash", "push"], Reply::ok(""))
.on(["git", "checkout"], Reply::ok(""))
.on(["git", "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(), 6);
assert_eq!(
calls[2].args_str(),
["stash", "push", "--include-untracked"]
);
assert_eq!(calls[4].args_str(), ["checkout", "feature", "--"]);
assert_eq!(calls[5].args_str(), ["stash", "pop", "--index"]);
}
#[tokio::test]
async fn switch_with_stash_does_not_pop_when_push_saved_nothing() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["git", "status"], Reply::ok(" M sub\0"))
.on(
["git", "stash", "list"],
Reply::ok("stash@{0}: someone else's WIP\n"),
)
.on(
["git", "stash", "push"],
Reply::ok("No local changes to save\n"),
)
.on(["git", "checkout"], Reply::ok("")),
);
let git = Git::with_runner(&rec);
git.switch_with_stash(Path::new("/r"), "feature")
.await
.expect("switch");
assert!(
rec.calls()
.iter()
.all(|c| c.args_str() != ["stash", "pop", "--index"]
&& c.args_str() != ["stash", "pop"]),
"must not pop an unrelated stash when the push saved nothing"
);
}
#[tokio::test]
async fn switch_with_stash_skips_stash_on_clean_tree() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["git", "status"], Reply::ok(""))
.on(["git", "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(["git", "status"], Reply::ok(" M a.rs\0"))
.on_sequence(
["git", "stash", "list"],
[Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
)
.on(["git", "stash", "push"], Reply::ok(""))
.on(
["git", "checkout"],
Reply::fail(1, "error: pathspec 'nope'"),
)
.on(["git", "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.last().unwrap().args_str(),
["stash", "pop", "--index"],
"restoring pop ran with --index"
);
}
#[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().as_deref() == Some(want)
}
let mut mock = MockGitApi::new();
mock.expect_current_branch()
.returning(|_| Ok(Some("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 {}
}