#![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, OutputBudget,
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<RefName>,
pub commitish: Option<RevSpec>,
pub no_checkout: bool,
}
impl WorktreeAdd {
pub fn checkout(path: impl Into<PathBuf>, commitish: RevSpec) -> Self {
Self {
path: path.into(),
new_branch: None,
commitish: Some(commitish),
no_checkout: false,
}
}
pub fn create_branch(path: impl Into<PathBuf>, name: RefName, commitish: RevSpec) -> Self {
Self {
path: path.into(),
new_branch: Some(name),
commitish: Some(commitish),
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: RefName) -> Self {
Self {
remote: "origin".to_string(),
refspec: name.as_str().to_string(),
set_upstream: false,
}
}
pub fn refspec(local: &RefName, remote_branch: &RefName) -> Self {
Self {
remote: "origin".to_string(),
refspec: format!("{}:{}", local.as_str(), remote_branch.as_str()),
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: RefName,
}
impl MergeCheckPartial {
pub fn into_base(self, base: RevSpec) -> MergeCheck {
MergeCheck {
branch: self.branch,
base,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MergeCheck {
pub branch: RefName,
pub base: RevSpec,
}
impl MergeCheck {
pub fn branch(name: RefName) -> MergeCheckPartial {
MergeCheckPartial { branch: name }
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct MergeCommit {
pub branch: RevSpec,
pub no_ff: bool,
pub message: Option<String>,
}
impl MergeCommit {
pub fn branch(target: RevSpec) -> Self {
Self {
branch: target,
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: RevSpec,
pub squash: bool,
pub no_ff: bool,
}
impl MergeNoCommit {
pub fn branch(target: RevSpec) -> Self {
Self {
branch: target,
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: RefName,
pub message: String,
pub rev: Option<RevSpec>,
}
impl AnnotatedTag {
pub fn new(name: RefName, message: impl Into<String>) -> Self {
Self {
name,
message: message.into(),
rev: None,
}
}
pub fn rev(mut self, r: RevSpec) -> Self {
self.rev = Some(r);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BranchDelete {
pub name: RefName,
pub force: bool,
}
impl BranchDelete {
pub fn new(name: RefName) -> Self {
Self { name, force: false }
}
pub fn force(mut self) -> Self {
self.force = true;
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct StashPush {
pub include_untracked: bool,
}
impl StashPush {
pub fn new() -> Self {
Self::default()
}
pub fn include_untracked(mut self) -> Self {
self.include_untracked = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WorktreeRemove {
pub path: PathBuf,
pub force: bool,
}
impl WorktreeRemove {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
force: false,
}
}
pub fn force(mut self) -> Self {
self.force = true;
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(
BINARY,
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)
}
}
impl std::str::FromStr for RefName {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::new(s)
}
}
impl std::str::FromStr for RevSpec {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::new(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckoutTarget {
Ref(RevSpec),
Previous,
}
impl CheckoutTarget {
pub fn rev(rev: RevSpec) -> Self {
Self::Ref(rev)
}
pub fn previous() -> Self {
Self::Previous
}
fn as_arg(&self) -> &str {
match self {
Self::Ref(rev) => rev.as_str(),
Self::Previous => "-",
}
}
}
impl From<RevSpec> for CheckoutTarget {
fn from(rev: RevSpec) -> Self {
Self::Ref(rev)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct GitCapabilities {
pub version: GitVersion,
}
const MIN_SUPPORTED_MAJOR: u64 = 2;
const MIN_SUPPORTED_MINOR: u64 = 31;
impl GitCapabilities {
pub fn is_supported(&self) -> bool {
(self.version.major, self.version.minor) >= (MIN_SUPPORTED_MAJOR, MIN_SUPPORTED_MINOR)
}
pub fn ensure_supported(&self) -> Result<()> {
if self.is_supported() {
return Ok(());
}
Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"vcs-git requires git >= {MIN_SUPPORTED_MAJOR}.{MIN_SUPPORTED_MINOR} \
(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<PathBuf>>;
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: &RevSpec, max: usize) -> Result<Vec<Commit>>;
async fn log_paths(
&self,
dir: &Path,
revspec: &RevSpec,
max: usize,
paths: &[String],
) -> Result<Vec<Commit>>;
async fn rev_parse(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
async fn rev_parse_short(&self, dir: &Path, rev: &RevSpec) -> 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: &RefName) -> Result<()>;
async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()>;
async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> 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: &RevSpec) -> Result<String>;
async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
async fn branch_exists(&self, dir: &Path, name: &RefName) -> Result<bool>;
async fn remote_branch_exists(&self, dir: &Path, name: &RefName) -> 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: &RefName, upstream: &RefName) -> Result<()>;
async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()>;
async fn rename_branch(&self, dir: &Path, old: &RefName, new: &RefName) -> Result<()>;
async fn rev_list_count(&self, dir: &Path, range: &RevSpec) -> Result<usize>;
async fn diff_range_is_empty(&self, dir: &Path, range: &RevSpec) -> Result<bool>;
async fn diff_stat(&self, dir: &Path, range: &RevSpec) -> 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 is_cherry_pick_in_progress(&self, dir: &Path) -> Result<bool>;
async fn is_revert_in_progress(&self, dir: &Path) -> Result<bool>;
async fn is_bisect_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: &RefName) -> Result<()>;
async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
async fn merge_squash(&self, dir: &Path, branch: &RevSpec) -> 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: &RevSpec) -> Result<()>;
async fn rebase(&self, dir: &Path, onto: &RevSpec) -> 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, spec: StashPush) -> 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, spec: WorktreeRemove) -> 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: &RefName, rev: Option<RevSpec>) -> 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: &RefName) -> Result<()>;
async fn show_file(&self, dir: &Path, rev: &RevSpec, 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<RevSpec>) -> Result<Vec<BlameLine>>;
async fn cherry_pick(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
async fn revert(&self, dir: &Path, rev: &RevSpec) -> Result<()>;
async fn rebase_skip(&self, dir: &Path) -> Result<()>;
async fn cherry_pick_abort(&self, dir: &Path) -> Result<()>;
async fn cherry_pick_continue(&self, dir: &Path) -> Result<()>;
async fn revert_abort(&self, dir: &Path) -> Result<()>;
async fn revert_continue(&self, dir: &Path) -> Result<()>;
async fn bisect_reset(&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, expect_host)
.await?
{
Some(cred) => {
let helper = git_credential_helper(&cred, expect_host);
Ok((helper.config_args, helper.env))
}
None => Ok((Vec::new(), Vec::new())),
}
}
}
impl<R: ProcessRunner> Git<R> {
pub async fn diff_text_within(
&self,
dir: &Path,
spec: DiffSpec,
budget: OutputBudget,
) -> Result<String> {
self.diff_text_budgeted(dir, spec, budget).await
}
pub async fn diff_within(
&self,
dir: &Path,
spec: DiffSpec,
budget: OutputBudget,
) -> Result<Vec<FileDiff>> {
let text = self.diff_text_budgeted(dir, spec, budget).await?;
Ok(parse_diff(&text))
}
async fn diff_text_budgeted(
&self,
dir: &Path,
spec: DiffSpec,
budget: OutputBudget,
) -> Result<String> {
let target = match spec {
DiffSpec::WorkingTree => {
if self.is_unborn(dir).await? {
self.empty_tree_oid(dir).await?
} else {
"HEAD".to_string()
}
}
DiffSpec::Rev(rev) => {
reject_flag_like("revision", &rev)?;
rev
}
};
self.core
.run_untrimmed_within(
self.core.command_in(
dir,
[
"diff",
target.as_str(),
"--no-color",
"--no-ext-diff",
"-M",
"--src-prefix=a/",
"--dst-prefix=b/",
"--",
],
),
budget,
)
.await
}
pub async fn show_file_within(
&self,
dir: &Path,
rev: &RevSpec,
path: &str,
budget: OutputBudget,
) -> Result<String> {
let rev = rev.as_str();
#[cfg(windows)]
let path = path.replace('\\', "/");
let spec = format!("{rev}:{path}");
self.core
.run_untrimmed_within(self.core.command_in(dir, ["show", spec.as_str()]), budget)
.await
}
}
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(
BINARY,
format!("unrecognisable `git --version` output: {raw:?}"),
)
})?;
Ok(GitCapabilities { version })
}
async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
self.core
.parse_bytes(
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_bytes(
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<PathBuf>> {
self.core
.parse_bytes(
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: &RevSpec, max: usize) -> Result<Vec<Commit>> {
let n = format!("-n{max}");
self.core
.parse(
self.core.command_in(
dir,
[
"log",
revspec.as_str(),
n.as_str(),
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
],
),
parse::parse_log,
)
.await
}
async fn log_paths(
&self,
dir: &Path,
revspec: &RevSpec,
max: usize,
paths: &[String],
) -> Result<Vec<Commit>> {
if paths.is_empty() {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"log_paths requires at least one path — an empty list would log \
unrestricted history, not history scoped to the named paths",
),
));
}
if let Some(oversized) = paths.iter().find(|p| p.len() + 1 > ARGV_PATHSPEC_BUDGET) {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"log_paths: a single path is {} bytes, exceeding the \
{ARGV_PATHSPEC_BUDGET}-byte argv pathspec budget on its own — \
`git log` has no NUL-safe pathspec-from-file transport (unlike \
`add`/`commit_paths`), so this path cannot be transmitted at all: \
{oversized:?}",
oversized.len() + 1,
),
),
));
}
let n = format!("-n{max}");
let chunks = chunk_pathspecs(paths);
if chunks.len() <= 1 {
let command = self.log_paths_command(
dir,
[revspec.as_str()],
&n,
paths.iter().map(String::as_str),
);
return self.core.parse(command, parse::parse_log).await;
}
let resolved_revspec: Vec<String> = self
.core
.run(self.core.command_in(dir, ["rev-parse", revspec.as_str()]))
.await?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect();
let mut merged: Vec<Commit> = Vec::new();
let mut seen = std::collections::HashSet::new();
for chunk in &chunks {
let command = self.log_paths_command(
dir,
resolved_revspec.iter().map(String::as_str),
&n,
chunk.iter().copied(),
);
let commits = self.core.parse(command, parse::parse_log).await?;
for commit in commits {
if seen.insert(commit.hash.clone()) {
merged.push(commit);
}
}
}
let order_command = self.log_paths_order_command(dir, &resolved_revspec);
let order = self.core.parse(order_command, parse_commit_order).await?;
let rank: std::collections::HashMap<&str, usize> = order
.iter()
.enumerate()
.map(|(index, hash)| (hash.as_str(), index))
.collect();
merged.sort_by_key(|commit| {
rank.get(commit.hash.as_str())
.copied()
.unwrap_or(usize::MAX)
});
merged.truncate(max);
Ok(merged)
}
async fn rev_parse(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
self.core
.run(
self.core
.command_in(dir, ["rev-parse", "--verify", rev.as_str()]),
)
.await
}
async fn rev_parse_short(&self, dir: &Path, rev: &RevSpec) -> Result<String> {
self.core
.run(
self.core
.command_in(dir, ["rev-parse", "--verify", "--short", rev.as_str()]),
)
.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<()> {
if pathspec_argv_len(paths.iter().map(|p| p.as_os_str())) > ARGV_PATHSPEC_BUDGET {
let stdin = processkit::Stdin::from_bytes(pathspec_nul_bytes(
paths.iter().map(|p| p.as_os_str()),
)?);
let command = self
.core
.command_in(
dir,
[
"--literal-pathspecs",
"add",
"--pathspec-from-file=-",
"--pathspec-file-nul",
],
)
.stdin(stdin);
return self.core.run_unit(command).await;
}
let mut command = self
.core
.command_in(dir, ["--literal-pathspecs", "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: &RefName) -> Result<()> {
self.core
.run_unit(self.core.command_in(dir, ["branch", name.as_str()]))
.await
}
async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()> {
self.core
.run_unit(
self.core
.command_in(dir, ["checkout", target.as_arg(), "--"]),
)
.await
}
async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> Result<()> {
self.core
.run_unit(
self.core
.command_in(dir, ["checkout", "--detach", commit.as_str()]),
)
.await
}
async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
if pathspec_argv_len(spec.paths.iter().map(|p| p.as_os_str())) > ARGV_PATHSPEC_BUDGET {
let stdin = processkit::Stdin::from_bytes(pathspec_nul_bytes(
spec.paths.iter().map(|p| p.as_os_str()),
)?);
let mut command =
c_locale(self.core.command_in(dir, ["--literal-pathspecs", "commit"]));
if spec.amend {
command = command.arg("--amend");
}
command = command
.arg("-m")
.arg(spec.message)
.arg("--only")
.arg("--pathspec-from-file=-")
.arg("--pathspec-file-nul")
.stdin(stdin);
return self.core.run_unit(command).await;
}
let mut command = c_locale(self.core.command_in(dir, ["--literal-pathspecs", "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: &RevSpec) -> Result<String> {
let spec = format!("{}^{{commit}}", rev.as_str());
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: &RefName) -> Result<bool> {
let refname = format!("refs/heads/{}", name.as_str());
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: &RefName) -> Result<bool> {
let name = name.as_str();
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 head = self
.core
.output_string(
self.core
.command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
)
.await?;
let _ = head.ensure_success()?;
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(128) => Ok(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> {
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: &RefName, upstream: &RefName) -> Result<()> {
let flag = format!("--set-upstream-to={}", upstream.as_str());
self.core
.run_unit(
self.core
.command_in(dir, ["branch", flag.as_str(), branch.as_str()]),
)
.await
}
async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()> {
let flag = if spec.force { "-D" } else { "-d" };
self.core
.run_unit(
self.core
.command_in(dir, ["branch", flag, spec.name.as_str()]),
)
.await
}
async fn rename_branch(&self, dir: &Path, old: &RefName, new: &RefName) -> Result<()> {
self.core
.run_unit(
self.core
.command_in(dir, ["branch", "-m", old.as_str(), new.as_str()]),
)
.await
}
async fn rev_list_count(&self, dir: &Path, range: &RevSpec) -> Result<usize> {
self.core
.try_parse(
self.core
.command_in(dir, ["rev-list", "--count", range.as_str()]),
|s| {
s.trim()
.parse::<usize>()
.map_err(|e| Error::parse(BINARY, e.to_string()))
},
)
.await
}
async fn diff_range_is_empty(&self, dir: &Path, range: &RevSpec) -> Result<bool> {
self.core
.probe(
self.core
.command_in(dir, ["diff", "--quiet", range.as_str(), "--"]),
)
.await
}
async fn diff_stat(&self, dir: &Path, range: &RevSpec) -> Result<DiffStat> {
self.core
.parse(
c_locale(
self.core
.command_in(dir, ["diff", "--shortstat", range.as_str(), "--"]),
),
parse::parse_shortstat,
)
.await
}
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
self.diff_text_budgeted(dir, spec, self.core.output_budget())
.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 is_cherry_pick_in_progress(&self, dir: &Path) -> Result<bool> {
Ok(self
.resolved_git_dir(dir)
.await?
.join("CHERRY_PICK_HEAD")
.exists())
}
async fn is_revert_in_progress(&self, dir: &Path) -> Result<bool> {
Ok(self
.resolved_git_dir(dir)
.await?
.join("REVERT_HEAD")
.exists())
}
async fn is_bisect_in_progress(&self, dir: &Path) -> Result<bool> {
Ok(self
.resolved_git_dir(dir)
.await?
.join("BISECT_LOG")
.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 = self.core.budget_diagnostics(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 = self.core.budget_diagnostics(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: &RefName) -> Result<()> {
let branch = branch.as_str();
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 = self.core.budget_diagnostics(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(
BINARY,
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: &RevSpec) -> Result<()> {
self.core
.run_unit(c_locale(
self.core
.command_in(dir, ["merge", "--squash", branch.as_str()]),
))
.await
}
async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
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.as_str());
self.core
.run_unit(c_locale(self.core.command_in(dir, args)))
.await
}
async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
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.as_str());
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: &RevSpec) -> Result<()> {
self.core
.run_unit(self.core.command_in(dir, ["reset", "--hard", rev.as_str()]))
.await
}
async fn rebase(&self, dir: &Path, onto: &RevSpec) -> Result<()> {
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["rebase", onto.as_str()]),
)))
.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, spec: StashPush) -> Result<()> {
let mut command = self.core.command_in(dir, ["stash", "push"]);
if spec.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_bytes(
self.core
.command_in(dir, ["worktree", "list", "--porcelain"]),
parse::parse_worktree_porcelain,
)
.await
}
async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
let mut command = self.core.command_in(dir, ["worktree", "add"]);
if let Some(name) = spec.new_branch.as_ref() {
command = command.arg("-b").arg(name.as_str());
}
if spec.no_checkout {
command = command.arg("--no-checkout");
}
command = command.arg(&spec.path);
if let Some(commitish) = spec.commitish.as_ref() {
command = command.arg(commitish.as_str());
}
self.core.run_unit(command).await
}
async fn worktree_remove(&self, dir: &Path, spec: WorktreeRemove) -> Result<()> {
let mut command = self.core.command_in(dir, ["worktree", "remove"]);
if spec.force {
command = command.arg("--force");
}
command = command.arg(&spec.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 = self.core.budget_diagnostics(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: &RefName, rev: Option<RevSpec>) -> Result<()> {
let mut args = vec!["tag", name.as_str()];
if let Some(rev) = rev.as_ref() {
args.push(rev.as_str());
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
let mut args = vec!["tag", "-a", spec.name.as_str(), "-m", &spec.message];
if let Some(rev) = spec.rev.as_ref() {
args.push(rev.as_str());
}
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: &RefName) -> Result<()> {
self.core
.run_unit(self.core.command_in(dir, ["tag", "-d", name.as_str()]))
.await
}
async fn show_file(&self, dir: &Path, rev: &RevSpec, path: &str) -> Result<String> {
self.show_file_within(dir, rev, path, self.core.output_budget())
.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<RevSpec>) -> Result<Vec<BlameLine>> {
let mut args = vec!["blame", "--line-porcelain"];
if let Some(rev) = rev.as_ref() {
args.push(rev.as_str());
}
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: &RevSpec) -> Result<()> {
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["cherry-pick", rev.as_str()]),
)))
.await
}
async fn revert(&self, dir: &Path, rev: &RevSpec) -> Result<()> {
self.core
.run_unit(no_editor(c_locale(
self.core
.command_in(dir, ["revert", "--no-edit", rev.as_str()]),
)))
.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
}
async fn cherry_pick_abort(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(c_locale(
self.core.command_in(dir, ["cherry-pick", "--abort"]),
))
.await
}
async fn cherry_pick_continue(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["cherry-pick", "--continue"]),
)))
.await
}
async fn revert_abort(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(c_locale(self.core.command_in(dir, ["revert", "--abort"])))
.await
}
async fn revert_continue(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(no_editor(c_locale(
self.core.command_in(dir, ["revert", "--continue"]),
)))
.await
}
async fn bisect_reset(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(c_locale(self.core.command_in(dir, ["bisect", "reset"])))
.await
}
}
impl<R: ProcessRunner> Git<R> {
fn log_paths_command<'a>(
&self,
dir: &Path,
revs: impl IntoIterator<Item = &'a str>,
n: &str,
paths: impl IntoIterator<Item = &'a str>,
) -> Command {
let mut command = self.core.command_in(dir, ["--literal-pathspecs", "log"]);
for rev in revs {
command = command.arg(rev);
}
command = command
.arg(n)
.arg("-z")
.arg("--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s")
.arg("--");
for path in paths {
command = command.arg(path);
}
command
}
fn log_paths_order_command(&self, dir: &Path, revs: &[String]) -> Command {
let mut command = self.core.command_in(dir, ["log"]);
for rev in revs {
command = command.arg(rev);
}
command.arg("-z").arg("--format=%H")
}
}
pub const EMPTY_TREE_SHA1: &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)
}
const ARGV_PATHSPEC_BUDGET: usize = 6_000;
fn pathspec_argv_len<'a>(paths: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> usize {
paths
.into_iter()
.map(|p| p.as_encoded_bytes().len() + 1)
.sum()
}
fn pathspec_nul_bytes<'a>(paths: impl IntoIterator<Item = &'a std::ffi::OsStr>) -> Result<Vec<u8>> {
let mut buf = Vec::new();
for path in paths {
let bytes = path.as_encoded_bytes();
if bytes.contains(&0) {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path contains an embedded NUL byte, which the \
--pathspec-file-nul transport uses as its separator — \
refusing before spawning rather than silently splitting \
it into two pathspecs",
),
));
}
buf.extend_from_slice(bytes);
buf.push(0);
}
Ok(buf)
}
fn chunk_pathspecs(paths: &[String]) -> Vec<Vec<&str>> {
let mut chunks: Vec<Vec<&str>> = Vec::new();
let mut current: Vec<&str> = Vec::new();
let mut current_len = 0usize;
for path in paths {
let len = path.len() + 1;
if !current.is_empty() && current_len + len > ARGV_PATHSPEC_BUDGET {
chunks.push(std::mem::take(&mut current));
current_len = 0;
}
current.push(path.as_str());
current_len += len;
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
fn parse_commit_order(output: &str) -> Vec<String> {
output
.split('\0')
.filter(|rec| !rec.is_empty())
.map(str::to_string)
.collect()
}
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 async fn run_in(&self, dir: &Path, args: &[String]) -> Result<String> {
self.core.run(self.core.command_in(dir, args)).await
}
pub async fn run_raw_in(&self, dir: &Path, args: &[String]) -> Result<ProcessResult<String>> {
self.core
.output_string(self.core.command_in(dir, args))
.await
}
pub async fn run_args_in(&self, dir: &Path, args: &[&str]) -> Result<String> {
self.core.run(self.core.command_in(dir, args)).await
}
pub async fn run_raw_args_in(
&self,
dir: &Path,
args: &[&str],
) -> Result<ProcessResult<String>> {
self.core
.output_string(self.core.command_in(dir, args))
.await
}
pub async fn empty_tree_oid(&self, dir: &Path) -> Result<String> {
self.core
.run(
self.core
.command_in(dir, ["hash-object", "-t", "tree", "--stdin"])
.stdin(processkit::Stdin::empty()),
)
.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, target: &CheckoutTarget) -> Result<()> {
if self.status(dir).await?.is_empty() {
return self.checkout(dir, target).await;
}
let depth_before = self.stash_depth(dir).await?;
self.stash_push(dir, StashPush::new().include_untracked())
.await?;
if self.stash_depth(dir).await? <= depth_before {
return self.checkout(dir, target).await;
}
match self.checkout(dir, target).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 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<PathBuf>>;
fn current_branch() -> Result<Option<String>>;
fn branches() -> Result<Vec<Branch>>;
fn log(revspec: &RevSpec, max: usize) -> Result<Vec<Commit>>;
fn log_paths(revspec: &RevSpec, max: usize, paths: &[String]) -> Result<Vec<Commit>>;
fn rev_parse(rev: &RevSpec) -> Result<String>;
fn rev_parse_short(rev: &RevSpec) -> Result<String>;
fn init() -> Result<()>;
fn add(paths: &[PathBuf]) -> Result<()>;
fn commit(message: &str) -> Result<()>;
fn create_branch(name: &RefName) -> Result<()>;
fn checkout(target: &CheckoutTarget) -> Result<()>;
fn checkout_detach(commit: &RevSpec) -> 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: &RevSpec) -> Result<String>;
fn remote_head_branch() -> Result<Option<String>>;
fn branch_exists(name: &RefName) -> Result<bool>;
fn remote_branch_exists(name: &RefName) -> 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: &RefName, upstream: &RefName) -> Result<()>;
fn delete_branch(spec: BranchDelete) -> Result<()>;
fn rename_branch(old: &RefName, new: &RefName) -> Result<()>;
fn rev_list_count(range: &RevSpec) -> Result<usize>;
fn diff_range_is_empty(range: &RevSpec) -> Result<bool>;
fn diff_stat(range: &RevSpec) -> 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 is_cherry_pick_in_progress() -> Result<bool>;
fn is_revert_in_progress() -> Result<bool>;
fn is_bisect_in_progress() -> Result<bool>;
fn fetch() -> Result<()>;
fn fetch_from(remote: &str) -> Result<()>;
fn fetch_branch(branch: &RefName) -> Result<()>;
fn push(spec: GitPush) -> Result<()>;
fn merge_squash(branch: &RevSpec) -> 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: &RevSpec) -> Result<()>;
fn rebase(onto: &RevSpec) -> Result<()>;
fn rebase_abort() -> Result<()>;
fn am_abort() -> Result<()>;
fn rebase_continue() -> Result<()>;
fn stash_push(spec: StashPush) -> Result<()>;
fn stash_pop() -> Result<()>;
fn switch_with_stash(target: &CheckoutTarget) -> Result<()>;
fn worktree_list() -> Result<Vec<Worktree>>;
fn worktree_add(spec: WorktreeAdd) -> Result<()>;
fn worktree_remove(spec: WorktreeRemove) -> Result<()>;
fn worktree_move(from: &Path, to: &Path) -> Result<()>;
fn worktree_prune() -> Result<()>;
fn tag_create(name: &RefName, rev: Option<RevSpec>) -> Result<()>;
fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
fn tag_list() -> Result<Vec<String>>;
fn tag_delete(name: &RefName) -> Result<()>;
fn show_file(rev: &RevSpec, 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<RevSpec>) -> Result<Vec<BlameLine>>;
fn cherry_pick(rev: &RevSpec) -> Result<()>;
fn revert(rev: &RevSpec) -> Result<()>;
fn rebase_skip() -> Result<()>;
fn cherry_pick_abort() -> Result<()>;
fn cherry_pick_continue() -> Result<()>;
fn revert_abort() -> Result<()>;
fn revert_continue() -> Result<()>;
fn bisect_reset() -> Result<()>;
}
raw {
fn run(args: &[String]) -> Result<String> => run_in;
fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
fn run_args(args: &[&str]) -> Result<String> => run_args_in;
fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
}
}
pub mod blocking {
use std::path::Path;
use std::process::Command;
pub fn worktree_remove(dir: &Path, spec: super::WorktreeRemove) -> std::io::Result<()> {
let mut cmd = Command::new(super::BINARY);
cmd.current_dir(dir).args(["worktree", "remove"]);
if spec.force {
cmd.arg("--force");
}
cmd.arg(&spec.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::*;
fn rn(s: &str) -> RefName {
RefName::new(s).unwrap()
}
fn rv(s: &str) -> RevSpec {
RevSpec::new(s).unwrap()
}
fn ct(s: &str) -> CheckoutTarget {
if s == "-" {
CheckoutTarget::Previous
} else {
CheckoutTarget::Ref(rv(s))
}
}
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(rv("feat")).no_ff())
.await
.unwrap();
git.at(dir)
.merge_commit(MergeCommit::branch(rv("feat")).no_ff())
.await
.unwrap();
git.worktree_remove(dir, WorktreeRemove::new("/wt").force())
.await
.unwrap();
git.at(dir)
.worktree_remove(WorktreeRemove::new("/wt").force())
.await
.unwrap();
git.conflicted_files(dir).await.unwrap();
git.at(dir).conflicted_files().await.unwrap();
git.tag_delete(dir, &rn("v1")).await.unwrap();
git.at(dir).tag_delete(&rn("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 bound_view_raw_hatch_runs_in_bound_dir() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.at(dir).run(&["status".to_string()]).await.unwrap();
let _ = git.at(dir).run_raw(&["status".to_string()]).await.unwrap();
git.at(dir).run_args(&["status"]).await.unwrap();
let _ = git.at(dir).run_raw_args(&["status"]).await.unwrap();
git.run(&["status".to_string()]).await.unwrap();
let _ = git.run_raw(&["status".to_string()]).await.unwrap();
git.run_args(&["status"]).await.unwrap();
let _ = git.run_raw_args(&["status"]).await.unwrap();
let calls = rec.calls();
for c in &calls[0..4] {
assert_eq!(
c.cwd.as_deref(),
Some(dir),
"raw call through the bound view runs in the bound dir"
);
assert_eq!(c.args_str(), ["status"]);
}
for c in &calls[4..8] {
assert_eq!(
c.cwd.as_deref(),
None,
"raw call on the client stays in the process cwd"
);
assert_eq!(c.args_str(), ["status"]);
}
}
#[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, Path::new("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,
[PathBuf::from("a.rs"), PathBuf::from("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"), &rv("HEAD"))
.await
.unwrap();
assert_eq!(out, "a1b2c3d");
assert_eq!(
rec.only_call().args_str(),
["rev-parse", "--verify", "--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"), &rv("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 detects_cherry_pick_revert_and_bisect_markers() {
use vcs_testkit::TempDir;
let gd = TempDir::new("t044-seq");
let git = Git::with_runner(ScriptedRunner::new().on(
["git", "rev-parse", "--git-dir"],
Reply::ok(gd.path().to_str().unwrap()),
));
let d = Path::new("/r");
let touch = |name: &str| std::fs::write(gd.path().join(name), b"x\n").unwrap();
let rm = |name: &str| std::fs::remove_file(gd.path().join(name)).unwrap();
touch("CHERRY_PICK_HEAD");
assert!(git.is_cherry_pick_in_progress(d).await.unwrap());
assert!(!git.is_merge_in_progress(d).await.unwrap());
assert!(!git.is_revert_in_progress(d).await.unwrap());
assert!(!git.is_bisect_in_progress(d).await.unwrap());
rm("CHERRY_PICK_HEAD");
touch("REVERT_HEAD");
assert!(git.is_revert_in_progress(d).await.unwrap());
assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
assert!(!git.is_merge_in_progress(d).await.unwrap());
rm("REVERT_HEAD");
touch("BISECT_LOG");
assert!(git.is_bisect_in_progress(d).await.unwrap());
assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
assert!(!git.is_revert_in_progress(d).await.unwrap());
rm("BISECT_LOG");
assert!(!git.is_cherry_pick_in_progress(d).await.unwrap());
assert!(!git.is_revert_in_progress(d).await.unwrap());
assert!(!git.is_bisect_in_progress(d).await.unwrap());
}
#[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", "--literal-pathspecs", "add", "--"], Reply::ok("")),
);
git.add(Path::new("."), &[PathBuf::from("f.rs")])
.await
.expect("add should build `--literal-pathspecs add -- <paths>`");
}
#[tokio::test]
async fn add_large_path_set_uses_pathspec_from_file_stdin() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
let paths: Vec<PathBuf> = (0..2_000)
.map(|i| PathBuf::from(format!("dir/file_{i:05}.txt")))
.collect();
git.add(Path::new("."), &paths).await.expect("add");
let call = rec.only_call();
assert_eq!(
call.args_str(),
[
"--literal-pathspecs",
"add",
"--pathspec-from-file=-",
"--pathspec-file-nul",
]
);
assert!(call.has_stdin, "paths must travel over stdin, not argv");
}
#[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", rn("feature"), rv("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"), WorktreeRemove::new("/wt").force())
.await
.expect("remove");
assert_eq!(
rec.only_call().args_str(),
["worktree", "remove", "--force", "/wt"]
);
}
#[tokio::test]
async fn worktree_remove_default_omits_force() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.worktree_remove(Path::new("/repo"), WorktreeRemove::new("/wt"))
.await
.expect("remove");
assert_eq!(rec.only_call().args_str(), ["worktree", "remove", "/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", rv("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("."), &rv("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(),
[
"--literal-pathspecs",
"commit",
"--amend",
"-m",
"msg",
"--only",
"--",
"a.rs",
"b.rs"
]
);
}
#[tokio::test]
async fn commit_paths_large_path_set_uses_pathspec_from_file_stdin() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
let paths: Vec<PathBuf> = (0..2_000)
.map(|i| PathBuf::from(format!("dir/file_{i:05}.txt")))
.collect();
git.commit_paths(Path::new("."), CommitPaths::new(paths, "msg").amend())
.await
.expect("commit_paths");
let calls = rec.calls();
assert_eq!(calls.len(), 1, "must be a single atomic commit invocation");
let call = &calls[0];
assert_eq!(
call.args_str(),
[
"--literal-pathspecs",
"commit",
"--amend",
"-m",
"msg",
"--only",
"--pathspec-from-file=-",
"--pathspec-file-nul",
]
);
assert!(call.has_stdin, "paths must travel over stdin, not argv");
}
#[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("."), &rv("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 log_paths_builds_revspec_format_and_pathspec_separator() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.log_paths(
Path::new("."),
&rv("main..HEAD"),
5,
&["src/a.rs".to_string(), "src/b.rs".to_string()],
)
.await
.expect("log_paths");
let args = rec.only_call().args_str();
assert_eq!(
args,
[
"--literal-pathspecs",
"log",
"main..HEAD",
"-n5",
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
"--",
"src/a.rs",
"src/b.rs",
]
);
assert_eq!(args.iter().filter(|a| *a == "--").count(), 1);
}
#[tokio::test]
async fn log_paths_refuses_empty_paths_without_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
let err = git
.log_paths(Path::new("."), &rv("HEAD"), 5, &[])
.await
.expect_err("empty paths must be refused");
assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
assert!(rec.calls().is_empty(), "nothing may spawn");
}
#[tokio::test]
async fn log_paths_large_path_set_chunks_dedupes_and_reorders_by_oracle_order() {
let path_a = "a".repeat(4_000);
let path_b = "b".repeat(4_000);
let common = [
"git",
"--literal-pathspecs",
"log",
"resolved-head-sha",
"-n5",
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
"--",
];
let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
chunk_a_args.push(path_a.clone());
let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
chunk_b_args.push(path_b.clone());
let reply_a = Reply::ok(
"aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0\
shar\u{1f}sha\u{1f}S\u{1f}2026-01-02T00:00:00Z\u{1f}shared\0"
.to_string(),
);
let reply_b = Reply::ok(
"bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-02T00:00:00Z\u{1f}newest-b\0\
shar\u{1f}sha\u{1f}S\u{1f}2026-01-02T00:00:00Z\u{1f}shared\0"
.to_string(),
);
let order_reply = Reply::ok("bbb1\0shar\0aaa1\0".to_string());
let git = Git::with_runner(
ScriptedRunner::new()
.on(
["git", "rev-parse", "HEAD"],
Reply::ok("resolved-head-sha\n".to_string()),
)
.on(chunk_a_args, reply_a)
.on(chunk_b_args, reply_b)
.on(
["git", "log", "resolved-head-sha", "-z", "--format=%H"],
order_reply,
),
);
let commits = git
.log_paths(Path::new("."), &rv("HEAD"), 5, &[path_a, path_b])
.await
.expect("log_paths");
assert_eq!(
commits.iter().map(|c| c.hash.as_str()).collect::<Vec<_>>(),
["bbb1", "shar", "aaa1"],
"expected the oracle's commit order across chunks, with the shared \
commit deduplicated"
);
}
#[tokio::test]
async fn log_paths_single_call_and_chunked_call_agree_on_order() {
let single_reply = Reply::ok(
"bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0\
aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0",
);
let single_call_git = Git::with_runner(ScriptedRunner::new().on(
[
"git",
"--literal-pathspecs",
"log",
"HEAD",
"-n5",
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
"--",
"src/a.rs",
"src/b.rs",
],
single_reply,
));
let single_commits = single_call_git
.log_paths(
Path::new("."),
&rv("HEAD"),
5,
&["src/a.rs".to_string(), "src/b.rs".to_string()],
)
.await
.expect("log_paths");
let path_a = "a".repeat(4_000);
let path_b = "b".repeat(4_000);
let common = [
"git",
"--literal-pathspecs",
"log",
"HEAD",
"-n5",
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
"--",
];
let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
chunk_a_args.push(path_a.clone());
let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
chunk_b_args.push(path_b.clone());
let reply_a =
Reply::ok("aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0".to_string());
let reply_b =
Reply::ok("bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0".to_string());
let order_reply = Reply::ok("bbb1\0aaa1\0".to_string());
let chunked_git = Git::with_runner(
ScriptedRunner::new()
.on(
["git", "rev-parse", "HEAD"],
Reply::ok("HEAD\n".to_string()),
)
.on(chunk_a_args, reply_a)
.on(chunk_b_args, reply_b)
.on(["git", "log", "HEAD", "-z", "--format=%H"], order_reply),
);
let chunked_commits = chunked_git
.log_paths(Path::new("."), &rv("HEAD"), 5, &[path_a, path_b])
.await
.expect("log_paths");
assert_eq!(
single_commits
.iter()
.map(|c| c.hash.as_str())
.collect::<Vec<_>>(),
chunked_commits
.iter()
.map(|c| c.hash.as_str())
.collect::<Vec<_>>(),
"single-call and chunked-call order must agree when the oracle \
agrees with the single-call order"
);
}
#[tokio::test]
async fn log_paths_range_revspec_is_resolved_once_and_reused_across_chunks() {
let path_a = "a".repeat(4_000);
let path_b = "b".repeat(4_000);
let common = [
"git",
"--literal-pathspecs",
"log",
"feature-sha",
"^main-sha",
"-n5",
"-z",
"--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
"--",
];
let mut chunk_a_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
chunk_a_args.push(path_a.clone());
let mut chunk_b_args: Vec<String> = common.iter().map(|s| (*s).to_string()).collect();
chunk_b_args.push(path_b.clone());
let reply_a =
Reply::ok("aaa1\u{1f}aaa\u{1f}A\u{1f}2026-01-02T00:00:00Z\u{1f}newer-a\0".to_string());
let reply_b =
Reply::ok("bbb1\u{1f}bbb\u{1f}B\u{1f}2026-01-03T00:00:00Z\u{1f}newest-b\0".to_string());
let order_reply = Reply::ok("bbb1\0aaa1\0".to_string());
let git = Git::with_runner(
ScriptedRunner::new()
.on(
["git", "rev-parse", "main..feature"],
Reply::ok("feature-sha\n^main-sha\n".to_string()),
)
.on(chunk_a_args, reply_a)
.on(chunk_b_args, reply_b)
.on(
[
"git",
"log",
"feature-sha",
"^main-sha",
"-z",
"--format=%H",
],
order_reply,
),
);
let commits = git
.log_paths(Path::new("."), &rv("main..feature"), 5, &[path_a, path_b])
.await
.expect("log_paths");
assert_eq!(
commits.iter().map(|c| c.hash.as_str()).collect::<Vec<_>>(),
["bbb1", "aaa1"]
);
}
#[tokio::test]
async fn log_paths_rejects_individually_oversized_path_without_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
let huge = "z".repeat(ARGV_PATHSPEC_BUDGET + 1);
let err = git
.log_paths(Path::new("."), &rv("HEAD"), 5, &[huge])
.await
.expect_err("an individually oversized path must be refused");
assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
assert!(rec.calls().is_empty(), "nothing may spawn");
}
#[test]
fn pathspec_argv_len_sums_bytes_plus_one_per_path() {
use std::ffi::OsStr;
let paths = [OsStr::new("a.rs"), OsStr::new("dir/b.rs")];
assert_eq!(pathspec_argv_len(paths), 5 + 9);
assert_eq!(pathspec_argv_len(std::iter::empty()), 0);
}
#[test]
fn pathspec_nul_bytes_joins_paths_literally_in_order() {
use std::ffi::OsStr;
let paths = [
OsStr::new("-weird.txt"),
OsStr::new("has space.txt"),
OsStr::new("glob[1].txt"),
];
let bytes = pathspec_nul_bytes(paths).expect("no embedded NUL");
assert_eq!(bytes, b"-weird.txt\0has space.txt\0glob[1].txt\0".to_vec());
}
#[test]
fn pathspec_nul_bytes_rejects_embedded_nul() {
use std::ffi::OsStr;
let bad = unsafe { OsStr::from_encoded_bytes_unchecked(b"a\0b") };
let err = pathspec_nul_bytes([bad]).expect_err("embedded NUL must be refused");
assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
}
#[test]
fn chunk_pathspecs_packs_under_budget_and_splits_over_it() {
let short = vec!["a".to_string(), "b".to_string(), "c".to_string()];
assert_eq!(chunk_pathspecs(&short), vec![vec!["a", "b", "c"]]);
let big_a = "a".repeat(ARGV_PATHSPEC_BUDGET - 100);
let big_b = "b".repeat(ARGV_PATHSPEC_BUDGET - 100);
let big = vec![big_a.clone(), big_b.clone()];
assert_eq!(
chunk_pathspecs(&big),
vec![vec![big_a.as_str()], vec![big_b.as_str()]]
);
let huge = vec!["z".repeat(ARGV_PATHSPEC_BUDGET * 2)];
assert_eq!(chunk_pathspecs(&huge), vec![vec![huge[0].as_str()]]);
assert!(chunk_pathspecs(&[]).is_empty());
}
#[test]
fn parse_commit_order_splits_on_nul_and_drops_trailing_empty() {
assert_eq!(
parse_commit_order("aaa1\0bbb2\0ccc3\0"),
vec!["aaa1", "bbb2", "ccc3"]
);
assert!(parse_commit_order("").is_empty());
}
#[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("."), StashPush::new().include_untracked())
.await
.expect("stash");
assert_eq!(
rec.only_call().args_str(),
["stash", "push", "--include-untracked"]
);
}
#[tokio::test]
async fn stash_push_default_omits_include_untracked() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.stash_push(Path::new("."), StashPush::new())
.await
.expect("stash");
assert_eq!(rec.only_call().args_str(), ["stash", "push"]);
}
#[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 oid = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
let git = Git::with_runner(
ScriptedRunner::new()
.on(["git", "rev-parse"], Reply::fail(1, "")) .on(["git", "hash-object"], Reply::ok(format!("{oid}\n")))
.on(["git", "diff", oid], Reply::ok("EMPTY")),
);
let out = git
.diff_text(Path::new("."), DiffSpec::WorkingTree)
.await
.expect("diff_text");
assert_eq!(out, "EMPTY");
}
#[tokio::test]
async fn empty_tree_oid_hashes_an_empty_tree() {
let rec = RecordingRunner::replying(Reply::ok(format!("{EMPTY_TREE_SHA1}\n")));
let git = Git::with_runner(&rec);
let oid = git
.empty_tree_oid(Path::new("."))
.await
.expect("empty_tree_oid");
assert_eq!(oid, EMPTY_TREE_SHA1);
assert_eq!(
rec.only_call().args_str(),
["hash-object", "-t", "tree", "--stdin"]
);
}
#[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, Path::new("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("."), &rn("main"))
.await
.unwrap()
);
let no =
Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
assert!(!no.branch_exists(Path::new("."), &rn("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"), &rn("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("."), &rn("x"))
.await
.unwrap()
);
}
#[test]
fn remote_branch_invalid_names_rejected_at_refname() {
for name in [
"",
"feature/*",
"feature/?",
"feature/[a]",
"a:b",
"two words",
"bad\nname",
] {
let err = RefName::new(name).expect_err("invalid remote branch name must be rejected");
assert!(vcs_cli_support::is_invalid_input(&err), "{name:?}");
}
}
#[tokio::test]
async fn remote_branch_exists_accepts_valid_names() {
let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/feature/T-010_fix\n"));
let git = Git::with_runner(&rec);
assert!(
git.remote_branch_exists(Path::new("/repo"), &rn("feature/T-010_fix"))
.await
.expect("valid remote branch name")
);
assert_eq!(
rec.only_call().args_str(),
["ls-remote", "origin", "refs/heads/feature/T-010_fix"]
);
}
#[tokio::test(start_paused = true)]
async fn remote_branch_exists_bounded_wait_resolves_a_hung_remote() {
let git =
Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::pending()));
let name = rn("main");
let probe = git.remote_branch_exists(Path::new("/r"), &name);
let exists = tokio::time::timeout(std::time::Duration::from_secs(3600), probe)
.await
.expect("the per-command 10 s timeout must resolve a hung ls-remote")
.expect("a timed-out best-effort probe is `Ok(false)`, not an error");
assert!(!exists, "an unreachable remote reads as absent");
}
#[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("."), &rv("main..HEAD"))
.await
.unwrap();
assert_eq!(
(stat.files_changed, stat.insertions, stat.deletions),
(2, 5, 1)
);
}
#[tokio::test]
async fn diff_range_verbs_terminate_revisions_with_dashes() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.diff_range_is_empty(Path::new("/r"), &rv("main..HEAD"))
.await
.expect("diff_range_is_empty");
assert_eq!(
rec.only_call().args_str(),
["diff", "--quiet", "main..HEAD", "--"]
);
let rec = RecordingRunner::replying(Reply::ok(" 0 files changed\n"));
let git = Git::with_runner(&rec);
git.diff_stat(Path::new("/r"), &rv("main..HEAD"))
.await
.expect("diff_stat");
assert_eq!(
rec.only_call().args_str(),
["diff", "--shortstat", "main..HEAD", "--"]
);
}
#[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(rv("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(rv("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"), &rv("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(&rn("feat"), &rn("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(rn("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", "a:b:c", "main:prod"] {
assert!(RefName::new(bad).is_err(), "{bad:?} carries a `:`");
}
assert!(
git.push(Path::new("/r"), GitPush::branch(rn("+main")))
.await
.is_err(),
"a force refspec must be refused before spawning"
);
assert!(
git.push(Path::new("/r"), GitPush::refspec(&rn("main"), &rn("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(rn("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(rn("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(rn("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 one_client_host_keyed_provider_isolates_tokens_across_hosts() {
let provider = Arc::new(provider_fn(|r: &CredentialRequest<'_>| {
Ok(match r.host {
Some("github.com") => Some(Credential::token("gh-secret")),
Some("gitlab.example") => Some(Credential::token("gl-secret")),
_ => None,
})
}));
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec).with_credentials(provider);
git.clone_repo(
"https://github.com/o/r.git",
Path::new("/dest-gh"),
CloneSpec::default(),
)
.await
.unwrap();
git.clone_repo(
"https://gitlab.example/o/r.git",
Path::new("/dest-gl"),
CloneSpec::default(),
)
.await
.unwrap();
let calls = rec.calls();
assert_eq!(calls.len(), 2, "two clones recorded");
assert!(calls[0].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gh-secret"));
assert!(calls[0].env_is("VCS_TOOLKIT_GIT_HOST", "github.com"));
assert!(calls[1].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gl-secret"));
assert!(calls[1].env_is("VCS_TOOLKIT_GIT_HOST", "gitlab.example"));
assert!(!calls[0].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gl-secret"));
assert!(!calls[1].env_is("VCS_TOOLKIT_GIT_PASSWORD", "gh-secret"));
for c in calls.iter() {
assert!(
!c.args_str()
.iter()
.any(|a| a.contains("gh-secret") || a.contains("gl-secret")),
"secrets stay out of argv"
);
}
}
#[tokio::test]
async fn git_credential_fallback_policy_for_read_and_write() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec)
.with_credentials(Arc::new(provider_fn(|_r: &CredentialRequest<'_>| Ok(None))));
git.fetch(Path::new("/r")).await.unwrap();
git.push(Path::new("/r"), GitPush::branch(rn("feature")))
.await
.unwrap();
for c in rec.calls().iter() {
assert!(
!c.has_env("VCS_TOOLKIT_GIT_PASSWORD"),
"ambient: no secret env on {:?}",
c.args_str()
);
assert_ne!(
c.args_str().first().map(String::as_str),
Some("-c"),
"ambient: no leading credential -c flags"
);
}
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec).with_credentials(Arc::new(provider_fn(
|_r: &CredentialRequest<'_>| {
Err(Error::spawn(
BINARY,
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault down"),
))
},
)));
assert!(
git.fetch(Path::new("/r")).await.is_err(),
"read aborts on provider error"
);
assert!(
git.push(Path::new("/r"), GitPush::branch(rn("feature")))
.await
.is_err(),
"write aborts on provider error"
);
assert!(
rec.calls().is_empty(),
"git never spawns when the provider errored"
);
}
#[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_distinguishes_no_upstream_from_errors() {
let set = Git::with_runner(
ScriptedRunner::new()
.on(["git", "symbolic-ref"], Reply::ok("main\n"))
.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", "symbolic-ref"], Reply::ok("main\n"))
.on(["git", "rev-parse"], Reply::fail(128, "")),
);
assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
let detached =
Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
assert!(detached.upstream(Path::new(".")).await.is_err());
let not_repo = Git::with_runner(ScriptedRunner::new().on(
["git", "symbolic-ref"],
Reply::fail(128, "fatal: not a git repository"),
));
assert!(not_repo.upstream(Path::new(".")).await.is_err());
let broken = Git::with_runner(
ScriptedRunner::new()
.on(["git", "symbolic-ref"], Reply::ok("main\n"))
.on(["git", "rev-parse"], Reply::fail(1, "corrupt config")),
);
assert!(broken.upstream(Path::new(".")).await.is_err());
let timed_out = Git::with_runner(
ScriptedRunner::new()
.on(["git", "symbolic-ref"], Reply::ok("main\n"))
.on(["git", "rev-parse"], Reply::timeout()),
);
assert!(timed_out.upstream(Path::new(".")).await.is_err());
let signalled = Git::with_runner(
ScriptedRunner::new()
.on(["git", "symbolic-ref"], Reply::ok("main\n"))
.on(["git", "rev-parse"], Reply::signalled(Some(9))),
);
assert!(signalled.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"), &rn("feat"), &rn("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"), BranchDelete::new(rn("old")).force())
.await
.unwrap();
assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
}
#[tokio::test]
async fn delete_branch_default_uses_lowercase_d() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.delete_branch(Path::new("/r"), BranchDelete::new(rn("old")))
.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(rn(name)).into_base(rv("main"))
)
.await
.unwrap(),
"{name} should be reported merged"
);
}
assert!(
!git.is_merged(
Path::new("."),
MergeCheck::branch(rn("absent")).into_base(rv("main"))
)
.await
.unwrap()
);
}
#[tokio::test]
async fn merge_check_names_branch_and_base_without_transposition() {
use processkit::testing::RecordingRunner;
let spec = MergeCheck::branch(rn("feature")).into_base(rv("main"));
assert_eq!(spec.branch.as_str(), "feature");
assert_eq!(spec.base.as_str(), "main");
let rec = RecordingRunner::replying(Reply::ok(" feature\n* main\n"));
let merged = Git::with_runner(&rec)
.is_merged(
Path::new("/repo"),
MergeCheck::branch(rn("feature")).into_base(rv("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"
);
}
#[test]
fn validated_ref_and_rev_newtypes_reject_bad_values() {
for ok in ["main", "feature/x", "origin/main", "v1.2.3", "a-b_c"] {
assert!(RefName::new(ok).is_ok(), "{ok}");
}
for bad in [
"", "-evil", "--force", "-D", "-bad", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b",
"a?b", "a*b", "a[b", "a\\b", "end/", "x.lock",
] {
let err = RefName::new(bad).expect_err(&format!("{bad:?} must be rejected"));
assert!(
vcs_cli_support::is_invalid_input(&err),
"{bad:?} must classify as invalid input"
);
}
for ok in ["HEAD", "HEAD~2", "main..feature", "@{-1}", "abc123"] {
assert!(RevSpec::new(ok).is_ok(), "{ok}");
}
for bad in ["", "-evil", "-i", "-n", "-s"] {
let err = RevSpec::new(bad).expect_err(&format!("{bad:?} must be rejected"));
assert!(vcs_cli_support::is_invalid_input(&err), "{bad:?}");
}
}
#[tokio::test]
async fn str_positionals_are_rejected_before_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
let dir = Path::new("/r");
assert!(git.config_set(dir, "-evil", "v").await.is_err());
assert!(git.config_get(dir, "-evil").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.remote_add(dir, "-evil", "url").await.is_err());
assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
assert!(
git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
.await
.is_err()
);
assert!(
git.diff_text(dir, DiffSpec::Rev("-evil".into()))
.await
.is_err()
);
assert!(
rec.calls().is_empty(),
"nothing may spawn: {:?}",
rec.calls()
);
}
#[tokio::test]
async fn typed_checkout_targets_pass_through() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.checkout(Path::new("/r"), &CheckoutTarget::Ref(rv("feature/x")))
.await
.expect("checkout");
assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x", "--"]);
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.checkout(Path::new("/r"), &CheckoutTarget::Previous)
.await
.expect("checkout -");
assert_eq!(rec.only_call().args_str(), ["checkout", "-", "--"]);
}
#[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 mid = Git::with_runner(
ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 2.7.4\n")),
);
let caps = mid.capabilities().await.expect("capabilities");
assert!(!caps.is_supported(), "2.7.4 is below the 2.31 floor");
let err = caps.ensure_supported().expect_err("2.7.4 unsupported");
let Error::Spawn { source, .. } = &err else {
panic!("expected Spawn, got {err:?}");
};
assert!(
source.to_string().contains(">= 2.31"),
"names the 2.31 floor"
);
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"), &rn("v1"), None)
.await
.unwrap();
git.tag_create(Path::new("/r"), &rn("v1"), Some(rv("abc")))
.await
.unwrap();
git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new(rn("v2"), "notes"))
.await
.unwrap();
git.tag_delete(Path::new("/r"), &rn("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(rn("b")).into_base(rv("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(rv("b")))
.await
.unwrap();
git.merge_squash(Path::new("."), &rv("b")).await.unwrap();
git.merge_no_commit(Path::new("."), MergeNoCommit::branch(rv("b")))
.await
.unwrap();
git.cherry_pick(Path::new("."), &rv("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"), &rv("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"), &rv("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"), &rv("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 diff_text_over_budget_errors_output_too_large() {
let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
.default_output_budget(OutputBudget::bytes(64 * 1024));
match git
.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
.await
{
Err(Error::OutputTooLarge {
program,
max_bytes,
total_bytes,
..
}) => {
assert_eq!(program, "git");
assert_eq!(max_bytes, Some(64 * 1024), "the allowed ceiling");
assert!(
total_bytes > 64 * 1024,
"the actual size ({total_bytes}) exceeds the allowed cap"
);
}
other => panic!("expected OutputTooLarge, got {other:?}"),
}
}
#[tokio::test]
async fn diff_text_under_budget_returns_full_output() {
let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(diff)))
.default_output_budget(OutputBudget::bytes(64 * 1024));
assert_eq!(
git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
.await
.expect("under-budget diff_text"),
diff
);
}
#[tokio::test]
async fn diff_text_within_override_reads_past_the_default() {
let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)))
.default_output_budget(OutputBudget::bytes(64 * 1024));
assert!(matches!(
git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
.await,
Err(Error::OutputTooLarge { .. })
));
let got = git
.diff_text_within(
Path::new("/r"),
DiffSpec::Rev("HEAD".into()),
OutputBudget::unlimited(),
)
.await
.expect("override reads the large diff");
assert_eq!(got, big);
}
#[tokio::test]
async fn show_file_over_budget_errors_and_override_reads() {
let big = "x".repeat(200_000);
let git = Git::with_runner(ScriptedRunner::new().on(["git", "show"], Reply::ok(&big)))
.default_output_budget(OutputBudget::bytes(64 * 1024));
assert!(matches!(
git.show_file(Path::new("/r"), &rv("HEAD"), "big.bin").await,
Err(Error::OutputTooLarge { .. })
));
let got = git
.show_file_within(
Path::new("/r"),
&rv("HEAD"),
"big.bin",
OutputBudget::unlimited(),
)
.await
.expect("override reads the large blob");
assert_eq!(got, big);
}
#[tokio::test]
async fn default_client_has_no_budget() {
let big = "diff --git a/f b/f\n".to_string() + &"+padding line\n".repeat(10_000);
let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(&big)));
assert_eq!(
git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
.await
.expect("unbudgeted client returns the full diff"),
big
);
}
#[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(rv("HEAD~1")))
.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"), &rv("abc")).await.unwrap();
git.cherry_pick(Path::new("/r"), &rv("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 sequencer_abort_continue_reset_commands() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
let d = Path::new("/r");
git.cherry_pick_abort(d).await.unwrap();
git.cherry_pick_continue(d).await.unwrap();
git.revert_abort(d).await.unwrap();
git.revert_continue(d).await.unwrap();
git.bisect_reset(d).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["cherry-pick", "--abort"]);
assert_eq!(calls[1].args_str(), ["cherry-pick", "--continue"]);
assert_eq!(calls[2].args_str(), ["revert", "--abort"]);
assert_eq!(calls[3].args_str(), ["revert", "--continue"]);
assert_eq!(calls[4].args_str(), ["bisect", "reset"]);
let has_editor = |idx: usize| {
calls[idx]
.envs
.iter()
.any(|(k, _)| k.to_str() == Some("GIT_EDITOR"))
};
assert!(has_editor(1), "cherry-pick --continue suppresses editor");
assert!(has_editor(3), "revert --continue suppresses editor");
}
#[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"), &rv("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"), &ct("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"), &ct("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"), &ct("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"), &ct("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);
}
#[test]
fn fetch_branch_invalid_names_rejected_at_refname() {
for branch in [
"",
"feature/*",
"feature/?",
"feature/[a]",
"a:b",
"two words",
"bad\tname",
] {
let err = RefName::new(branch).expect_err("invalid fetch branch name must be rejected");
assert!(vcs_cli_support::is_invalid_input(&err), "{branch:?}");
}
}
#[tokio::test]
async fn fetch_branch_accepts_valid_names() {
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.fetch_branch(Path::new("/repo"), &rn("feature/T-010_fix"))
.await
.expect("valid fetch branch name");
assert_eq!(
rec.only_call().args_str(),
[
"fetch",
"--quiet",
"origin",
"refs/heads/feature/T-010_fix:refs/remotes/origin/feature/T-010_fix"
]
);
}
#[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 {}
}