Expand description
§Cookbook
Task-oriented, end-to-end recipes that compose the wrappers into the jobs people actually reach for — deeper than the README snippets, lighter than the per-crate guides (git / jj / github / gitlab / gitea / core / forge), which document the full surface each recipe draws on.
§A prompt / status-bar line in one or two spawns
A shell prompt or TUI refreshes constantly, so the cost is per-field spawns.
Repo::snapshot batches the common state into one git status --porcelain=v2 --branch (plus an in-progress probe) — or, on jj, one log -r @
template plus a change count — and hands back a RepoSnapshot.
let s = repo.snapshot().await?; // RepoSnapshot — one/two spawns
let branch = s.branch.as_deref().unwrap_or("(detached)");
let mut line = branch.to_string();
if let (Some(a), Some(b)) = (s.ahead, s.behind) {
line.push_str(&format!(" ↑{a}↓{b}")); // upstream tracking — git only
}
if s.dirty {
line.push_str(" *"); // uncommitted changes
}
if s.conflicted || s.operation != OperationState::Clear {
line.push_str(" ⚠"); // mid-merge/rebase or conflicted
}
println!("{line}"); // e.g. `main ↑1↓0 *`Notes: upstream/ahead/behind are always None on jj (no git-style
upstream tracking) — the ↑↓ segment simply won’t render there. If you’re git-only
and want the raw one-spawn primitive without the facade, call
GitApi::branch_status directly — it returns a BranchStatus
with the same fields plus is_dirty().
§Keep a status line live
Don’t poll snapshot() on a timer — let vcs-watch push a fresh one
whenever the repo actually changes. It filesystem-watches .git/.jj, debounces,
re-queries, and hands you the new [RepoSnapshot] plus the typed deltas.
let repo = Repo::open(".")?;
let mut watcher = RepoWatcher::watch(repo).await?; // tokio runtime required
render(watcher.current()); // initial paint
while let Some(change) = watcher.recv().await {
render(&change.snapshot); // repaint with the fresh state
let _ = &change.events; // …or react to specific deltas
}Notes: each change carries both the full snapshot (repaint) and events
(HeadMoved/BranchCreated/WorkingCopyChanged/… — react). A bare unstaged edit
is caught only once staged unless you opt into RepoWatcher::builder(repo) .working_tree(true). Dropping the watcher stops it. RepoSnapshot is re-exported
from vcs-watch, so depending on it alone suffices.
§Open a PR and wait for CI
Push a branch, open the PR, then block on its workflow run and branch on the
outcome. gh run watch blocks for the whole run, so drive it from a client with a
generous (or no) timeout — see github.md.
if !gh.auth_status().await? { // bool
return Ok(()); // not logged in — `gh auth login` first
}
let spec = PrCreate::new("Add the thing", "Body.").head("feat/x").base("main");
let url = gh.pr_create(repo, spec).await?; // String — PR url
println!("opened {url}");
// The newest run on the head branch carries the id `run_watch` needs.
let runs = gh.run_list(repo, 1, Some("feat/x".into())).await?; // Vec<WorkflowRun>, newest first
if let Some(run) = runs.first() {
let done = gh.run_watch(repo, run.database_id).await?; // blocks, then re-reads → WorkflowRun
match done.conclusion.as_str() { // "" until complete; "success"/"failure"/…
"success" => println!("CI green"),
other => println!("CI {other}: {}", done.url),
}
}Notes: run_watch deliberately omits --exit-status, so the outcome travels in
WorkflowRun.conclusion (a failed run can’t be told from a cancelled one by exit
code). PrCreate’s .head()/.base() are optional — omitted means the current
branch / repo default. run_list’s limit is a u64. Targeting GitLab or
Gitea instead of GitHub? Use the vcs-forge facade — one
Forge::pr_create/pr_merge/pr_checks lifecycle across all three forges, with
unified DTOs (it picks the binary; gh-specific bits like run_watch stay on
vcs-github).
§Cancel a long-running watch / fetch
run_watch blocks for the whole CI run; a fetch/clone/push over a dead
network can hang for its full timeout. With the cancellation feature (off by
default — enable vcs-github/cancellation, or vcs-core/vcs-forge/cancellation
for the facades) a client built with default_cancel_on(token) carries that token
into every command it runs, so one token.cancel() kills all of its in-flight
calls — no new API, no per-call plumbing.
// `CancellationToken` (under the `cancellation` feature) and `Error` are both
// re-exported by each wrapper, so a consumer needn't depend on `processkit` directly.
use vcs_github::{CancellationToken, Error, GitHub, GitHubApi};
let token = CancellationToken::new();
// Scope the cancellation to a CLIENT, not a call — clients are cheap; give each
// cancellable scope its own (child) token.
let gh = GitHub::new().default_cancel_on(token.child_token());
// A controller (timeout, Ctrl-C handler, "stop" button) cancels out-of-band:
tokio::spawn(async move {
shutdown_signal().await;
token.cancel(); // every in-flight gh call dies (kill-on-close tree)
});
match gh.run_watch(repo, run_id).await { // long block — interruptible now
Err(e) if matches!(e, Error::Cancelled { .. }) => println!("watch cancelled"),
other => { other?; }
}A per-command cancel_on on a built command replaces the client default
(explicit beats default, like timeout); derive both from one child_token() if
you need two cancel sources. Error::Cancelled is terminal — the fetch-retry
treats it as non-transient and will not replay a cancelled run. Through the facades,
build the wrapped client the same way (GitHub::new().default_cancel_on(t)) and
hand it to Forge::for_github(cwd, client) / Repo::from_git(root, cwd, client).
Cancellation is “stop now”, not “stop and clean up”. A fired token kills
every command the client still runs — including any cleanup the toolkit itself
issues. A multi-step facade operation that is cancelled mid-flight can therefore
be left part-done: Repo::try_merge probes a throwaway merge and
rolls it back with op_restore (jj) / merge --abort (git), but that rollback runs
on the same client, so a token that fired during the probe also cancels the rollback
— the probe change may remain. Likewise [Jj::transaction]’s op-log rollback runs
on Err, and a cancellation is an Err, but the op_restore it would run is
itself cancelled. If you need a guaranteed-clean state after cancelling, re-probe
(Repo::in_progress_state / Jj::op_head) and reset with a fresh, un-cancelled
client rather than assuming the interrupted call tidied up after itself.
§Stash-safe branch switch
Carry a dirty working tree across a checkout without losing it.
switch_with_stash is an inherent helper on Git (not the GitApi
trait): it does stash push -u → checkout → stash pop, popping back to restore
the original branch if the checkout fails.
git.switch_with_stash(repo, "feature").await?; // tracked + untracked changes come alongNotes: a clean tree skips the stash round-trip entirely. On a conflicting pop the
target branch stays checked out with the stash entry preserved — inspect with
git stash list rather than assuming the pop landed. Being a composed helper, it
lives off the object-safe trait; in tests, script its underlying stash/checkout
calls rather than mocking one method.
§Programmatic conflict resolution
Resolve every conflict in a file to one side, without a text editor. The
conflict modules are pure parsers over a file’s content — the
client fetches the bytes, the module reasons about them. Pair
conflicted_files (or jj’s resolve_list) to find the paths
with show_file / file_show (or just reading the worktree
file) to get the bytes.
for path in git.conflicted_files(repo).await? { // Vec<String>, `/`-separated
let content = std::fs::read_to_string(repo.join(&path))?;
if !has_conflict_markers(&content) {
continue; // cheap pre-check
}
let segments = parse_conflicts(&content)?; // Vec<ConflictSegment>
let resolved = resolve(&segments, ResolutionSide::Ours)?; // keep our side everywhere
std::fs::write(repo.join(&path), resolved)?;
}
// then `git.add(...)` + `git.merge_continue(repo)` / `rebase_continue`.The jj side mirrors this with JjResolution and 0-based side
indices (jj conflicts can have more than two sides):
for path in jj.resolve_list(repo, "@").await? { // Vec<String> — conflicts on `@`
let content = jj.file_show(repo, "@", &path).await?; // String (lossy)
let segments = parse_conflicts(&content)?; // Vec<JjConflictSegment>
let resolved = resolve(&segments, JjResolution::Side(0))?; // the first side
std::fs::write(repo.join(&path), resolved)?;
}Notes: ResolutionSide::Base (git) / JjResolution::Base errors when the conflict
records no base (git’s 2-way merge style has none). render(parse(x)?) is a
byte-exact round-trip, so resolving only rewrites the regions you chose. A
jj file materialized with the git marker style parses with vcs_git::conflict,
not vcs_jj::conflict (the module steers you there on a mismatch).
§Detect the backend and dispatch
Write one code path that works on git or jj. detect probes the
filesystem (jj wins when colocated), and Repo::open opens a handle
bound to a directory; the common methods dispatch to whichever tool is present.
if detect(start).is_none() { // Option<Located> — no spawn
return Ok(()); // not a repo
}
let repo = Repo::open(start)?;
println!("backend: {}", repo.kind().as_str()); // "git" / "jj"
for change in repo.changed_files().await? { /* FileChange */ let _ = change; }
let branch = repo.current_branch().await?; // Option<String>
let conflicts = repo.conflicted_files().await?; // Vec<String>
let _ = (branch, conflicts);
// Drop to the raw client for tool-specific ops off the common surface:
match repo.kind() {
BackendKind::Git => { let _g = repo.git().unwrap(); /* git-only verbs */ }
BackendKind::Jj => { let _j = repo.jj().unwrap(); /* jj-only verbs */ }
}Notes: repo.git() / repo.jj() return Option — Some only for the matching
backend. For a dir-free view bound to the handle’s cwd, use repo.git_at() /
repo.jj_at(). A consumer that wants to avoid naming the runner generic can hold a
&dyn VcsRepo instead.
§jj transaction with op-log rollback
jj’s operation log makes a multi-step mutation atomically reversible — something
git can’t faithfully offer. Jj::transaction captures the current op
head, runs your closure against a bound JjAt, and restores the op head on
Err.
jj.transaction(repo, |tx| async move { // tx: JjAt, dir pre-bound
tx.describe("wip: refactor").await?;
tx.new_change("next").await // an Err here rolls back the describe
})
.await?;Notes: transaction is inherent (the generic closure can’t live on the object-safe
trait). Rollback runs on Err only — not on panic or a dropped future (no async
Drop); convert panics to Err inside the closure if you need that. If the restore
itself fails, the closure’s original error is returned and the repo may be left
mid-transaction — re-probe op_head to detect it.
§Test a consumer hermetically
Depend on the interface, never the concrete client, then pick the cheapest seam (see
testing.md). Stub a whole method with the mock feature, or feed
canned process output through the real argv-building and parsing with a
ScriptedRunner — and assert the exact argv with a RecordingRunner.
use vcs_git::{Git, GitApi};
use processkit::{RecordingRunner, Reply, ScriptedRunner};
// 1. Code against the trait — the mock implements it too.
async fn on_main(git: &dyn GitApi) -> bool {
git.current_branch(Path::new(".")).await.unwrap() == "main"
}
// 2. Feed canned output through the real command wiring (no binary, no repo).
let git = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
assert!(on_main(&git).await);
// 3. Record to assert the exact argv that was built.
let rec = RecordingRunner::replying(Reply::ok(""));
let git = Git::with_runner(&rec);
git.create_branch(Path::new("/repo"), "feature").await?;
assert_eq!(rec.only_call().args_str(), ["branch", "feature"]);Notes: the mock feature (MockGitApi / MockJjApi / MockGitHubApi) lives in
[dev-dependencies] only — it never ships in release builds. To test the
vcs-core facade’s dispatch, build a Repo over a fake runner with
Repo::from_git("/repo", "/repo", Git::with_runner(runner)) / Repo::from_jj(…).
§Drop to a raw command
Every client carries an escape hatch for an unmodelled command. run returns
trimmed stdout and errors on a non-zero exit; run_raw never errors on exit — it
hands back the captured ProcessResult so you read the code yourself.
let described = git.run(&["describe".into(), "--tags".into()]).await?; // String
let res = git.run_raw(&["rev-parse".into(), "HEAD".into()]).await?; // ProcessResult<String>
println!("exited {:?}", res.code());
let _ = described;
// Inherent `run_args` / `run_raw_args` take &[&str] — no Vec<String> allocation:
let short = git.run_args(&["rev-parse", "--short", "HEAD"]).await?; // String
let _ = short;Notes: run / run_raw are the same on Jj and GitHub (gh.run(&["api", "user".into()]), etc.). These are not flag-guarded — you own the argv, so a
caller-supplied value with a leading - is passed through verbatim. Through the
facade, reach them via repo.git()?.run(…) / repo.jj()?.run(…).
§See also
- vcs-git guide — the full git surface.
- vcs-jj guide — the full jj surface, including the operation log.
- vcs-github guide — the full
ghsurface. - vcs-core guide — the backend-agnostic facade and DTOs.
- Conflict resolution — the
vcs_git::conflict/vcs_jj::conflictmarker models in depth. - Testing & mocking — the three test seams and the dry-run harness.