Skip to main content

Module cookbook

Module cookbook 

Source
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 -ucheckoutstash pop, popping back to restore the original branch if the checkout fails.

git.switch_with_stash(repo, "feature").await?;   // tracked + untracked changes come along

Notes: 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 OptionSome 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