Expand description
vcs-core — one backend-agnostic facade over vcs-git and
vcs-jj.
It detects whether a directory is a git or a jj checkout, then dispatches
the operations both tools share to whichever backend is present — returning
backend-agnostic DTOs (RepoSnapshot, FileChange, MergeProbe, …), so
a caller codes against “the repository” instead of against git or jj. Async
throughout, structured errors, and every subprocess inherits the underlying
client’s OS-job containment (via processkit) so no git/jj tree is
ever orphaned.
It is the honest least-common-denominator, not a god-object. The common surface carries only what unifies without lying; operations the backends model too differently are deliberately left on the bound per-tool handles rather than faked (see below). Reach for the facade when code must work on both backends; drop to the raw client the moment you need power only one of them offers.
§Mental model
The surface is three layers, narrowing from “which tool is this?” to “do the thing”:
detect— walk up from a directory to the filesystem root for a.git/.jjrepo (jj wins when colocated — it’s the tool driving the working copy). Pure filesystem probing, no subprocess; yields aLocated(BackendKind+ worktree root).Repo— the cwd-bound facade handle, the thing you hold. Open one withRepo::open(real job-backed runner) or build it over an explicit client withRepo::from_git/Repo::from_jj(the test seam). Re-anchor it to another directory cheaply withRepo::at— the backend is shared behind anArc, so threading work across worktrees never re-detects or rebuilds the client. Inspect it withkind/root/cwd.VcsRepo— the same common surface as an object-safe trait, so a consumer can hold aBox<dyn VcsRepo>/&dyn VcsRepowithout naming theProcessRunnergeneric. Every method mirrors the like-named inherent method onRepo; it adds nothing but the abstraction boundary.
§The common operations
All on Repo (and VcsRepo), dir-free, dispatched per backend:
- Refs —
current_branch,trunk,local_branches,branch_exists,delete_branch,rename_branch(branch on git, bookmark on jj). - Status —
changed_files,diff_stat,has_uncommitted_changes,has_tracked_changes,conflicted_files, andsnapshot— a batched prompt/status-bar read of the lot in one or two spawns. - Mutations —
commit_paths(partial commit),fetch/fetch_from/fetch_remote_branch/push,checkout,rebase. - Merge & operation state —
try_merge(a trace-free conflict probe →MergeProbe),in_progress_state/abort_in_progress/continue_in_progress→OperationState. - Worktrees / workspaces —
list_worktrees,create_worktree,remove_worktree, and the synchronouscleanup_worktree_blockingfor aDropguard that cannot.await.
Because the backends genuinely diverge in places, several common methods carry
a documented asymmetry (e.g. upstream/ahead/behind are always None on
jj; diff_stat excludes untracked files on git but not jj;
in_progress_state never returns Conflict on git).
The method docs spell each one out — the facade unifies the shape, not away
the truth.
§The escape hatches
Tool-specific work reaches the underlying typed clients without adding
vcs-git/vcs-jj as separate dependencies (both are re-exported):
git_at / jj_at hand out a cwd-bound view
(GitAt / JjAt, dir dropped); the raw
git / jj hand out a borrow of the client itself.
Each returns None for the other backend.
§What’s deliberately not unified
Three families stay off the common surface because no honest single shape exists — reach them through the bound handles:
- Full
merge— jj composesnew+squash+ bookmark moves; git runs a single command. Only the conflict probe unifies, astry_merge. - Operation rollback — jj’s
op restorehas no faithful git analogue; useJj::transactionon the jj client. - Range / revset queries — commit counts and diff stats over a range: git’s
a..band jj’s revsets aren’t interchangeable, so neither is forced onto a shared signature.
§Recipes
Open a repo and read a snapshot for a one-line prompt:
use vcs_core::Repo;
let repo = Repo::open(".")?; // detects git vs jj
let s = repo.snapshot().await?; // one or two spawns, not a call per field
let branch = s.branch.as_deref().unwrap_or("(detached)");
let head = s.head.as_deref().map(|h| &h[..7.min(h.len())]).unwrap_or("-");
println!("{}@{head} {}", branch, if s.dirty { "*" } else { "" });Probe a merge for conflicts (trace-free), or spin up a worktree:
use std::path::Path;
use vcs_core::{MergeProbe, Repo};
match repo.try_merge("feature").await? {
MergeProbe::Clean => println!("merges cleanly"),
MergeProbe::Conflicts(paths) => println!("would conflict in {paths:?}"),
_ => {} // #[non_exhaustive]
}
let wt = repo.create_worktree(Path::new("/tmp/feat"), "feature", "main").await?;§Testing
There is no mock feature on the facade traits — the runner is the seam.
Build a Repo over a fake ProcessRunner with Repo::from_git /
Repo::from_jj (e.g. a ScriptedRunner
replying to canned argv), so the real per-backend dispatch, argv-building and
parsing run against canned output — exactly what a mocked VcsRepo would skip.
The cross-cutting patterns live in
vcs-testkit’s guide.
use processkit::{Reply, ScriptedRunner};
use vcs_core::{vcs_git::Git, Repo};
let runner = ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0"));
let repo = Repo::from_git("/repo", "/repo", Git::with_runner(runner));
assert!(repo.has_uncommitted_changes().await?);§In-depth guide
Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
from docs/. See the guide module, which walks every operation in depth
and hosts the cross-cutting sub-guides: a cookbook of
end-to-end flows, the process_model (job containment,
errors, cancellation), positioning (facade-vs-raw-client
and the three call shapes), and the stability contract.
Re-exports§
Modules§
- guide
- vcs-core — backend-agnostic facade guide
Structs§
- Cancellation
Token cancellation - A token which can be used to signal a cancellation request to one or more tasks.
- Diff
Stat - Aggregate insertion/deletion counts for the working copy — the shared
vcs_diff::DiffStat, returned by the backends directly (no remapping). Aggregate line/file counts from a diff stat (git diff --shortstat,jj diff --stat). - File
Change - One changed path in the working copy, unified across
git status/jj diff --summary. - Located
- The result of
detect: which backend, and the repository root it was found at. - Repo
- A cwd-bound, backend-agnostic VCS handle. Operations run against the bound
directory (
cwd); useatto get a sibling handle bound elsewhere. - Repo
Snapshot - A one-shot snapshot of the common repository state — branch, upstream
tracking, ahead/behind, dirtiness, and operation state — gathered in one or
two process spawns instead of a call per field. The data a prompt, status
line, or TUI refresh needs. See
Repo::snapshot. - Worktree
Info - One attached worktree (git) / workspace (jj).
Enums§
- Backend
Kind - Which version-control tool backs a
Repo. - Change
Kind - How a file changed in the working copy — the shared
vcs_diff::ChangeKind(one type across the wrappers and the facade, no remapping). The status-code mappers in the backends turn git’sXYcodes / jj’s letters into it. How a file changed in a unified diff. - Create
Outcome - How a worktree was materialised. The facade always reports
Plain; theCowClonedvariant exists so a consumer that layers a copy-on-write strategy on top can reuse this type. - Error
- An error from a
Repooperation. - Merge
Probe - The outcome of a
try_mergeprobe. The probe itself is rolled back before it returns, whatever the outcome — this only reports what a real merge would do. - Operation
State - Whether the working copy is mid-operation, unified across the backends’
different models: git exposes an in-progress merge or rebase as on-disk state
(
MERGE_HEAD/ arebase-*dir), while jj has no multi-step operations — it records a conflict directly on the working-copy change.
Traits§
- VcsRepo
- The backend-agnostic common surface of
Repo, as a trait — so a consumer can hold aBox<dyn VcsRepo>/&dyn VcsRepoand code against the operations without naming theProcessRunnergeneric or wrappingRepothemselves.
Functions§
- detect
- Walk up from
startto the filesystem root looking for a repository. A.jjdirectory wins over.git(colocated repos are driven through jj);.gitmay be a directory or a gitlink file (a linked worktree/submodule). Pure filesystem probing — no subprocess.