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}