Skip to main content

vcs_testkit/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-testkit` — throwaway git/jj sandboxes (and a bare remote) for
4//! integration tests.
5//!
6//! Hands a `#[test]` a real repository to drive: a unique self-cleaning
7//! [`TempDir`], a configured [`GitSandbox`] / [`JjSandbox`] to build scenarios
8//! in, and a seeded [`BareRemote`] to clone/fetch/push against. It is
9//! **dependency-free** (not even the wrapper crates, so it can be a
10//! dev-dependency of any of them without a cycle), **synchronous** (test setup
11//! needs no runtime — it shells out with `std::process::Command`, not the async
12//! client under test), and **panics on failure** (a broken fixture should fail
13//! loudly at the call site, not thread `Result`s through scenario code).
14//!
15//! Built for `#[test]` / `#[ignore]` integration tests that need a *real* repo:
16//! the helpers run the actual `git` / `jj` on `PATH`, so gate any test that
17//! touches one behind `#[ignore = "requires the git binary"]` — a hermetic CI
18//! with no binaries installed then stays green, and `cargo test -- --ignored`
19//! runs them locally. Every sandbox is isolated from the host's VCS config (no
20//! system/global config, no `init.templateDir` hook leakage, a deterministic
21//! identity even on the commit `jj git init` creates) — see `command`.
22//!
23//! # The surface
24//!
25//! - **[`TempDir`]** — a unique temporary directory, removed on drop.
26//!   Uniqueness without a temp-dir crate: pid + a process-wide monotonic
27//!   counter, so parallel tests in a run never collide. Every fixture owns one.
28//! - **[`GitSandbox`]** — a throwaway **git** repo on branch `main` with a
29//!   deterministic identity. Build scenarios through the convenience steps
30//!   ([`commit_file`](GitSandbox::commit_file), [`branch`](GitSandbox::branch),
31//!   [`checkout`](GitSandbox::checkout), [`rev_parse`](GitSandbox::rev_parse))
32//!   plus the raw [`git`](GitSandbox::git) escape hatch for anything unmodelled.
33//! - **[`JjSandbox`]** — the same shape for a **jj** (git-backed) workspace,
34//!   including [`colocated`](JjSandbox::colocated) workspaces with both `.jj`
35//!   and `.git`: [`describe`](JjSandbox::describe),
36//!   [`new_change`](JjSandbox::new_change), [`bookmark`](JjSandbox::bookmark),
37//!   and the raw [`jj`](JjSandbox::jj) hatch.
38//! - **[`BareRemote`]** — a populated **bare** git repo, a local
39//!   clone/fetch/push source with no network. [`BareRemote::seeded`] gives one
40//!   commit on `main` containing `seed.txt`; [`url`](BareRemote::url) yields a
41//!   string remote URL.
42//! - **[`configure_identity`]** — stamp a git repo with a deterministic
43//!   identity and byte-stable behaviour (`user.*`, `commit.gpgsign=false`,
44//!   `core.autocrlf=false`). Standalone, for tests whose *subject* is `init`.
45//! - **Raw steps [`git`] / [`jj`]** — run one command in any `dir`, panicking
46//!   on failure: for scenario steps in directories no sandbox owns (linked
47//!   worktrees, fresh clones, repos the code under test initialised).
48//!
49//! # Recipes
50//!
51//! These are sync — no async wrapper, no `Result` (fixtures panic). They are
52//! `no_run`: they really create temp dirs and shell out to `git`/`jj`, so they
53//! compile here but only run under a binary-equipped `#[test]`.
54//!
55//! Build a git scenario — write + stage + commit is one step:
56//!
57//! ```no_run
58//! use vcs_testkit::GitSandbox;
59//! # fn demo() {
60//! let repo = GitSandbox::init("scenario");
61//! repo.commit_file("a.txt", "one\n", "first");   // write + add -A + commit
62//! repo.branch("feature");
63//! repo.checkout("feature");
64//! repo.commit_file("sub/b.txt", "two\n", "second");
65//!
66//! let head = repo.rev_parse("HEAD");
67//! assert_eq!(head.len(), 40);
68//! assert_ne!(head, repo.rev_parse("main"));       // feature has diverged
69//! # }
70//! ```
71//!
72//! Seed a bare remote and fetch from it — drop to raw `git` for the remote wiring:
73//!
74//! ```no_run
75//! use vcs_testkit::{BareRemote, GitSandbox};
76//! # fn demo() {
77//! let repo = GitSandbox::init("local");
78//! repo.commit_file("a.txt", "one\n", "first");
79//!
80//! let remote = BareRemote::seeded("origin");
81//! repo.git(&["remote", "add", "origin", remote.url().as_str()]);
82//! repo.git(&["fetch", "-q", "origin"]);
83//! assert_eq!(repo.rev_parse("origin/main").len(), 40); // seed commit fetched
84//! # }
85//! ```
86//!
87//! # In-depth guide
88//!
89//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
90//! from `docs/`. See the [`guide`] module (and its cross-cutting
91//! [`testing`](crate::guide::testing) sub-guide on the trait / mock / runner
92//! seams that let most tests skip real binaries entirely).
93
94use std::path::{Path, PathBuf};
95use std::process::Command;
96use std::sync::atomic::{AtomicU64, Ordering};
97
98static COUNTER: AtomicU64 = AtomicU64::new(0);
99
100/// A unique temporary directory, removed on drop.
101///
102/// Unique without a temp-dir crate: process id + a process-wide monotonic
103/// counter, so parallel tests within a run never collide. The name is kept
104/// deliberately short — jj's `op_store` paths are deep, and a long prefix here
105/// can tip a nested `.jj/repo/op_store/operations/<id>` path over Windows'
106/// `MAX_PATH` (260) limit.
107pub struct TempDir(PathBuf);
108
109impl TempDir {
110    /// Create `%TEMP%/vcs-testkit-<tag>-<pid>-<n>`. Panics when the directory
111    /// cannot be created.
112    pub fn new(tag: &str) -> Self {
113        let path = std::env::temp_dir().join(format!(
114            "vcs-testkit-{tag}-{}-{}",
115            std::process::id(),
116            COUNTER.fetch_add(1, Ordering::Relaxed)
117        ));
118        std::fs::create_dir_all(&path).expect("create temp dir");
119        TempDir(path)
120    }
121
122    /// The directory's path.
123    pub fn path(&self) -> &Path {
124        &self.0
125    }
126}
127
128impl Drop for TempDir {
129    fn drop(&mut self) {
130        // Best-effort: a leaked temp dir must not fail the test run.
131        let _ = std::fs::remove_dir_all(&self.0);
132    }
133}
134
135/// Build an isolated [`Command`] for `binary` in `cwd`.
136///
137/// **Every** git/jj invocation the testkit makes routes through here so the
138/// sandbox is hermetic — it must not inherit the host user's VCS config. A
139/// host-global `init.templateDir` / `core.hooksPath` (git) or `[user]` block
140/// (jj) would otherwise leak in: a templateDir hook gets copied into the
141/// sandbox's `.git/hooks` and *executes* during sandbox commits, and a host
142/// jj identity stamps the init-created working-copy commit.
143///
144/// The redirect-config env vars point at a guaranteed-nonexistent path; git
145/// and jj both treat a missing config file as empty, so no temp file is
146/// needed and the free [`git`]/[`jj`] helpers (which own no sandbox dir) get
147/// the same isolation as the sandbox methods.
148fn command(binary: &str, cwd: &Path) -> Command {
149    // A path that cannot exist: a child of *this* binary's own path (a file,
150    // so it can have no children). Resolved per call to stay self-contained.
151    let nonexistent = std::env::current_exe()
152        .unwrap_or_else(|_| PathBuf::from("vcs-testkit-no-such"))
153        .join("vcs-testkit-nonexistent-config");
154    let mut cmd = Command::new(binary);
155    cmd.current_dir(cwd);
156    match binary {
157        "git" => {
158            // Ignore system config; redirect global/system config at a
159            // nonexistent file (defeats a host-set GIT_CONFIG_GLOBAL too);
160            // and never block on a credential prompt. Scrub any inherited
161            // GIT_DIR-style vars that would otherwise point git elsewhere.
162            cmd.env("GIT_CONFIG_NOSYSTEM", "1")
163                .env("GIT_CONFIG_GLOBAL", &nonexistent)
164                .env("GIT_CONFIG_SYSTEM", &nonexistent)
165                .env("GIT_TERMINAL_PROMPT", "0")
166                .env_remove("GIT_CONFIG_PARAMETERS")
167                .env_remove("GIT_CONFIG")
168                .env_remove("GIT_DIR")
169                .env_remove("GIT_COMMON_DIR")
170                .env_remove("GIT_WORK_TREE")
171                .env_remove("GIT_INDEX_FILE")
172                .env_remove("GIT_OBJECT_DIRECTORY")
173                .env_remove("GIT_NAMESPACE");
174        }
175        "jj" => {
176            // Read config exclusively from a nonexistent file (no host
177            // config), and stamp a deterministic identity on *every* commit
178            // — including the working-copy commit `jj git init` creates,
179            // which a later `config set --repo user.*` cannot retroactively
180            // re-author.
181            cmd.env("JJ_CONFIG", &nonexistent)
182                .env("JJ_USER", "test")
183                .env("JJ_EMAIL", "test@example.com");
184            // jj 0.42+ stores secure repo-scoped configuration below the
185            // platform config directory, even when `JJ_CONFIG` bypasses user
186            // config files. Keep that store out of the host profile as well.
187            #[cfg(windows)]
188            cmd.env(
189                "APPDATA",
190                std::env::temp_dir().join("vcs-testkit-jj-config"),
191            );
192        }
193        _ => {}
194    }
195    cmd
196}
197
198/// Run a binary in `cwd`, panicking (with the command line in the message) on
199/// a spawn failure or non-zero exit. The fixture contract: fail loudly.
200fn run(binary: &str, cwd: &Path, args: &[&str]) {
201    let status = command(binary, cwd)
202        .args(args)
203        .status()
204        .unwrap_or_else(|e| panic!("failed to run `{binary} {args:?}`: {e}"));
205    assert!(status.success(), "`{binary} {args:?}` exited with {status}");
206}
207
208/// Like [`run`] but capturing trimmed stdout.
209fn run_capture(binary: &str, cwd: &Path, args: &[&str]) -> String {
210    let out = command(binary, cwd)
211        .args(args)
212        .output()
213        .unwrap_or_else(|e| panic!("failed to run `{binary} {args:?}`: {e}"));
214    assert!(
215        out.status.success(),
216        "`{binary} {args:?}` exited with {}: {}",
217        out.status,
218        String::from_utf8_lossy(&out.stderr)
219    );
220    String::from_utf8_lossy(&out.stdout).trim_end().to_string()
221}
222
223/// Run `git <args>` in `dir`, panicking on failure — for scenario steps in
224/// directories not owned by a [`GitSandbox`] (linked worktrees, fresh clones,
225/// repos initialised by the code under test).
226pub fn git(dir: &Path, args: &[&str]) {
227    run("git", dir, args);
228}
229
230/// Run `jj <args>` in `dir`, panicking on failure (see [`git`]).
231pub fn jj(dir: &Path, args: &[&str]) {
232    run("jj", dir, args);
233}
234
235/// A file-name whose bytes are **not** valid UTF-8 — for exercising lossless path
236/// handling on Unix, where a filename may be an arbitrary byte sequence (only `/`
237/// and NUL are forbidden). The returned [`OsString`](std::ffi::OsString) can be
238/// joined onto a directory ([`Path::join`]) and written with [`std::fs::write`];
239/// the toolkit's `status`/`diff`/`conflict` paths must carry these exact bytes
240/// back (not a `U+FFFD`-substituted `String`) for a `status → add/commit_paths`
241/// round trip.
242///
243/// **Unix only.** A Windows filename is UTF-16 (an *unpaired surrogate*, not a raw
244/// invalid-UTF-8 byte, is its analogue), so `git`/`jj` never emit raw invalid-UTF-8
245/// path bytes there; gate any test that uses this on `#[cfg(unix)]`.
246#[cfg(unix)]
247pub fn non_utf8_filename() -> std::ffi::OsString {
248    use std::os::unix::ffi::OsStringExt;
249    // `0xFF` is never a valid UTF-8 byte; the ASCII tail keeps the name a plausible,
250    // eyeball-able file in failure output.
251    std::ffi::OsString::from_vec(b"caf\xff\xfe.txt".to_vec())
252}
253
254/// Give the git repository at `dir` a deterministic identity and byte-stable
255/// behaviour: `user.name`/`user.email`, `commit.gpgsign=false` (no keychain
256/// prompts), and `core.autocrlf=false` (no CRLF rewriting under content
257/// assertions on Windows).
258///
259/// Deliberately does NOT touch `core.hooksPath`: host-config hook leakage is
260/// neutralised at the source instead — `command`'s env redirect keeps a host
261/// global/system config (a `core.hooksPath` or `init.templateDir`) out of
262/// every testkit-run git, and `--template=` on `init` keeps template hooks
263/// from being copied into `.git/hooks`. Disabling hooks in the repo's *local*
264/// config would also disable hooks a test itself installs on purpose (e.g.
265/// the hardened-profile suppression test).
266///
267/// Standalone (not folded into [`GitSandbox::init`] only) for tests whose
268/// *subject* is repository initialisation itself — they run their own `init`
269/// and only need the identity applied afterwards.
270pub fn configure_identity(dir: &Path) {
271    for (key, val) in [
272        ("user.name", "Test"),
273        ("user.email", "test@example.com"),
274        ("commit.gpgsign", "false"),
275        ("core.autocrlf", "false"),
276    ] {
277        run("git", dir, &["config", key, val]);
278    }
279}
280
281/// A throwaway **git** repository: owns its [`TempDir`], initialised on
282/// branch `main` with a deterministic identity (see [`configure_identity`]).
283///
284/// Scenario-building goes through the raw [`git`](GitSandbox::git) escape
285/// hatch plus the convenience methods — the sandbox deliberately does not
286/// depend on the typed wrapper crates, so it can be a dev-dependency of any
287/// of them.
288pub struct GitSandbox {
289    dir: TempDir,
290}
291
292impl GitSandbox {
293    /// Create and initialise a repository (`git init -b main` — git ≥ 2.28,
294    /// comfortably below the wrappers' documented floor).
295    ///
296    /// `--template=` (empty) makes the new repo skip *any* init template,
297    /// so a host-global `init.templateDir` cannot seed hooks into
298    /// `.git/hooks` — the version-portable complement to the config
299    /// isolation in `command`.
300    pub fn init(tag: &str) -> Self {
301        let dir = TempDir::new(tag);
302        run(
303            "git",
304            dir.path(),
305            &["init", "-q", "-b", "main", "--template="],
306        );
307        configure_identity(dir.path());
308        GitSandbox { dir }
309    }
310
311    /// The repository's working-tree path.
312    pub fn path(&self) -> &Path {
313        self.dir.path()
314    }
315
316    /// Run `git <args>` in the repository, panicking on failure.
317    pub fn git(&self, args: &[&str]) {
318        run("git", self.path(), args);
319    }
320
321    /// Write `content` to the repo-relative `path` (creating parent dirs).
322    pub fn write(&self, path: &str, content: &str) {
323        let full = self.path().join(path);
324        if let Some(parent) = full.parent() {
325            std::fs::create_dir_all(parent).expect("create parent dirs");
326        }
327        std::fs::write(full, content).expect("write file");
328    }
329
330    /// Stage everything (`git add -A`).
331    pub fn add_all(&self) {
332        self.git(&["add", "-A"]);
333    }
334
335    /// Commit the staged changes (`git commit -qm <message>`).
336    pub fn commit(&self, message: &str) {
337        self.git(&["commit", "-qm", message]);
338    }
339
340    /// Write + stage + commit one file — the everyday scenario step.
341    pub fn commit_file(&self, path: &str, content: &str, message: &str) {
342        self.write(path, content);
343        self.add_all();
344        self.commit(message);
345    }
346
347    /// Create a branch at HEAD without switching (`git branch <name>`).
348    pub fn branch(&self, name: &str) {
349        self.git(&["branch", "-q", name]);
350    }
351
352    /// Switch to a branch (`git checkout <name>`).
353    pub fn checkout(&self, name: &str) {
354        self.git(&["checkout", "-q", name]);
355    }
356
357    /// Resolve a revision to a full hash (`git rev-parse <rev>`).
358    pub fn rev_parse(&self, rev: &str) -> String {
359        run_capture("git", self.path(), &["rev-parse", rev])
360    }
361}
362
363/// A populated **bare** git repository — a local clone/fetch/push source for
364/// integration tests (no network). Seeded with one commit on `main`
365/// containing `seed.txt`.
366pub struct BareRemote {
367    dir: TempDir,
368    bare: PathBuf,
369}
370
371impl BareRemote {
372    /// Build the seeded bare repository.
373    pub fn seeded(tag: &str) -> Self {
374        let dir = TempDir::new(tag);
375        let work = dir.path().join("seed-work");
376        let bare = dir.path().join("remote.git");
377        std::fs::create_dir_all(&work).expect("create work dir");
378        std::fs::create_dir_all(&bare).expect("create bare dir");
379        run("git", &work, &["init", "-q", "-b", "main", "--template="]);
380        configure_identity(&work);
381        std::fs::write(work.join("seed.txt"), "seed\n").expect("write seed");
382        run("git", &work, &["add", "-A"]);
383        run("git", &work, &["commit", "-qm", "seed"]);
384        run(
385            "git",
386            &bare,
387            &["init", "-q", "--bare", "-b", "main", "--template="],
388        );
389        run(
390            "git",
391            &work,
392            &["push", "-q", bare.to_str().expect("utf8 path"), "main:main"],
393        );
394        BareRemote { dir, bare }
395    }
396
397    /// The bare repository's path (use as a local remote URL).
398    pub fn path(&self) -> &Path {
399        &self.bare
400    }
401
402    /// The path as a `String` — convenient for argv slices.
403    pub fn url(&self) -> String {
404        self.bare.to_str().expect("utf8 path").to_string()
405    }
406
407    /// The owning temp dir (kept alive as long as the remote is used).
408    pub fn temp_dir(&self) -> &Path {
409        self.dir.path()
410    }
411}
412
413/// A throwaway **jj** repository (git-backed) with a repo-scoped identity.
414pub struct JjSandbox {
415    dir: TempDir,
416}
417
418impl JjSandbox {
419    /// Create and initialise the repository (`jj git init` + repo-scoped
420    /// `user.name`/`user.email`).
421    ///
422    /// The identity is supplied to *every* jj invocation as `JJ_USER` /
423    /// `JJ_EMAIL` env (see `command`), so the working-copy commit that
424    /// `jj git init` creates is authored deterministically — a later
425    /// `config set --repo user.*` only affects *future* commits and so cannot
426    /// fix the init commit on its own. The repo-scoped config is kept anyway
427    /// as belt-and-braces for any tool path that reads config over the env.
428    pub fn init(tag: &str) -> Self {
429        let dir = TempDir::new(tag);
430        run("jj", dir.path(), &["git", "init"]);
431        configure_jj_identity(dir.path());
432        JjSandbox { dir }
433    }
434
435    /// Create a colocated jj/git workspace (`jj git init --colocate`) with
436    /// deterministic jj and git identities.
437    ///
438    /// The `--colocate` flag is deliberately explicit: jj's default varies by
439    /// version and can be changed by `git.colocate` config. The resulting
440    /// workspace has both `.jj` and `.git` directories. jj receives the same
441    /// deterministic `JJ_USER` / `JJ_EMAIL` environment as [`Self::init`], its
442    /// repo-scoped `user.*` config is set, and [`configure_identity`] configures
443    /// the colocated git repository for direct git scenario steps.
444    pub fn colocated(tag: &str) -> Self {
445        let dir = TempDir::new(tag);
446        run("jj", dir.path(), &["git", "init", "--colocate"]);
447        configure_jj_identity(dir.path());
448        configure_identity(dir.path());
449        JjSandbox { dir }
450    }
451
452    /// The workspace root path.
453    pub fn path(&self) -> &Path {
454        self.dir.path()
455    }
456
457    /// Run `jj <args>` in the workspace, panicking on failure.
458    pub fn jj(&self, args: &[&str]) {
459        run("jj", self.path(), args);
460    }
461
462    /// Run `jj <args>` in the workspace and capture trimmed stdout (panics on
463    /// failure) — for reading state in assertions (op ids, the `@` commit id).
464    /// Uses the same config-isolated environment as [`jj`](JjSandbox::jj).
465    pub fn jj_capture(&self, args: &[&str]) -> String {
466        run_capture("jj", self.path(), args)
467    }
468
469    /// The current operation id (`jj op log … --ignore-working-copy`). Capture it
470    /// before and after a series of *read-only* queries to assert none recorded a
471    /// new operation (a mutating `jj` snapshot would advance it).
472    ///
473    /// **Read-only measurement:** `--ignore-working-copy` is essential here — a
474    /// plain `jj op log` would itself snapshot any pending working-tree edit and
475    /// record an operation, so the measurement would perturb the very thing it is
476    /// asserting about.
477    pub fn op_head(&self) -> String {
478        self.jj_capture(&[
479            "op",
480            "log",
481            "--no-graph",
482            "-n1",
483            "-T",
484            "id.short()",
485            "--ignore-working-copy",
486        ])
487    }
488
489    /// The working-copy commit id of `@` (`jj log -r @ … --ignore-working-copy`) —
490    /// to assert a read-only query did not move `@` (jj rewrites `@` when it
491    /// snapshots an unsnapshotted working-tree edit). Read-only for the same reason
492    /// as [`op_head`](JjSandbox::op_head).
493    pub fn at_commit(&self) -> String {
494        self.jj_capture(&[
495            "log",
496            "-r",
497            "@",
498            "--no-graph",
499            "-T",
500            "commit_id",
501            "--ignore-working-copy",
502        ])
503    }
504
505    /// Write `content` to the workspace-relative `path` (creating parents).
506    pub fn write(&self, path: &str, content: &str) {
507        let full = self.path().join(path);
508        if let Some(parent) = full.parent() {
509            std::fs::create_dir_all(parent).expect("create parent dirs");
510        }
511        std::fs::write(full, content).expect("write file");
512    }
513
514    /// Describe the working-copy change (`jj describe -m <message>`).
515    pub fn describe(&self, message: &str) {
516        self.jj(&["describe", "-m", message]);
517    }
518
519    /// Start a new change on top (`jj new -m <message>`).
520    pub fn new_change(&self, message: &str) {
521        self.jj(&["new", "-m", message]);
522    }
523
524    /// Create a bookmark at `@` (`jj bookmark create <name> -r @`).
525    pub fn bookmark(&self, name: &str) {
526        self.jj(&["bookmark", "create", name, "-r", "@"]);
527    }
528}
529
530/// Set deterministic, repo-scoped jj identity after `jj git init`.
531///
532/// [`command`] still supplies `JJ_USER` / `JJ_EMAIL` to the init command, so
533/// its already-created working-copy commit is deterministic. This config is
534/// retained for code paths that read identity from the repository instead.
535fn configure_jj_identity(dir: &Path) {
536    run("jj", dir, &["config", "set", "--repo", "user.name", "Test"]);
537    run(
538        "jj",
539        dir,
540        &["config", "set", "--repo", "user.email", "test@example.com"],
541    );
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    fn jj_config_value_without_identity(dir: &Path, key: &str) -> String {
549        let out = command("jj", dir)
550            .env_remove("JJ_USER")
551            .env_remove("JJ_EMAIL")
552            .args(["config", "get", key])
553            .output()
554            .expect("run jj config get");
555        assert!(
556            out.status.success(),
557            "`jj config get {key}` exited with {}: {}",
558            out.status,
559            String::from_utf8_lossy(&out.stderr)
560        );
561        String::from_utf8_lossy(&out.stdout).trim_end().to_string()
562    }
563
564    // Hermetic: uniqueness and cleanup need no binaries.
565    #[test]
566    fn temp_dirs_are_unique_and_removed_on_drop() {
567        let a = TempDir::new("unique");
568        let b = TempDir::new("unique");
569        assert_ne!(a.path(), b.path());
570        assert!(a.path().exists() && b.path().exists());
571        let kept = a.path().to_path_buf();
572        drop(a);
573        assert!(!kept.exists(), "removed on drop");
574    }
575
576    // Real-binary round-trips; ignored so hermetic CI stays green.
577    #[test]
578    #[ignore = "requires the git binary"]
579    fn git_sandbox_builds_scenarios() {
580        let repo = GitSandbox::init("sandbox");
581        repo.commit_file("a.txt", "one\n", "first");
582        repo.branch("feature");
583        repo.checkout("feature");
584        repo.commit_file("sub/b.txt", "two\n", "second");
585        let head = repo.rev_parse("HEAD");
586        assert_eq!(head.len(), 40);
587        assert_ne!(head, repo.rev_parse("main"));
588
589        let remote = BareRemote::seeded("remote");
590        repo.git(&["remote", "add", "origin", remote.url().as_str()]);
591        repo.git(&["fetch", "-q", "origin"]);
592        assert_eq!(
593            run_capture("git", repo.path(), &["show", "origin/main:seed.txt"]),
594            "seed"
595        );
596    }
597
598    // Isolation: `--template=` plus the config env keep a host-global
599    // `init.templateDir` from seeding hooks, so the sandbox's `.git/hooks`
600    // holds no live hook. (A real host hook firing is what the reviewer hit;
601    // here we assert the precondition — no enabled hook files — which holds
602    // regardless of the host's config.)
603    #[test]
604    #[ignore = "requires the git binary"]
605    fn git_sandbox_has_no_leaked_hooks() {
606        let repo = GitSandbox::init("hooks");
607        repo.commit_file("a.txt", "one\n", "first");
608        let hooks = repo.path().join(".git").join("hooks");
609        let enabled: Vec<_> = std::fs::read_dir(&hooks)
610            .into_iter()
611            .flatten()
612            .flatten()
613            .map(|e| e.file_name().to_string_lossy().into_owned())
614            // git ships `*.sample` hooks (inert); only non-sample files run.
615            .filter(|name| !name.ends_with(".sample"))
616            .collect();
617        assert!(
618            enabled.is_empty(),
619            "sandbox should have no live hooks, found {enabled:?}"
620        );
621        // Note `core.hooksPath` is deliberately NOT pinned in the local config —
622        // a test may install its own hook on purpose (see `configure_identity`);
623        // the isolation lives in `command`'s env + `--template=` instead.
624    }
625
626    #[test]
627    #[ignore = "requires the jj binary"]
628    fn jj_sandbox_builds_scenarios() {
629        let repo = JjSandbox::init("sandbox");
630        repo.write("a.txt", "one\n");
631        repo.describe("base");
632        repo.bookmark("mark");
633        repo.new_change("next");
634        // The described change and the bookmark are visible to jj.
635        let out = run_capture(
636            "jj",
637            repo.path(),
638            &[
639                "log",
640                "-r",
641                "::@",
642                "--no-graph",
643                "-T",
644                "description.first_line() ++ \"\\n\"",
645                "--color",
646                "never",
647            ],
648        );
649        assert!(out.contains("base"), "got {out:?}");
650    }
651
652    // Isolation: the working-copy commit `jj git init` creates is authored
653    // deterministically from the `JJ_USER`/`JJ_EMAIL` env, *not* from the
654    // host's jj config (which `config set --repo` could not retroactively
655    // re-author). `root()+` is the first non-root commit — the init commit.
656    #[test]
657    #[ignore = "requires the jj binary"]
658    fn jj_sandbox_init_commit_has_deterministic_author() {
659        let repo = JjSandbox::init("identity");
660        let email = run_capture(
661            "jj",
662            repo.path(),
663            &[
664                "log",
665                "-r",
666                "root()+",
667                "--no-graph",
668                "-T",
669                "author.email()",
670                "--color",
671                "never",
672            ],
673        );
674        assert_eq!(email, "test@example.com", "init commit author.email");
675    }
676
677    #[test]
678    #[ignore = "requires the jj binary"]
679    fn colocated_sandbox_has_state_dirs_and_deterministic_identity() {
680        let repo = JjSandbox::colocated("colocated");
681        assert!(repo.path().join(".jj").is_dir(), "jj state directory");
682        assert!(repo.path().join(".git").is_dir(), "git state directory");
683
684        let init_author_email = run_capture(
685            "jj",
686            repo.path(),
687            &[
688                "log",
689                "-r",
690                "root()+",
691                "--no-graph",
692                "-T",
693                "author.email()",
694                "--color",
695                "never",
696            ],
697        );
698        assert_eq!(
699            init_author_email, "test@example.com",
700            "JJ_EMAIL authors the colocated init commit"
701        );
702        assert_eq!(
703            jj_config_value_without_identity(repo.path(), "user.name"),
704            "Test",
705            "repo-scoped jj user.name"
706        );
707        assert_eq!(
708            jj_config_value_without_identity(repo.path(), "user.email"),
709            "test@example.com",
710            "repo-scoped jj user.email"
711        );
712        assert_eq!(
713            run_capture("git", repo.path(), &["config", "--get", "user.name"]),
714            "Test",
715            "colocated git user.name"
716        );
717        assert_eq!(
718            run_capture("git", repo.path(), &["config", "--get", "user.email"]),
719            "test@example.com",
720            "colocated git user.email"
721        );
722    }
723}
724
725// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
726#[doc = include_str!("../docs/testkit.md")]
727#[allow(rustdoc::broken_intra_doc_links)]
728pub mod guide {
729    #[doc = include_str!("../docs/testing.md")]
730    #[allow(rustdoc::broken_intra_doc_links)]
731    pub mod testing {}
732}