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 asyncprocesskitclient 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 methodunwraps/asserts internally. - Needs real binaries. Helpers run the real
git/jjonPATH. Gate any test that touches them behind#[ignore = "requires the git binary"]so a hermetic CI (no binaries installed) stays green; run them locally withcargo 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-effortremove_dir_allon 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(&[..])— rungit <args>in the repo, panicking on failure (the escape hatch for anything the convenience methods don’t cover).write(path, content)— writecontentto the repo-relativepath, 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 ownedString, 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(&[..])— runjj <args>in the workspace, panicking on failure.write(path, content)— write to the workspace-relativepath, 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, &[..])— rungit <args>indir, panicking on failure.jj(dir, &[..])— the same forjj.configure_identity(dir)— give a git repo atdira 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 intoGitSandbox::init— for tests whose subject is repository initialisation itself: they run their owninitand 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