#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"vcs-testkit-{tag}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&path).expect("create temp dir");
TempDir(path)
}
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn command(binary: &str, cwd: &Path) -> Command {
let nonexistent = std::env::current_exe()
.unwrap_or_else(|_| PathBuf::from("vcs-testkit-no-such"))
.join("vcs-testkit-nonexistent-config");
let mut cmd = Command::new(binary);
cmd.current_dir(cwd);
match binary {
"git" => {
cmd.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", &nonexistent)
.env("GIT_CONFIG_SYSTEM", &nonexistent)
.env("GIT_TERMINAL_PROMPT", "0")
.env_remove("GIT_CONFIG_PARAMETERS")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
}
"jj" => {
cmd.env("JJ_CONFIG", &nonexistent)
.env("JJ_USER", "test")
.env("JJ_EMAIL", "test@example.com");
}
_ => {}
}
cmd
}
fn run(binary: &str, cwd: &Path, args: &[&str]) {
let status = command(binary, cwd)
.args(args)
.status()
.unwrap_or_else(|e| panic!("failed to run `{binary} {args:?}`: {e}"));
assert!(status.success(), "`{binary} {args:?}` exited with {status}");
}
fn run_capture(binary: &str, cwd: &Path, args: &[&str]) -> String {
let out = command(binary, cwd)
.args(args)
.output()
.unwrap_or_else(|e| panic!("failed to run `{binary} {args:?}`: {e}"));
assert!(
out.status.success(),
"`{binary} {args:?}` exited with {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim_end().to_string()
}
pub fn git(dir: &Path, args: &[&str]) {
run("git", dir, args);
}
pub fn jj(dir: &Path, args: &[&str]) {
run("jj", dir, args);
}
pub fn configure_identity(dir: &Path) {
for (key, val) in [
("user.name", "Test"),
("user.email", "test@example.com"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
run("git", dir, &["config", key, val]);
}
}
pub struct GitSandbox {
dir: TempDir,
}
impl GitSandbox {
pub fn init(tag: &str) -> Self {
let dir = TempDir::new(tag);
run(
"git",
dir.path(),
&["init", "-q", "-b", "main", "--template="],
);
configure_identity(dir.path());
GitSandbox { dir }
}
pub fn path(&self) -> &Path {
self.dir.path()
}
pub fn git(&self, args: &[&str]) {
run("git", self.path(), args);
}
pub fn write(&self, path: &str, content: &str) {
let full = self.path().join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(full, content).expect("write file");
}
pub fn add_all(&self) {
self.git(&["add", "-A"]);
}
pub fn commit(&self, message: &str) {
self.git(&["commit", "-qm", message]);
}
pub fn commit_file(&self, path: &str, content: &str, message: &str) {
self.write(path, content);
self.add_all();
self.commit(message);
}
pub fn branch(&self, name: &str) {
self.git(&["branch", "-q", name]);
}
pub fn checkout(&self, name: &str) {
self.git(&["checkout", "-q", name]);
}
pub fn rev_parse(&self, rev: &str) -> String {
run_capture("git", self.path(), &["rev-parse", rev])
}
}
pub struct BareRemote {
dir: TempDir,
bare: PathBuf,
}
impl BareRemote {
pub fn seeded(tag: &str) -> Self {
let dir = TempDir::new(tag);
let work = dir.path().join("seed-work");
let bare = dir.path().join("remote.git");
std::fs::create_dir_all(&work).expect("create work dir");
std::fs::create_dir_all(&bare).expect("create bare dir");
run("git", &work, &["init", "-q", "-b", "main", "--template="]);
configure_identity(&work);
std::fs::write(work.join("seed.txt"), "seed\n").expect("write seed");
run("git", &work, &["add", "-A"]);
run("git", &work, &["commit", "-qm", "seed"]);
run(
"git",
&bare,
&["init", "-q", "--bare", "-b", "main", "--template="],
);
run(
"git",
&work,
&["push", "-q", bare.to_str().expect("utf8 path"), "main:main"],
);
BareRemote { dir, bare }
}
pub fn path(&self) -> &Path {
&self.bare
}
pub fn url(&self) -> String {
self.bare.to_str().expect("utf8 path").to_string()
}
pub fn temp_dir(&self) -> &Path {
self.dir.path()
}
}
pub struct JjSandbox {
dir: TempDir,
}
impl JjSandbox {
pub fn init(tag: &str) -> Self {
let dir = TempDir::new(tag);
run("jj", dir.path(), &["git", "init"]);
run(
"jj",
dir.path(),
&["config", "set", "--repo", "user.name", "Test"],
);
run(
"jj",
dir.path(),
&["config", "set", "--repo", "user.email", "test@example.com"],
);
JjSandbox { dir }
}
pub fn path(&self) -> &Path {
self.dir.path()
}
pub fn jj(&self, args: &[&str]) {
run("jj", self.path(), args);
}
pub fn write(&self, path: &str, content: &str) {
let full = self.path().join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(full, content).expect("write file");
}
pub fn describe(&self, message: &str) {
self.jj(&["describe", "-m", message]);
}
pub fn new_change(&self, message: &str) {
self.jj(&["new", "-m", message]);
}
pub fn bookmark(&self, name: &str) {
self.jj(&["bookmark", "create", name, "-r", "@"]);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn temp_dirs_are_unique_and_removed_on_drop() {
let a = TempDir::new("unique");
let b = TempDir::new("unique");
assert_ne!(a.path(), b.path());
assert!(a.path().exists() && b.path().exists());
let kept = a.path().to_path_buf();
drop(a);
assert!(!kept.exists(), "removed on drop");
}
#[test]
#[ignore = "requires the git binary"]
fn git_sandbox_builds_scenarios() {
let repo = GitSandbox::init("sandbox");
repo.commit_file("a.txt", "one\n", "first");
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"));
let remote = BareRemote::seeded("remote");
repo.git(&["remote", "add", "origin", remote.url().as_str()]);
repo.git(&["fetch", "-q", "origin"]);
assert_eq!(
run_capture("git", repo.path(), &["show", "origin/main:seed.txt"]),
"seed"
);
}
#[test]
#[ignore = "requires the git binary"]
fn git_sandbox_has_no_leaked_hooks() {
let repo = GitSandbox::init("hooks");
repo.commit_file("a.txt", "one\n", "first");
let hooks = repo.path().join(".git").join("hooks");
let enabled: Vec<_> = std::fs::read_dir(&hooks)
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| !name.ends_with(".sample"))
.collect();
assert!(
enabled.is_empty(),
"sandbox should have no live hooks, found {enabled:?}"
);
}
#[test]
#[ignore = "requires the jj binary"]
fn jj_sandbox_builds_scenarios() {
let repo = JjSandbox::init("sandbox");
repo.write("a.txt", "one\n");
repo.describe("base");
repo.bookmark("mark");
repo.new_change("next");
let out = run_capture(
"jj",
repo.path(),
&[
"log",
"-r",
"::@",
"--no-graph",
"-T",
"description.first_line() ++ \"\\n\"",
"--color",
"never",
],
);
assert!(out.contains("base"), "got {out:?}");
}
#[test]
#[ignore = "requires the jj binary"]
fn jj_sandbox_init_commit_has_deterministic_author() {
let repo = JjSandbox::init("identity");
let email = run_capture(
"jj",
repo.path(),
&[
"log",
"-r",
"root()+",
"--no-graph",
"-T",
"author.email()",
"--color",
"never",
],
);
assert_eq!(email, "test@example.com", "init commit author.email");
}
}
#[doc = include_str!("../docs/testkit.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {
#[doc = include_str!("../docs/testing.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod testing {}
}