Skip to main content

Module guide

Module guide 

Source
Expand description

§vcs-testkit — test fixtures guide

Throwaway repositories for integration tests. vcs-testkit gives you a self-cleaning TempDir, a configured GitSandbox / JjSandbox to build scenarios in, and a seeded BareRemote to clone/fetch/push against — the same fixtures this workspace’s own ignored tests run on.

Three properties shape every helper, and they are deliberate:

  • Synchronous. Test setup needs no runtime — fixtures shell out with std::process::Command, not the async processkit client under test, so they stay usable from any #[test] regardless of how the subject is wired.
  • Panics on failure. A fixture is not the thing under test; a broken fixture should fail the test loudly at the call site, not thread Results through scenario-building code. Every method unwraps/asserts internally.
  • Needs real binaries. Helpers run the real git / jj on PATH. Gate any test that touches them behind #[ignore = "requires the git binary"] so a hermetic CI (no binaries installed) stays green; run them locally with cargo test -- --ignored.

vcs-testkit depends on nothing — not even the wrapper crates — so it can be a dev-dependency of any of them without a coupling cycle. Scenario-building goes through each sandbox’s raw escape hatch (git/jj) plus a few convenience methods.

# Cargo.toml — a path dev-dependency, stripped on publish.
[dev-dependencies]
vcs-testkit = { path = "../testkit" }

§TempDir

A unique temporary directory, removed on drop. Uniqueness without a temp-dir crate: process id + a process-wide monotonic counter, so parallel tests never collide.

  • TempDir::new(tag) — create %TEMP%/vcs-testkit-<tag>-<pid>-<n>. Panics when the directory cannot be created.
  • path() — the directory’s path (&Path).
  • Drop — best-effort remove_dir_all on the way out; a leaked temp dir must not fail the run, so the cleanup error is swallowed.
use vcs_testkit::TempDir;

let tmp = TempDir::new("scratch");
std::fs::write(tmp.path().join("note.txt"), "hi").unwrap();
let kept = tmp.path().to_path_buf();
drop(tmp);                       // directory tree removed here
assert!(!kept.exists());

§GitSandbox

A throwaway git repository: owns its TempDir, initialised on branch main (git init -b main) with a deterministic identity (see configure_identity).

  • GitSandbox::init(tag) — create and initialise the repository.
  • path() — the working-tree path (&Path).
  • git(&[..]) — run git <args> in the repo, panicking on failure (the escape hatch for anything the convenience methods don’t cover).
  • write(path, content) — write content to the repo-relative path, creating parent dirs.
  • add_all() — stage everything (git add -A).
  • commit(msg) — commit the staged changes (git commit -qm <msg>).
  • commit_file(path, content, msg) — write + stage + commit one file, the everyday scenario step.
  • branch(name) — create a branch at HEAD without switching (git branch).
  • checkout(name) — switch to a branch (git checkout).
  • rev_parse(rev) — resolve a revision to its full 40-char hash (String).
use vcs_testkit::GitSandbox;

let repo = GitSandbox::init("scenario");
repo.commit_file("a.txt", "one\n", "first");   // write + add -A + commit
repo.branch("feature");
repo.checkout("feature");
repo.commit_file("sub/b.txt", "two\n", "second");

let head = repo.rev_parse("HEAD");
assert_eq!(head.len(), 40);
assert_ne!(head, repo.rev_parse("main"));       // feature has diverged

// Drop to raw git for anything not modelled:
repo.git(&["tag", "v1"]);

§BareRemote

A populated bare git repository — a local clone/fetch/push source for integration tests, no network. Seeded with one commit on main containing seed.txt.

  • BareRemote::seeded(tag) — build the seeded bare repository.
  • path() — the bare repo’s path (&Path); use it as a local remote URL.
  • url() — the path as an owned String, convenient for argv slices.
  • temp_dir() — the owning temp dir (&Path), kept alive as long as the remote is in use.
use vcs_testkit::{BareRemote, GitSandbox};

let remote = BareRemote::seeded("origin");
let repo = GitSandbox::init("clone-target");
repo.git(&["remote", "add", "origin", remote.url().as_str()]);
repo.git(&["fetch", "-q", "origin"]);
// `seed.txt` from the seed commit is now reachable as origin/main.

§JjSandbox

A throwaway jj repository (git-backed) with a repo-scoped identity (jj git init + user.name/user.email set --repo).

  • JjSandbox::init(tag) — create and initialise the workspace.
  • path() — the workspace root path (&Path).
  • jj(&[..]) — run jj <args> in the workspace, panicking on failure.
  • write(path, content) — write to the workspace-relative path, creating parents.
  • describe(msg) — describe the working-copy change (jj describe -m <msg>).
  • new_change(msg) — start a new change on top (jj new -m <msg>).
  • bookmark(name) — create a bookmark at @ (jj bookmark create <name> -r @).
use vcs_testkit::JjSandbox;

let repo = JjSandbox::init("jj-scenario");
repo.write("a.txt", "one\n");
repo.describe("base");          // describe the working-copy change
repo.bookmark("mark");          // bookmark at @
repo.new_change("next");        // start a fresh change on top
repo.jj(&["log", "--no-graph"]); // raw escape hatch

§Colocated jj + git workspace

Use JjSandbox::colocated(tag) -> Self when the test needs the first-class colocated layout: a jj workspace whose root has both .jj and .git, such as code that discovers jj in preference to git, watches both state directories, or runs direct git commands against the same checkout. It explicitly runs jj git init --colocate; the flag is not left to jj version defaults or git.colocate configuration. Like JjSandbox::init, it uses JJ_USER / JJ_EMAIL for the init commit and writes repo-scoped jj identity. It also calls configure_identity for the colocated git repository, so direct git calls are deterministic too.

use vcs_testkit::{git, JjSandbox};

let repo = JjSandbox::colocated("colocated-scenario");
assert!(repo.path().join(".jj").is_dir());
assert!(repo.path().join(".git").is_dir());

// The same root is ready for direct git scenario steps.
git(repo.path(), &["status", "--porcelain"]);
repo.jj(&["status"]);

§Standalone functions

For scenario steps in directories not owned by a sandbox — linked worktrees, fresh clones, or repos initialised by the code under test.

  • git(dir, &[..]) — run git <args> in dir, panicking on failure.
  • jj(dir, &[..]) — the same for jj.
  • configure_identity(dir) — give a git repo at dir a deterministic identity and byte-stable behaviour: user.name/user.email, commit.gpgsign=false (no keychain prompts), core.autocrlf=false (no CRLF rewriting under content assertions on Windows). Standalone — not folded only into GitSandbox::init — for tests whose subject is repository initialisation itself: they run their own init and only need the identity applied afterwards.
use std::path::Path;
use vcs_testkit::{configure_identity, git, TempDir};

// Test whose subject is init itself: do the init, then make it deterministic.
let tmp = TempDir::new("custom-init");
git(tmp.path(), &["init", "-q", "-b", "trunk"]);
configure_identity(tmp.path());
git(tmp.path(), &["commit", "--allow-empty", "-qm", "root"]);

§Worked end-to-end scenario

Sandbox + bare remote, exercising a push then a fetch round-trip — the shape of the crate’s own ignored integration test.

use vcs_testkit::{BareRemote, GitSandbox};

#[test]
#[ignore = "requires the git binary"]   // gated — hermetic CI has no git
fn push_then_fetch_round_trip() {
    // Local repo with two commits on a feature branch.
    let repo = GitSandbox::init("local");
    repo.commit_file("a.txt", "one\n", "first");
    repo.branch("feature");
    repo.checkout("feature");
    repo.commit_file("b.txt", "two\n", "second");

    // A seeded bare repo to push at.
    let remote = BareRemote::seeded("remote");
    repo.git(&["remote", "add", "origin", remote.url().as_str()]);
    repo.git(&["push", "-q", "origin", "feature"]);

    // Fetch brings the seeded main back; the pushed feature is on the remote.
    repo.git(&["fetch", "-q", "origin"]);
    let remote_main = repo.rev_parse("origin/main");
    assert_eq!(remote_main.len(), 40);
    // Both fixtures' temp dirs clean themselves up when they drop here.
}

See also: Testing & mocking for the trait / mock / runner seams that let most tests skip real binaries entirely, and the crate docs.

Modules§

testing
Testing & mocking guide