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