Expand description
§vcs-git — Git CLI guide
What you can do: status & branches, stage/commit/checkout, diff & log, merge/rebase/reset, worktrees, tags, blame, clone, config, cherry-pick/revert, parse & resolve conflict markers, and a hardened (hooks-off) mode for untrusted repos. This guide is the full reference — every command by theme, with examples.
Typed, repo-scoped, async commands over the git binary, behind a mockable
interface. Every method runs git inside an OS job (via processkit) so a
subprocess is never orphaned, returns the structured processkit::Error, and
honours an optional timeout. Consumers code against the GitApi trait and swap
in a fake in tests.
Caller-supplied names, revisions, ranges, remotes, and URLs that land in a bare
positional argv slot are guarded automatically — a value that is empty or begins
with - is rejected with an ErrorReason::Spawn before anything spawns, so it can’t
be smuggled in as a flag.
§Construction & configuration
use vcs_git::Git;
let git = Git::new(); // real, job-backed runner
let git = Git::new().default_timeout(Duration::from_secs(30)); // every cmd → ErrorReason::Timeout past 30sGit::new()— the production client over the real job-backed runner.Git::with_runner(runner)— inject a fakeProcessRunner(e.g.processkit::testing::ScriptedRunner) for hermetic tests; see Testing & mocking.default_timeout(Duration)— builder; arms a per-command timeout.
new, with_runner, and default_timeout all come from the
processkit::cli_client! macro that defines Git.
§Hardening (Git::hardened())
Running git inside an untrusted checkout executes that repository’s hooks and
honours its config — arbitrary code execution by default. Git::hardened()
(equivalently Git::new().harden()) applies a containment profile to every
command the client runs:
- Disables hooks —
core.hooksPathis pinned to/dev/nullvia git’s env-based config (GIT_CONFIG_COUNT/KEY_n/VALUE_n, which overrides even the repo-local.git/config) — andcore.fsmonitoris forcedfalse(a config-driven daemon launch). - Neutralizes
core.sshCommand— pinned empty (the config-key twin of the scrubbedGIT_SSH_COMMAND), so a repo-local override can’t run an arbitrary program for the SSH transport. The defaultssh(ambient~/.ssh/config/agent) still works. - Scrubs inherited
GIT_*redirectors so a poisoned parent environment can’t point commands at another repo:GIT_DIR,GIT_WORK_TREE,GIT_INDEX_FILE,GIT_OBJECT_DIRECTORY,GIT_ALTERNATE_OBJECT_DIRECTORIES,GIT_NAMESPACE,GIT_CEILING_DIRECTORIES,GIT_CONFIG_PARAMETERS,GIT_CONFIG_GLOBAL,GIT_CONFIG_SYSTEM. - Scrubs inherited command hooks that make git spawn an arbitrary program
from the environment:
GIT_SSH_COMMAND/GIT_SSH,GIT_ASKPASS,GIT_EXTERNAL_DIFF,GIT_PAGER,GIT_EDITOR/GIT_SEQUENCE_EDITOR. The opt-inwith_credentialsauth seam still works (it injects acredential.helper/ token env, not these variables). - Skips system config (
GIT_CONFIG_NOSYSTEM=1) and keeps terminal prompts off everywhere (GIT_TERMINAL_PROMPT=0).
Residual repo-local-config vectors (not neutralized). A few repo-local
.git/config / .gitattributes keys still run an arbitrary program and are not
pinned: filter.<drv>.clean/smudge (run on any working-tree materialization —
checkout, stash pop, worktree add) and diff.<drv>.textconv/diff.external
(run when a diff is produced; diff_text defends with --no-ext-diff, other diff/
blame reads do not). For a fully untrusted repo, don’t materialize its working
tree or run diffs through a hardened client without an OS-level sandbox — harden()
is hardening, not a sandbox.
Requires git ≥ 2.31. The hook / fsmonitor / sshCommand pins ride
GIT_CONFIG_COUNT (added in git 2.31); on older git they silently no-op — the
GIT_* scrub and GIT_TERMINAL_PROMPT=0 still apply, but repo-local hooks/fsmonitor/
sshCommand are not disabled, with no error. capabilities().ensure_supported() now
enforces the ≥ 2.31 floor — call it before relying on harden() against an untrusted
repo, or add an OS-level sandbox.
It does not sandbox the git binary or vet the repo’s content. harden() is
chainable on any runner — Git::with_runner(rec).harden() works in tests — but
Git::hardened() is the shorthand for the common case. See
Security & hardening.
use vcs_git::Git;
let git = Git::hardened(); // drive a repo you didn't create — hooks/config neutered§The cwd-bound view (GitAt)
Most GitApi methods take a leading dir: &Path. When you drive one directory
repeatedly, bind it once with git.at(&path) — the returned GitAt drops that
argument:
let git = Git::new();
let at = git.at(repo); // GitAt — Copy, borrows the client + path
let branch = at.current_branch().await?; // == git.current_branch(repo)
at.commit("feat: thing").await?; // == git.commit(repo, "…")GitAt is Copy for every runner (it holds only two references). The dir-taking
GitApi methods stay on Git so one client can drive many directories — e.g.
linked worktrees. Through the facade, vcs_core::Repo::git_at yields the same
handle.
§Inherent run_args / run_raw_args
The object-safe GitApi trait can’t take &[&str], so two inherent helpers do —
no Vec<String> allocation:
let out = git.run_args(&["status", "-s"]).await?; // String, errors on non-zero exit
let res = git.run_raw_args(&["rev-parse", "HEAD"]).await?; // ProcessResult<String>, never errors on exit§Status & staging
Working-tree inspection and the index.
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 add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
async fn conflicted_files(&self, dir: &Path) -> Result<Vec<PathBuf>>;status—git status --porcelain=v1 -z, parsed. Renames carry both paths.status_text— the raw porcelain text (--porcelain=v1), unparsed.status_tracked—statusignoring untracked files (--untracked-files=no); “is the tracked tree dirty”, staged or not.branch_status— a combined branch + working-tree snapshot in one spawn (status --porcelain=v2 --branch -z): HEAD, branch, upstream, ahead/behind, and change counts (BranchStatus) — the cheap primitive behind the facade’sRepo::snapshot. Use it for a prompt/status-bar line without N round-trips.add—git add -- <paths>(the--keeps a path from being read as a flag).staged_is_empty—git diff --cached --quiet, exit-code mapped:true= nothing staged.conflicted_files—git diff --name-only --diff-filter=U -z; repo-relative paths with/separators, empty when there are none.
git.add(repo, &[PathBuf::from("src/lib.rs")]).await?; // `git add -- src/lib.rs`
for entry in git.status(repo).await? { // Vec<StatusEntry>
match entry.old_path {
Some(from) => println!("rename {} -> {}", from.display(), entry.path.display()),
None => println!("{} {}", entry.code, entry.path.display()),
}
}
if !git.staged_is_empty(repo).await? { // bool
println!("index has staged changes");
}
for path in git.conflicted_files(repo).await? { // Vec<PathBuf>
println!("conflict: {}", path.display());
}§Commits & log
async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>>;
async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
async fn last_commit_message(&self, dir: &Path) -> Result<String>;
async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;log— up tomaxcommits reachable fromrevspec, newest first. Pass"HEAD"for the current branch, or a range likemain..HEAD. One signature mirrorsJjApi::log’s revset argument.commit—git commit -m <message>of the staged index.commit_paths— commit exactly the spec’s paths’ working-tree content, ignoring the index (commit [--amend] -m <message> --only -- <paths>); built throughCommitPaths.last_commit_message— the full last message (log -1 --format=%B), e.g. to pre-fill an amend.rev_list_count— how many commits arangespans (rev-list --count <range>), e.g. how far ahead of the upstream you are — cheaper than fetching and countinglog.
git.commit(repo, "feat: tidy lib").await?;
for c in git.log(repo, "HEAD", 5).await? { // Vec<Commit>, newest first
println!("{} {} — {} <{}>", c.short_hash, c.subject, c.author, c.date);
}
let ahead = git.log(repo, "origin/main..HEAD", 50).await?; // Vec<Commit>
let n = git.rev_list_count(repo, "origin/main..HEAD").await?; // usize — # commits ahead
let _ = (ahead, n);A commit that finds nothing to record fails; classify it with
is_nothing_to_commit rather than treating it as a real
error.
§Branches
async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
async fn delete_branch(&self, dir: &Path, spec: BranchDelete) -> Result<()>; // BranchDelete::new(name)[.force()]
async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool>; // MergeCheck::branch(b).into_base(base)
async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;branches— local branches (git branch), current one flagged.create_branch—git branch <name>, without switching to it.branch_exists—show-ref --verify --quiet refs/heads/<name>, exit-code mapped.delete_branch—branch -d, or-Dwhenforce.rename_branch—branch -m <old> <new>.is_merged— whether theMergeCheck’sbranchis fully merged into itsbase(branch --merged <base>). Built asMergeCheck::branch("feature").into_base("main")— naming the two same-typed refs across two steps so a swap (which would invert the answer) can’t compile silently.set_upstream—branch --set-upstream-to=<upstream> <branch>.current_branch—symbolic-ref --quiet --short HEAD→Some("main")for a normal or unborn branch,Noneon a detached HEAD. (Mirrors jj’sOptionbookmark shape.)
if !git.branch_exists(repo, "feature").await? { // bool
git.create_branch(repo, "feature").await?;
}
git.set_upstream(repo, "feature", "origin/feature").await?;
if git.is_merged(repo, MergeCheck::branch("feature").into_base("main")).await? { // bool
git.delete_branch(repo, BranchDelete::new("feature")).await?; // `branch -d feature`
}
for b in git.branches(repo).await? { // Vec<Branch>
println!("{}{}", if b.current { "* " } else { " " }, b.name);
}§Revisions
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 resolve_commit(&self, dir: &Path, rev: &RevSpec) -> Result<String>;
async fn is_unborn(&self, dir: &Path) -> Result<bool>;
async fn checkout(&self, dir: &Path, target: &CheckoutTarget) -> Result<()>;
async fn checkout_detach(&self, dir: &Path, commit: &RevSpec) -> Result<()>;Ref-name and revision arguments are the validated newtypes
RefName / RevSpec (and checkout’s CheckoutTarget), not bare &str — build
them at your input boundary.
rev_parse— resolve a revision to its full hash (rev-parse <rev>).rev_parse_short— the abbreviated hash (rev-parse --short <rev>), e.g. to label a detached HEAD.resolve_commit— resolve to a commit hash, peeling annotated tags (rev-parse --verify <rev>^{commit}).is_unborn— whetherHEADis unborn — a fresh repo with no commits (rev-parse --verify -q HEAD, exit-code mapped).checkout— switch to a branch/revision, or the previous branch (git checkout <target>); seeCheckoutTarget.checkout_detach— check out a commit as a detached HEAD (checkout --detach <commit>).
if git.is_unborn(repo).await? { // bool
println!("no commits yet");
}
let head = RevSpec::new("HEAD")?;
let hash = git.rev_parse(repo, &head).await?; // String — full 40-hex sha
let short = git.rev_parse_short(repo, &head).await?; // String — abbreviated
let _ = (hash, short);
git.checkout(repo, &CheckoutTarget::Ref(RevSpec::new("main")?)).await?;
git.checkout(repo, &CheckoutTarget::Previous).await?; // `git checkout -`To carry uncommitted changes across a switch, see the composed inherent helper
switch_with_stash.
§Worktrees
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<()>; // WorktreeRemove::new(path)[.force()]
async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
async fn worktree_prune(&self, dir: &Path) -> Result<()>;worktree_list—worktree list --porcelain, parsed intoVec<Worktree>.worktree_add—worktree add [-b <branch>] [--no-checkout] <path> [<commitish>]; built throughWorktreeAdd.worktree_remove—worktree remove [--force] <path>.worktree_move—worktree move <from> <to>.worktree_prune—worktree prune, dropping stale admin entries.
git.worktree_add(repo, WorktreeAdd::create_branch("/tmp/feature", "feature", "HEAD"))
.await?; // `worktree add -b feature /tmp/feature HEAD`
for wt in git.worktree_list(repo).await? { // Vec<Worktree>
println!("{} -> {:?}", wt.path.display(), wt.branch);
}
git.worktree_remove(repo, WorktreeRemove::new("/tmp/feature")).await?;For a synchronous best-effort removal in a Drop guard, see
blocking::worktree_remove.
§Submodules
async fn submodule_list(&self, dir: &Path) -> Result<Vec<Submodule>>;
async fn submodule_status(&self, dir: &Path) -> Result<Vec<SubmoduleStatus>>;
async fn submodule_update(&self, dir: &Path, spec: SubmoduleUpdate) -> Result<()>;submodule_list— the submodules declared in<dir>/.gitmodules, parsed from the machine-unambiguousgit config --file .gitmodules --list -zsource (not a text scan of the ini file). A repo with no submodules has no.gitmodules; that is an empty list, not an error, and no process is spawned. EachSubmodulecarriesname,path,url, and an optionalbranch. A pure read — it never clones, fetches, or executes submodule config.submodule_status—git submodule status, parsed into typedSubmoduleStatusentries: the checked-outsha, thepath, an optionaldescribe, and aSubmoduleStatefrom the line’s leading prefix (Current/Uninitialized-/RevisionMismatch+/ConflictU). Also a read.submodule_update—git submodule update [--init] [--recursive] [--depth <n>] [-- <paths>], built throughSubmoduleUpdate. This one executes a nested repository’s content: withinitit fetches from the URLs.gitmodulesrecords and checks out each submodule’s working tree. Terminal prompts are pinned off; positional paths are flag-guarded behind--. See the security guide for the untrusted-nested-config boundary — vet.gitmoduleswithsubmodule_listbefore updating an untrusted superproject.
for sub in git.submodule_list(repo).await? { // Vec<Submodule>
println!("{} <- {}", sub.path.display(), sub.url);
}
for st in git.submodule_status(repo).await? { // Vec<SubmoduleStatus>
if st.state == SubmoduleState::Uninitialized {
println!("{} is not initialized", st.path.display());
}
}
git.submodule_update(repo, SubmoduleUpdate::new().init().recursive())
.await?; // `submodule update --init --recursive`§Diff
async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
async fn diff_between(&self, dir: &Path, from: &RevSpec, to: &RevSpec) -> Result<Vec<FileDiff>>;
async fn diff_text_between(&self, dir: &Path, from: &RevSpec, to: &RevSpec) -> Result<String>;
async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;diff— parsed per-file unified diff forspec, layered ondiff_text.diff_text— raw git-format unified diff forspec(diff <spec> --no-color --no-ext-diff -M) — stable machine output. On an unborn repo,DiffSpec::WorkingTreediffs against the empty tree rather than failing; the empty-tree id is resolved for the repo’s active object format viaGit::empty_tree_oid(so it also works in asha256repo), not the SHA-1-onlyEMPTY_TREE_SHA1constant.diff_text_between— raw git-format unified diff comparing the tree atfromwith the tree atto(diff <from> <to> --no-color --no-ext-diff -M), with the existing explicit prefixes, rename detection, and trailing--. The twoRevSpecendpoints are validated independently and are never joined into a range string; the operation therefore excludes implicit working-tree changes and preserves thefrom→todirection.diff_between— parsed per-file counterpart ofdiff_text_between.Git::diff_text_between_withinandGit::diff_between_withinaccept an explicit per-callOutputBudget; the same budgeted methods are available onGitAt. All four content methods preserve diff output verbatim, including trailing context, and over-budget output remains an error rather than a truncated result.diff_is_empty—git diff --quiet, exit-code mapped: are there unstaged modifications to tracked files? Untracked files are not counted — not a full “is the working tree clean?” check; usestatusfor that.diff_range_is_empty—diff --quiet <range>, exit-code mapped.diff_stat— aggregateDiffStatfor a range (diff --shortstat <range>).
DiffSpec selects what is compared: WorkingTree (vs HEAD) or
Rev(String) (a revision or range, passed straight through to git diff) — see
DiffSpec below for what that passthrough implies.
if !git.diff_is_empty(repo).await? {
println!("working tree has unstaged tracked changes");
}
for file in git.diff(repo, DiffSpec::WorkingTree).await? { // Vec<FileDiff>
println!("{:?} {}", file.change, file.path.display());
}
let raw = git.diff_text(repo, DiffSpec::Rev("main..HEAD".into())).await?; // String
let between = git.diff_text_between(repo, &RevSpec::new("main")?, &RevSpec::new("HEAD")?).await?;
let stat = git.diff_stat(repo, "main..HEAD").await?; // DiffStat
println!("{} files, +{} -{}", stat.files_changed, stat.insertions, stat.deletions);
let _ = (raw, between);§Blame
async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;Per-line authorship of path (blame --line-porcelain [<rev>] -- <path>); None
blames the working tree’s HEAD.
for line in git.blame(repo, "src/lib.rs", None).await? { // Vec<BlameLine>
println!("{} {} {}", &line.commit[..8], line.author, line.content);
}§Remotes & upstream
async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
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 remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
async fn upstream(&self, dir: &Path) -> Result<Option<String>>;remote_url— a remote’s URL (remote get-url <remote>).remote_add/remote_set_url—remote add/remote set-url.remote_branches— branch names onremote, without fetching (ls-remote --heads <remote>), withGIT_TERMINAL_PROMPT=0.remote_branch_exists— whetheroriginhasnamewithout fetching, querying the fully-qualified ref sofoocan’t tail-matchbar/foo. Runs prompt-off with a 10s timeout; an unreachable remote reads asfalse, not an error.remote_head_branch—origin’s default branch (short name) fromsymbolic-ref refs/remotes/origin/HEAD;Nonewhen unset.upstream— the current branch’s upstream, e.g.Some("origin/main");Nonewhen unset.
if let Some(up) = git.upstream(repo).await? { // Option<String>
println!("tracking {up}");
}
if let Some(default) = git.remote_head_branch(repo).await? { // Option<String>
println!("origin default: {default}");
}
let exists = git.remote_branch_exists(repo, "main").await?; // bool — best-effort
let _ = exists;§Fetch / push / merge
async fn fetch(&self, dir: &Path) -> Result<()>;
async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
async fn merge_abort(&self, dir: &Path) -> Result<()>;
async fn merge_continue(&self, dir: &Path) -> Result<()>;
async fn reset_merge(&self, dir: &Path) -> Result<()>;
async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;fetch—fetch --quietfrom the default remote, prompt-off, retried on transient failures (3 attempts, 500 ms backoff).fetch_from— fetch from a named remote; same containment and retry.fetch_branch— fetch one branch into its remote-tracking ref (fetch --quiet origin refs/heads/<b>:refs/remotes/origin/<b>); same retry.push—push [-u] <remote> <refspec>, prompt-off; built throughGitPush.merge_squash— stage a branch’s changes without committing (merge --squash).merge_commit—merge [--no-ff] [-m <msg> | --no-edit] <branch>; with no message it takes the default merge message non-interactively. Built throughMergeCommit.merge_no_commit— merge without committing, for a dry run (merge --no-commit [--squash | --no-ff] <branch>). Built throughMergeNoCommit.merge_abort—merge --abort.merge_continue— finish a merge after resolving conflicts (commit --no-edit, editor suppressed).reset_merge— clear merge state, squash-safe (reset --merge).reset_hard— moveHEADand the working tree torev, discarding all staged and unstaged changes (reset --hard <rev>) — destructive; there is no undo for uncommitted work.
git.fetch(repo).await?; // retried on transient failure
git.push(repo, GitPush::branch("feature").set_upstream()).await?; // `push -u origin feature`
match git.merge_commit(repo, MergeCommit::branch("feature").no_ff()).await { // --no-ff, default message
Ok(()) => {}
Err(e) if is_merge_conflict(&e) => {
// resolve conflicts, then:
git.merge_continue(repo).await?;
}
Err(e) => return Err(e),
}§Rebase & sequencer
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
async fn rebase_abort(&self, dir: &Path) -> Result<()>;
async fn rebase_continue(&self, dir: &Path) -> Result<()>;
async fn rebase_skip(&self, dir: &Path) -> Result<()>;
async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;Every command here suppresses the editor (GIT_EDITOR=true,
GIT_SEQUENCE_EDITOR=true) so it never hangs a headless caller.
rebase— rebase the current branch ontoonto(rebase <onto>).rebase_abort/rebase_continue—rebase --abort/--continue.rebase_skip—rebase --skip; mainly for theapplybackend’s “nothing to commit” stop (the defaultmergebackend auto-drops emptied patches on--continue).cherry_pick— apply a commit onto the current branch (cherry-pick <rev>); a conflict surfaces as an error classifiable byis_merge_conflict.revert— revert a commit with the default message (revert --no-edit <rev>).
match git.cherry_pick(repo, "abc123").await {
Ok(()) => {}
Err(e) if is_merge_conflict(&e) => {
// resolve, then continue or abort
git.rebase_abort(repo).await.ok();
}
Err(e) => return Err(e),
}§Stash & clean
async fn stash_push(&self, dir: &Path, spec: StashPush) -> Result<()>; // StashPush::new()[.include_untracked()]
async fn stash_pop(&self, dir: &Path) -> Result<()>;
async fn stash_list(&self, dir: &Path) -> Result<Vec<StashEntry>>;
async fn stash_apply(&self, dir: &Path, index: usize) -> Result<()>;
async fn stash_drop(&self, dir: &Path, index: usize) -> Result<()>;
async fn clean(&self, dir: &Path, spec: Clean) -> Result<Vec<CleanEntry>>;stash_push—stash push(--include-untrackedwhen asked), e.g. to save state before a copy-on-write restore.stash_pop— restore the most recent stash and drop it (stash pop).stash_list— the stash list, most-recent first (stash@{0}), machine-parsed viastash list -z --format=%gd%x1f%H%x1f%gsintoVec<StashEntry>(index / hash / branch / message).stash_apply— apply the stash atindexwithout dropping it (stash apply stash@{<index>}).stash_drop— drop the stash atindexwithout applying it (stash drop stash@{<index>}).clean— remove untracked files/directories (git clean) per theCleanbuilder. Destructive unlessdry_runis set — with neitherdry_runnorforcepicked, this refuses before spawninggitat all, so deletion is never the default and never implied by omission. Returns the typed paths removed (forced) or that would be removed (dry run).
git.stash_push(repo, StashPush::new().include_untracked()).await?; // include untracked
// … do work on a clean tree …
git.stash_pop(repo).await?;
// Inspect and manage individual stashes:
for entry in git.stash_list(repo).await? {
println!("stash@{{{}}}: {}", entry.index, entry.message);
}
git.stash_apply(repo, 0).await?; // apply without dropping
git.stash_drop(repo, 0).await?; // then drop it explicitly
// Preview untracked garbage, then actually delete it — `force` is always an
// explicit, separate call; there is no default that deletes.
let preview = git.clean(repo, Clean::new().dry_run().directories()).await?;
for entry in &preview {
println!("would remove {:?} (dir: {})", entry.path, entry.is_dir);
}
git.clean(repo, Clean::new().force().directories()).await?;§In-progress state
async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;is_rebase_in_progress—truewhen arebase-merge/rebase-applydir exists under the git dir.is_merge_in_progress—truewhenMERGE_HEADexists under the git dir.
§Bisect sessions
async fn bisect_start(
&self,
dir: &Path,
bad: &RevSpec,
good: &RevSpec,
) -> Result<BisectStep>;
async fn bisect_good(&self, dir: &Path) -> Result<BisectStep>;
async fn bisect_bad(&self, dir: &Path) -> Result<BisectStep>;
async fn bisect_skip(&self, dir: &Path) -> Result<BisectStep>;
async fn bisect_reset(&self, dir: &Path) -> Result<()>;bisect_start emits exactly bisect start <bad> <good>; the other methods emit
bisect good, bisect bad, or bisect skip. bad and good are validated
RevSpec values, so they cannot smuggle a leading flag into Git’s argv.
Each classification returns a BisectStep: NextCandidate means Git has
checked out the returned revision for the caller’s test, while FirstBad means
the search is complete. The caller owns the test/classification loop — these
methods never run a test command or closure. Git’s ambiguous skip output is a
parse error rather than a guessed commit. Call bisect_reset when the session
finishes or is abandoned so the original branch/checkout is restored.
let good = RevSpec::new("main")?;
let bad = RevSpec::new("HEAD")?;
let mut step = git.bisect_start(repo, &bad, &good).await?;
loop {
if step.is_first_bad() {
println!("first bad: {}", step.revision());
break;
}
// Run the consumer's regression test at step.revision(), then classify it.
step = git.bisect_good(repo).await?;
}
git.bisect_reset(repo).await?;if git.is_rebase_in_progress(repo).await? || git.is_merge_in_progress(repo).await? {
println!("repo is mid-operation");
}§Clone / tags / config / show
async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;clone_repo—git clone <url> <dest>plusCloneSpecflags. Runs without a working directory — pass an absolutedest. Prompt-off.clone_repo_with_progressemits the same clone options and adds Git’s--progressflag while streaming process events.- The partial-clone,
--single-branch, and custom--originoptions described below are Git-only. The separatevcs-jjbackend’sjj git cloneexposes only its explicit--colocate/--no-colocatechoice; it does not provide these Git clone flags. tag_create— a lightweight tag atrev(tag <name> [<rev>];None= HEAD).tag_create_annotated—tag -a <name> -m <message> [<rev>]; built throughAnnotatedTag.tag_list— tag names in git’s default ordering (tag --list).tag_delete—tag -d <name>.show_file— a file’s content at a revision (show <rev>:<path>).pathis repo-relative; backslashes are normalised to/. Decoded lossily — binary files come back mangled rather than erroring. An empty or whitespace-onlypathis refused beforegitspawns: a bareshow <rev>:is not an error but the root tree listing, so an unguarded empty path would return a directory index as if it were a file’s content. (A leading-is still fine — it is inert inside the<rev>:<path>spec, so-dash.txtreads normally.)config_get— a config key’s value, orNonewhen unset (config --get <key>). A multi-valued key errors; read those viarun.config_set— set a key in the repo’s local config (config <key> <value>).
git.clone_repo(
"https://example.com/repo.git",
Path::new("/abs/dest"),
CloneSpec::new()
.branch("main")
.depth(1)
.filter(CloneFilter::BlobNone)
.single_branch()
.origin("upstream"),
).await?; // shallow partial clone
let repo = Path::new("/abs/dest");
git.tag_create_annotated(repo, AnnotatedTag::new("v1.0.0", "first release")).await?;
if let Some(name) = git.config_get(repo, "user.name").await? { // Option<String>
println!("user.name = {name}");
}
let readme = git.show_file(repo, "HEAD", "README.md").await?; // String (lossy)
let _ = readme;§Discovery
async fn version(&self) -> Result<String>;
async fn capabilities(&self) -> Result<GitCapabilities>;
async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
async fn init(&self, dir: &Path) -> Result<()>;version—git --versiontext.capabilities— the parsed version asGitCapabilities. A value type — probe once and keep it; an unrecognisable version string is anErrorReason::Parse.common_dir— the repository’s common git directory (rev-parse --git-common-dir), stable across linked worktrees.git_dir— this worktree’s git directory (rev-parse --git-dir).init— initialise a repository (git init).
let caps = git.capabilities().await?; // GitCapabilities
caps.ensure_supported()?; // clear error if git < 2.31
println!("git {}", caps.version);
let common = git.common_dir(repo).await?; // PathBuf
let _ = common;§Raw escape hatches
async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;run—git <args>in the process’s current directory, returning trimmed stdout (errors on a non-zero exit). For unmodelled commands.run_raw— likerunbut never errors on a non-zero exit — returns the capturedProcessResult.
These are not flag-guarded — the caller owns the argv. The inherent
run_args / run_raw_args take &[&str] to skip the Vec<String> allocation.
cwd (T-035). On the client (git.run(…)) these run in the process’s
current directory — target a specific repo with -C <dir> in the argv. On the
bound view (git.at(dir).run(…)) they are instead bound to dir: the view
forwards to the client’s dir-taking run_in/run_raw_in/run_args_in/
run_raw_args_in, so a raw call through the handle runs in the bound repo, like
every other GitAt method. Reach for the client’s run when you deliberately
want the process cwd.
let out = git.run(&["describe".into(), "--tags".into()]).await?; // String
let res = git.run_raw(&["status".into(), "-s".into()]).await?; // ProcessResult<String>
println!("exited {:?}", res.code());
let _ = out;§Composed inherent helpers
These live on Git (and GitAt), not the object-safe GitApi trait — they are
multi-step operations, not 1:1 CLI verbs, so mock their underlying calls instead.
switch_with_stash(dir, branch)— switch tobranch, carrying uncommitted changes (tracked and untracked) across via the stash:stash push -u→checkout→stash pop. A clean tree skips the stash round-trip. On a failed checkout the stash is popped back to restore the original state; on a conflicting pop the target branch stays checked out with the stash entry preserved.
git.switch_with_stash(repo, "feature").await?; // dirty tree comes along§Blocking helpers
pub fn blocking::worktree_remove(dir: &Path, spec: WorktreeRemove) -> std::io::Result<()>;A synchronous, best-effort git worktree remove [--force] <path> for contexts that
cannot .await — chiefly a Drop guard. It shells out through std::process
directly (no async, no job-containment), so reserve it for short-lived cleanup.
§Result types
All result structs/enums are #[non_exhaustive] (except GitVersion) — match with
a trailing .. and construct via the crate, not struct literals.
The diff types (ChangeKind, DiffLine, Hunk, FileDiff, DiffStat,
parse_diff) and GitVersion actually live in the shared
vcs-diff crate — git diff and
jj diff --git are byte-identical for ASCII paths (they differ only in
non-ASCII filename rendering, which the shared parser decodes), so vcs-git
and vcs-jj share one parser.
They’re re-exported here, so vcs_git::FileDiff etc. still resolve (GitVersion
is an alias of vcs_diff::Version).
§StatusEntry
One entry from git status --porcelain=v1 -z.
| field | type | meaning |
|---|---|---|
code | String | two-character status code, e.g. " M", "??", "A ", "R " |
path | PathBuf | the path (the new path for a rename/copy); raw -z bytes, lossless (non-UTF-8-safe on Unix) |
old_path | Option<PathBuf> | the original path for a rename/copy; None otherwise |
§BranchStatus
The combined snapshot from branch_status (status --porcelain=v2 --branch -z).
is_dirty() returns whether there’s any change (tracked or untracked).
| field | type | meaning |
|---|---|---|
head | Option<String> | HEAD commit’s full oid; None on an unborn repo (truncate for display) |
branch | Option<String> | current branch; None when detached |
upstream | Option<String> | upstream tracking branch; None when unset |
ahead | Option<usize> | commits ahead of upstream; None with no upstream |
behind | Option<usize> | commits behind upstream; None with no upstream |
tracked_changes | usize | changed tracked entries (1/2/u records) |
untracked | usize | untracked files (? records) |
conflicts | usize | unmerged entries (u records; also in tracked_changes) |
§Commit
A commit parsed from a \x1f-delimited git log line.
| field | type | meaning |
|---|---|---|
hash | String | full commit hash (%H) |
short_hash | String | abbreviated hash (%h) |
author | String | author name (%an) |
date | String | author date, strict ISO-8601 (%aI) |
subject | String | subject line (%s) |
§Branch
| field | type | meaning |
|---|---|---|
name | String | branch name |
current | bool | whether this is the checked-out branch (the * marker) |
§Worktree
| field | type | meaning |
|---|---|---|
path | PathBuf | absolute path to the worktree |
branch | Option<String> | short branch name (refs/heads/ stripped); None when detached or bare |
head | Option<String> | the checked-out commit; None for a bare entry |
bare | bool | the main worktree of a bare repository |
detached | bool | checked out at a detached HEAD |
locked | bool | locked against pruning |
§StashEntry
One entry from stash_list, parsed via stash list -z --format=%gd%x1f%H%x1f%gs.
| field | type | meaning |
|---|---|---|
index | usize | position in the list, most recent first — the numeral stash_apply/stash_drop take |
hash | String | the stashed commit’s full object id |
branch | Option<String> | branch checked out when pushed; None for a detached-HEAD stash |
message | String | the default <abbrev-sha> <subject> label, or the caller’s -m message |
§CleanEntry
One path from clean’s -n (dry run) or -f (forced) output.
| field | type | meaning |
|---|---|---|
path | PathBuf | the path, C-unquoted, with a directory entry’s trailing / stripped |
is_dir | bool | whether this names a whole untracked directory (-d), not a single file |
§DiffStat
Copy. Aggregate counts from git diff --shortstat.
| field | type | meaning |
|---|---|---|
files_changed | usize | number of files changed |
insertions | usize | lines added |
deletions | usize | lines removed |
§FileDiff
One file’s entry in a parsed git-format unified diff.
| field | type | meaning |
|---|---|---|
change | ChangeKind | how the file changed |
path | PathBuf | the file’s path (the new path for a rename), /-normalised; lossless |
old_path | Option<PathBuf> | the original path for a rename, /-normalised; None otherwise |
hunks | Vec<Hunk> | the @@ hunks; empty for a binary file or a pure rename |
raw | String | the verbatim diff --git … block, for callers that display raw text |
§ChangeKind
Copy enum: Added, Modified, Deleted, Renamed.
§Hunk
A single @@ … @@ hunk within a FileDiff.
| field | type | meaning |
|---|---|---|
old_start | usize | start line in the old file |
old_lines | usize | line count in the old file (defaults to 1 when the ,count is omitted) |
new_start | usize | start line in the new file |
new_lines | usize | line count in the new file (defaults to 1 when omitted) |
section | String | text after the closing @@ (the function/section heading); empty when none |
lines | Vec<DiffLine> | the hunk body, one entry per line |
§DiffLine
Enum, one variant per line role; the stored text excludes the leading marker:
Context(String) ( ), Added(String) (+), Removed(String) (-).
§BlameLine
One line of git blame --line-porcelain output.
| field | type | meaning |
|---|---|---|
commit | String | full hash of the commit that last changed the line |
orig_line | u32 | line number in that commit’s version (1-based) |
final_line | u32 | line number in the blamed version (1-based) |
author | String | author name of that commit |
author_time | i64 | author timestamp as a unix epoch (seconds) |
author_tz | String | author timezone offset, e.g. +0200 |
content | String | the line’s content (no trailing newline) |
§GitVersion
Copy, and Ord (so versions compare directly). Not #[non_exhaustive].
| field | type | meaning |
|---|---|---|
major | u64 | major component (2 in 2.54.0) |
minor | u64 | minor component |
patch | u64 | patch component (0 when the binary reports only major.minor) |
Displays as major.minor.patch.
§GitCapabilities
Copy. What the installed git supports, probed via capabilities().
| field | type | meaning |
|---|---|---|
version | GitVersion | the binary’s parsed version |
Methods: is_supported(&self) -> bool (git ≥ 2.31 — the highest version the crate’s own
argv requires) and ensure_supported(&self) -> Result<()> (a clear “needs git ≥ 2.31”
error otherwise, instead of a cryptic argv failure later).
§Config & builder types
§DiffSpec
An enum selecting what diff / diff_text compares — a re-export of
vcs_diff::DiffSpec, deliberately exhaustive (not #[non_exhaustive]):
DiffSpec::WorkingTree— all tracked working-tree changes vs the last commit (git diff HEAD), staged or not, excluding untracked files.DiffSpec::Rev(String)— a specific revision or range, e.g.main..HEADorHEAD~1(git diff <rev>).
Rev is a direct passthrough. The string becomes git’s one positional diff
argument verbatim — this crate doesn’t parse, classify, or rewrite it beyond the
reject_flag_like guard against a leading -. Whatever git diff <rev> would
do at the shell for that exact string is what Rev(rev) does here.
One consequence follows from plain git diff’s own rules: a lone revision (no
../...) diffs the working tree against that revision, not the revision
against its parent — when HEAD exists, Rev("HEAD".into()) behaves exactly
like WorkingTree, and Rev("abc".into()) includes any uncommitted changes on
top of abc. On an unborn repository the two differ: WorkingTree deliberately
diffs against the empty tree, while Rev("HEAD".into()) passes the unresolved
name through and errors. To diff two commits with the working tree excluded,
put a range in the string instead (Rev("abc..def".into()) or
Rev("abc^..abc".into())) — git’s two-dot/three-dot forms compare
commit-to-commit. None of this is vcs-git logic; it’s inherited by passing the
string straight to git, except for WorkingTree’s documented unborn fallback.
How DiffSpec is interpreted here is stable behavior, not an implementation detail.
Changing it would be a semver-breaking change.
For an explicit tree-to-tree comparison use diff_text_between/diff_between
with two independently validated RevSpec values. This separate API is the
unambiguous alternative when the working tree must be excluded; it does not
change the backend-specific DiffSpec::Rev behavior above.
§MergeCheck
The spec is_merged takes — “is branch fully merged into base?”. A two-step
type-state builder (#[non_exhaustive]), so the two same-typed refs are named across
separate steps and can’t be silently transposed (a swap would invert the answer):
pub fn branch(name: impl Into<String>) -> MergeCheckPartial; // entry: names the branch tested
pub fn into_base(self, base: impl Into<String>) -> MergeCheck; // on MergeCheckPartial: names the base
// fields: pub branch: String, pub base: String- Built as
MergeCheck::branch("feature").into_base("main");is_mergedthen runsgit branch --merged <base>and reports whether<branch>appears.
§WorktreeAdd
Options for worktree_add. #[non_exhaustive] — build it through the constructors,
not a struct literal.
pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self;
pub fn create_branch(path: impl Into<PathBuf>, name: impl Into<String>, commitish: impl Into<String>) -> Self;
pub fn no_checkout(self) -> Self; // chainable: register without populating files (--no-checkout)checkout— a worktree atpathchecking out an existingcommitish:worktree add <path> <commitish>.create_branch— create a new branchnamebased oncommitish:worktree add -b <name> <path> <commitish>.no_checkout— register the worktree without populating its files (--no-checkout), for a caller (e.g. a copy-on-write clone) that fills the working tree itself.
Fields: path: PathBuf, new_branch: Option<String>, commitish: Option<String>,
no_checkout: bool.
let a = WorktreeAdd::checkout("/wt", "main"); // existing branch
let b = WorktreeAdd::create_branch("/wt", "feature", "HEAD"); // new branch off HEAD
let c = WorktreeAdd::checkout("/wt", "main").no_checkout(); // skeleton only§Clean
Options for clean. #[non_exhaustive] — build it through Clean::new and the
chained setters, not a struct literal.
pub fn new() -> Self;
pub fn force(self) -> Self; // chainable: actually delete (--force)
pub fn dry_run(self) -> Self; // chainable: report only, delete nothing (--dry-run); wins over `force`
pub fn directories(self) -> Self; // chainable: remove whole untracked dirs too (-d)
pub fn include_ignored(self) -> Self; // chainable: also remove ignored files/dirs (-x)
pub fn only_ignored(self) -> Self; // chainable: remove ONLY ignored files/dirs (-X)Force is always an explicit, separate call — never a default. Clean::new() picks
neither dry_run nor force; passing that as-is to clean is refused before
spawning git (an ErrorReason::Spawn/InvalidInput), rather than depending on this
repository’s clean.requireForce git config to guard the delete. When dry_run is
set, force is ignored — dry-run always wins, so a spec that (oddly) sets both can
never actually delete.
Fields: force: bool, dry_run: bool, directories: bool, ignored: CleanIgnored.
CleanIgnored is Exclude (default: leave ignored files alone), Include (-x), or
Only (-X).
let preview = Clean::new().dry_run().directories(); // safe preview, incl. whole dirs
let delete = Clean::new().force().directories(); // actually delete
let only_ignored = Clean::new().dry_run().only_ignored(); // preview ignored files only§GitPush
Options for push. #[non_exhaustive] — build it through the constructors.
pub fn branch(name: impl Into<String>) -> Self; // push origin <name>
pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self; // push origin <local>:<remote_branch>
pub fn remote(self, remote: impl Into<String>) -> Self; // chainable: non-default remote
pub fn set_upstream(self) -> Self; // chainable: record upstream (-u)Fields: remote: String (defaults to origin), refspec: String,
set_upstream: bool.
let p = GitPush::branch("feature").set_upstream(); // push -u origin feature
let q = GitPush::refspec("local", "remote_branch").remote("upstream");§CloneSpec
Options for clone_repo. #[non_exhaustive], Default — build through new and
the chained setters.
pub fn new() -> Self; // a plain full clone of the default branch
pub fn branch(self, branch: impl Into<String>) -> Self; // --branch
pub fn depth(self, depth: u32) -> Self; // --depth (see local-path caveat below)
pub fn filter(self, filter: CloneFilter) -> Self; // --filter=blob:none or --filter=tree:0
pub fn single_branch(self) -> Self; // --single-branch
pub fn origin(self, name: impl Into<String>) -> Self; // --origin <name>
pub fn bare(self) -> Self; // --bareFields: branch: Option<String>, depth: Option<u32>,
filter: Option<CloneFilter>, single_branch: bool, origin: Option<String>,
bare: bool.
depth is silently ignored by git for a plain local-path source (it warns and
clones fully); use a file:// URL to shallow-clone locally. CloneFilter::BlobNone
requests --filter=blob:none (blobless) and CloneFilter::TreeZero requests
--filter=tree:0 (treeless). origin is checked before git is spawned and
rejects empty, whitespace-only, flag-like, or NUL-containing names.
CloneFilter is a Git-specific enum; jj git clone has no corresponding
partial-clone or single-branch/origin flags.
let spec = CloneSpec::new()
.branch("main")
.depth(1)
.filter(CloneFilter::TreeZero)
.single_branch()
.origin("upstream");
let bare = CloneSpec::new().bare();§CommitPaths
Options for commit_paths. #[non_exhaustive] — build through new and the
chained setter.
pub fn new(paths: impl IntoIterator<Item = impl Into<PathBuf>>, message: impl Into<String>) -> Self;
pub fn amend(self) -> Self; // chainable: amend the previous commit (--amend)Fields: paths: Vec<PathBuf> (--only -- <paths>), message: String (-m),
amend: bool (--amend).
let c = CommitPaths::new(["src/lib.rs"], "feat: thing");
let a = CommitPaths::new(["src/lib.rs"], "feat: thing").amend();§MergeCommit
Options for merge_commit. #[non_exhaustive] — build through branch and the
chained setters.
pub fn branch(name: impl Into<String>) -> Self; // merge --no-edit <name> (default message)
pub fn no_ff(self) -> Self; // chainable: always create a merge commit (--no-ff)
pub fn message(self, m: impl Into<String>) -> Self; // chainable: merge message (-m)Fields: branch: String, no_ff: bool (--no-ff), message: Option<String>
(-m; None takes the default message non-interactively via --no-edit).
let m = MergeCommit::branch("feature").no_ff(); // --no-ff, default message
let n = MergeCommit::branch("feature").message("merge it"); // -m "merge it"§MergeNoCommit
Options for merge_no_commit. #[non_exhaustive] — build through branch and the
chained setters.
pub fn branch(name: impl Into<String>) -> Self; // merge --no-commit <name>
pub fn squash(self) -> Self; // chainable: stage the squashed result (--squash)
pub fn no_ff(self) -> Self; // chainable: record a real abortable merge (--no-ff)Fields: branch: String, squash: bool (--squash; takes precedence over
no_ff), no_ff: bool (--no-ff). With no_ff (and not squash) git records
MERGE_HEAD, so the merge is abortable via merge_abort; with squash no
MERGE_HEAD is recorded — undo via reset_merge / reset_hard.
let dry = MergeNoCommit::branch("feature").no_ff(); // abortable dry-run merge
let sq = MergeNoCommit::branch("feature").squash(); // stage squashed, no MERGE_HEAD§AnnotatedTag
Options for tag_create_annotated. #[non_exhaustive] — build through new and
the chained setter.
pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self; // tag -a <name> -m <message> at HEAD
pub fn rev(self, r: impl Into<String>) -> Self; // chainable: tag <rev> instead of HEADFields: name: String, message: String (-m), rev: Option<String> (<rev>;
None tags HEAD).
let t = AnnotatedTag::new("v1.0.0", "first release");
let u = AnnotatedTag::new("v1.0.0", "first release").rev("abc123");§Validating newtypes
Every operation that names a reference (branch/tag/ref) or a revision
takes one of these validated newtypes — RefName or RevSpec — instead of a bare
&str. Construct one at your input boundary (from a UI/bot/agent string); a
flag-like or malformed value is rejected there, as a classifiable
vcs_cli_support::is_invalid_input, and can never reach an argv slot. The remaining
bare-positional &str inputs that are not refs/revisions (remote names, URLs,
config keys) keep an internal flag-injection guard.
§RefName
A validated reference name (branch/tag/remote-tracking ref), following the
load-bearing core of git check-ref-format. Used where git creates, deletes,
renames, or looks up a ref by exact name. Rejects a name that is:
- empty,
- has a leading
-or., - contains
.., - contains a control character or space, or any of
~ ^ : ? * [ \, - ends with
/or.lock.
pub fn new(name: impl Into<String>) -> Result<Self>;
pub fn as_str(&self) -> &str;§RevSpec
A validated revision/range expression (HEAD~2, main..feature). Used where git
resolves a general commit-ish or range (checkout, reset_hard, log, diff
ranges, …). Deliberately minimal — git’s revision grammar is too rich to validate
here — it only guarantees the expression is non-empty and cannot be parsed as a
flag (no leading -).
pub fn new(rev: impl Into<String>) -> Result<Self>;
pub fn as_str(&self) -> &str;§CheckoutTarget
checkout (and switch_with_stash) take a CheckoutTarget: either
CheckoutTarget::Ref(RevSpec) — a validated ref/revision — or
CheckoutTarget::Previous, git’s - “previous branch” shortcut modelled
explicitly as a safe fixed literal (rather than a leading-- value the flag guard
would reject).
let name = RefName::new("feature/login")?; // Ok
let rev = RevSpec::new("main..HEAD")?; // Ok
let target = CheckoutTarget::Ref(RevSpec::new("main")?);
assert!(RefName::new("-evil").is_err()); // leading '-'
assert!(RefName::new("bad..name").is_err()); // contains '..'
assert!(RevSpec::new("-x").is_err()); // would parse as a flag
let _ = (name, rev, target, CheckoutTarget::Previous);RefName/RevSpec implement Display and yield the validated string via
as_str().
§Error classification
git writes load-bearing diagnostics to either stream on failure, so these free
functions probe both stdout and stderr of an ErrorReason::Exit — call them instead
of re-implementing the string-scraping yourself.
pub fn is_merge_conflict(err: &Error) -> bool; // a merge/cherry-pick stopped on conflicts
pub fn is_nothing_to_commit(err: &Error) -> bool; // a commit found a clean tree
pub fn is_transient_fetch_error(err: &Error) -> bool; // DNS / dropped connection — retryableis_transient_fetch_error deliberately does not treat a processkit-level
ErrorReason::Timeout as retryable: a timeout already spent the caller’s full deadline, so
retrying it would multiply the wall-clock (a fetch is tried up to 3×). Raise the
timeout rather than have it silently tripled. See Process model & errors for the Error shape.
§See also
-
Supported CLI versions — the git 2.31 floor,
ensure_supportedbehavior, and CI coverage. -
Conflict resolution —
vcs_git::conflict: parse conflict markers into structured regions and resolve a chosen side. -
Testing & mocking — the
mockfeature’sMockGitApiand theScriptedRunner/RecordingRunnerseams. -
Security & hardening —
Git::hardened()and the injection guards. -
Process model & errors — job containment, timeouts, and the structured
Error.