mod common;
use common::{clean_base, outside_base};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn repository() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.canonicalize()
.expect("the repository root resolves")
}
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_headwater"))
}
fn remove_all(path: &Path) {
for _ in 0..50 {
let _ = std::fs::remove_dir_all(path);
if !path.exists() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
struct Scratch(PathBuf);
impl Scratch {
fn made(path: PathBuf) -> Scratch {
remove_all(&path);
let scratch = Scratch(path);
std::fs::create_dir_all(&scratch.0).expect("the scratch directory is made");
scratch
}
}
impl Drop for Scratch {
fn drop(&mut self) {
remove_all(&self.0);
}
}
#[test]
fn the_scratch_base_passes_over_a_candidate_inside_a_git_work_tree() {
let Some(clean) = outside_base() else {
return;
};
let outer = Scratch::made(clean.join(format!(
"headwater-cli-merge-driver-{}-base-selection",
std::process::id()
)));
std::fs::create_dir_all(outer.0.join(".git")).expect("the directory is made");
std::fs::create_dir_all(outer.0.join("tmp")).expect("the directory is made");
let inside = outer.0.join("tmp");
let refused = clean_base(std::slice::from_ref(&inside))
.expect_err("a candidate inside a work tree is no base");
assert!(
refused.contains(&outer.0.join(".git").display().to_string()),
"the refusal names the `.git` entry above the candidate: {refused}"
);
assert_eq!(
clean_base(&[inside, clean.clone()]),
Ok(clean),
"the clean candidate after it is taken"
);
#[cfg(unix)]
{
let link = Scratch(outer.0.with_extension("link"));
let _ = std::fs::remove_file(&link.0);
std::os::unix::fs::symlink(outer.0.join("tmp"), &link.0).expect("the link is made");
assert!(
clean_base(std::slice::from_ref(&link.0)).is_err(),
"a link into a work tree is no base"
);
}
assert!(
clean_base(std::slice::from_ref(&outer.0)).is_err(),
"a candidate that holds `.git` is no base"
);
std::fs::remove_dir_all(outer.0.join(".git")).expect("the directory goes");
std::fs::write(outer.0.join(".git"), "gitdir: /elsewhere\n").expect("the gitfile writes");
assert!(
clean_base(&[outer.0.join("tmp")]).is_err(),
"a candidate below a gitfile is no base"
);
}
#[test]
fn a_fenced_command_finds_no_repository_above_the_root() {
let Some(clean) = outside_base() else {
return;
};
let outer = Scratch::made(clean.join(format!(
"headwater-cli-merge-driver-{}-fence",
std::process::id()
)));
let git = |dir: &Path, fenced: bool, args: &[&str]| {
let mut command = Command::new("git");
command
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_CEILING_DIRECTORIES");
if fenced {
common::fence(&mut command, dir);
}
command.output().expect("git runs")
};
assert!(git(&outer.0, false, &["init", "-q"]).status.success());
let root = outer.0.join("root");
std::fs::create_dir_all(&root).expect("the root is made");
assert!(
git(&root, false, &["rev-parse", "--show-toplevel"])
.status
.success(),
"unfenced, git finds the repository above the root, so the case can fail"
);
let fenced = git(&root, true, &["rev-parse", "--show-toplevel"]);
assert!(
!fenced.status.success(),
"a fenced command found a repository above the root: {}",
String::from_utf8_lossy(&fenced.stdout)
);
}
const LOCK: &str = ".headwater/taxonomy.lock";
const DESCRIPTOR: &str = ".headwater/corpus.json";
struct Tree {
at: PathBuf,
}
impl Tree {
fn adopted(label: &str) -> Tree {
Tree::adopted_at(std::env::temp_dir().join(format!(
"headwater-cli-merge-driver-{}-{label}",
std::process::id()
)))
}
fn adopted_outside(label: &str) -> Option<Tree> {
Some(Tree::adopted_at(outside_base()?.join(format!(
"headwater-cli-merge-driver-{}-{label}",
std::process::id()
))))
}
fn adopted_at(at: PathBuf) -> Tree {
remove_all(&at);
Tree::adopted_in(at.clone(), &at)
}
fn adopted_in(at: PathBuf, top: &Path) -> Tree {
let tree = Tree { at };
std::fs::create_dir_all(tree.at.join("docs")).expect("the corpus directory is made");
tree.write("docs/one.md", "# a document\n");
copy_dir(
&repository().join(".headwater/packages/headwater-standard"),
&tree.at.join(".headwater/packages/headwater-standard"),
);
let init = tree.git_output(&["init", "-q", "-b", "main", &top.to_string_lossy()]);
assert!(init.status.success(), "`git init` succeeds");
tree.git(&["config", "user.email", "adopter@example.com"]);
tree.git(&["config", "user.name", "An adopter"]);
tree.git(&["config", "maintenance.auto", "false"]);
tree.git(&["config", "gc.auto", "0"]);
tree.git(&["config", "gc.autoDetach", "false"]);
tree.headwater_ok(&["init"]);
let overlay = tree.read(".headwater/overlay.yml");
assert!(
overlay.ends_with("add: {}\n"),
"the overlay `init` writes ends with an empty `add` block:\n{overlay}"
);
tree.write(
".headwater/overlay.yml",
&overlay.replace(
"add: {}\n",
"add:\n identifier_schemes.decision_id.namespace: ACME\n",
),
);
tree.headwater_ok(&["taxonomy", "resolve"]);
tree.headwater_ok(&["generate"]);
tree
}
fn read(&self, relative: &str) -> String {
std::fs::read_to_string(self.at.join(relative))
.unwrap_or_else(|error| panic!("{relative} reads: {error}"))
}
fn write(&self, relative: &str, body: &str) {
std::fs::write(self.at.join(relative), body).expect("the file writes");
}
fn git_output(&self, args: &[&str]) -> Output {
let bin = binary();
let dir = bin.parent().expect("the binary has a directory");
let path = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![dir.to_path_buf()];
paths.extend(std::env::split_paths(&path));
Command::new("git")
.args(args)
.current_dir(&self.at)
.env("PATH", std::env::join_paths(paths).expect("the path joins"))
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_WORK_TREE")
.output()
.expect("git runs")
}
fn git(&self, args: &[&str]) -> String {
let output = self.git_output(args);
assert!(
output.status.success(),
"`git {}` succeeds:\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn headwater(&self, args: &[&str]) -> Output {
Command::new(binary())
.args(args)
.arg("--root")
.arg(&self.at)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_WORK_TREE")
.output()
.expect("the binary runs")
}
fn headwater_ok(&self, args: &[&str]) -> Output {
let output = self.headwater(args);
assert_eq!(
output.status.code(),
Some(0),
"`headwater {}` exits 0:\n{}{}",
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
}
fn diverge_and_merge(&self) -> Output {
self.git(&["checkout", "-q", "-b", "a"]);
let overlay = self.read(".headwater/overlay.yml");
self.write(
".headwater/overlay.yml",
&format!(
"{overlay} identifier_schemes.doc_id: {{pattern: \"{{namespace}}-DOC-{{slug}}\", namespace: ACME, allocation: minted-once}}\n"
),
);
self.headwater_ok(&["taxonomy", "resolve"]);
self.headwater_ok(&["generate"]);
self.git(&["commit", "-q", "-am", "a scheme"]);
self.git(&["checkout", "-q", "main"]);
self.git(&["checkout", "-q", "-b", "b"]);
let declaration = self.read(".headwater/taxonomy.yml");
assert!(
declaration.contains(" bundles: []\n"),
"the declaration selects no bundle:\n{declaration}"
);
self.write(
".headwater/taxonomy.yml",
&declaration.replace(" bundles: []\n", " bundles: [diataxis]\n"),
);
self.headwater_ok(&["taxonomy", "resolve"]);
self.headwater_ok(&["generate"]);
self.git(&["commit", "-q", "-am", "a bundle"]);
self.git_output(&["merge", "--no-edit", "a"])
}
fn decide(&self, seq: &str) {
std::fs::create_dir_all(self.at.join("docs/decisions")).expect("the shelf is made");
self.write(
&format!("docs/decisions/{seq}-d{seq}.md"),
&format!(
"---\nid: ACME-DR-{seq}\ntitle: Decision {seq}\nstatus: draft\nsummary: The decision numbered {seq}.\n---\n\n# Decision {seq}\n"
),
);
}
fn info_attributes(&self) -> PathBuf {
let path = self.git(&["rev-parse", "--git-path", "info/attributes"]);
self.at.join(path.trim_end())
}
fn unmerged(&self) -> Vec<String> {
let listed = self.git(&["diff", "--name-only", "--diff-filter=U"]);
listed.lines().map(str::to_string).collect()
}
}
impl Drop for Tree {
fn drop(&mut self) {
remove_all(&self.at);
remove_all(&self.change_dir());
}
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).expect("the directory is made");
for entry in std::fs::read_dir(from).expect("the directory reads") {
let entry = entry.expect("the entry reads");
let target = to.join(entry.file_name());
match entry.file_type().expect("the type reads").is_dir() {
true => copy_dir(&entry.path(), &target),
false => {
std::fs::copy(entry.path(), &target).expect("the file copies");
}
}
}
}
#[test]
fn a_merge_that_moves_a_fold_stops_with_the_current_side_and_names_the_producer() {
let tree = Tree::adopted("driver");
tree.headwater_ok(&["init", "--git", "--git-config"]);
let override_ = std::fs::read_to_string(tree.info_attributes())
.expect("`--git-config` writes the clone's own attributes file");
assert!(
override_.contains(&format!("{LOCK} merge=headwater-regenerate\n")),
"the override names the driver for the lock:\n{override_}"
);
assert!(
!override_.contains(DESCRIPTOR),
"the descriptor is one record per entity, so nothing selects a driver for it:\n{override_}"
);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "adopt headwater"]);
let merge = tree.diverge_and_merge();
let stderr = String::from_utf8_lossy(&merge.stderr).into_owned();
let stdout = String::from_utf8_lossy(&merge.stdout).into_owned();
assert!(
!merge.status.success(),
"a merge that moves a fold to two values stops:\n{stdout}{stderr}"
);
let unmerged = tree.unmerged();
for path in [LOCK, DESCRIPTOR] {
assert!(
unmerged.iter().any(|listed| listed == path),
"{path} is left conflicted, and the conflicted paths are {unmerged:?}"
);
}
let merged = tree.read(LOCK);
assert!(
!merged.contains("<<<<<<<"),
"the lock carries no conflict marker:\n{merged}"
);
let current = tree.git(&["show", &format!("HEAD:{LOCK}")]);
assert_eq!(merged, current, "the lock holds the current side's bytes");
assert!(
stderr.contains(LOCK) && stderr.contains("headwater taxonomy resolve"),
"the driver names the lock and `headwater taxonomy resolve`:\n{stderr}"
);
tree.headwater_ok(&["taxonomy", "resolve"]);
tree.headwater_ok(&["generate"]);
tree.git(&["add", LOCK, DESCRIPTOR]);
tree.git(&["commit", "-q", "--no-edit"]);
tree.headwater_ok(&["taxonomy", "resolve", "--check"]);
tree.headwater_ok(&["generate", "--check"]);
}
#[test]
fn the_same_merge_without_the_git_step_writes_markers_into_the_folds() {
let tree = Tree::adopted("no-driver");
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "adopt headwater"]);
let merge = tree.diverge_and_merge();
assert!(!merge.status.success(), "the merge conflicts");
for path in [LOCK, DESCRIPTOR] {
assert!(
tree.read(path).contains("<<<<<<<"),
"{path} carries a conflict marker when no driver is configured"
);
}
}
#[test]
fn a_clone_with_the_committed_attributes_and_no_driver_config_conflicts_on_a_fold() {
let tree = Tree::adopted("unconfigured");
tree.headwater_ok(&["init", "--git"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "adopt headwater"]);
let configured =
tree.git_output(&["config", "--get-regexp", "^merge\\.headwater-regenerate\\."]);
assert!(
!configured.status.success(),
"this clone configures no driver"
);
assert!(
!tree.info_attributes().exists(),
"this clone holds no attribute override"
);
let merge = tree.diverge_and_merge();
let said = format!(
"{}{}",
String::from_utf8_lossy(&merge.stdout),
String::from_utf8_lossy(&merge.stderr)
);
assert!(
!merge.status.success(),
"a merge that moves a fold in an unconfigured clone stops:\n{said}"
);
let unmerged = tree.unmerged();
assert!(
unmerged.iter().any(|listed| listed == LOCK),
"the lock is left conflicted, and the conflicted paths are {unmerged:?}"
);
let merged = tree.read(LOCK);
assert!(
!merged.contains("<<<<<<<"),
"the lock carries no conflict marker:\n{merged}"
);
let current = tree.git(&["show", &format!("HEAD:{LOCK}")]);
assert_eq!(merged, current, "the lock holds the current side's bytes");
}
#[test]
fn a_shelf_index_carries_no_attribute_and_merges_to_what_generate_writes() {
const SHELF: &str = "docs/decisions/README.md";
let tree = Tree::adopted("record-shaped");
for seq in ["0001", "0003", "0005"] {
tree.decide(seq);
}
tree.headwater_ok(&["generate"]);
tree.headwater_ok(&["init", "--git"]);
assert!(
!tree.read(".gitattributes").contains(SHELF),
"`init --git` declares no attribute for a record-shaped index:\n{}",
tree.read(".gitattributes")
);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "adopt headwater"]);
tree.git(&["checkout", "-q", "-b", "a"]);
tree.decide("0002");
tree.headwater_ok(&["generate"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "a decision"]);
tree.git(&["checkout", "-q", "main"]);
tree.git(&["checkout", "-q", "-b", "b"]);
tree.decide("0006");
tree.headwater_ok(&["generate"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "another decision"]);
let merge = tree.git_output(&["merge", "--no-edit", "a"]);
assert!(
merge.status.success(),
"the two rows merge as text:\n{}",
String::from_utf8_lossy(&merge.stderr)
);
tree.headwater_ok(&["generate", "--check"]);
}
const EXPORT: &str = ".headwater/export.json";
const COMMITTED_EXPORT: &str =
"add_to:\n projections:\n - kind: graph_export\n profile: site\n output: .headwater/export.json\n";
fn graph_exports_in_lock(root: &Path) -> String {
let source = std::fs::read_to_string(root.join(LOCK)).expect("the lock reads");
let lock = headwater_yaml::load(&source).expect("the lock is YAML");
let projections = lock
.value
.as_map()
.and_then(|top| top.get("resolved"))
.and_then(|resolved| resolved.value.as_map())
.and_then(|resolved| resolved.get("projections"))
.and_then(|projections| projections.value.as_seq())
.map(<[_]>::to_vec)
.unwrap_or_default();
let mut entries = String::new();
for projection in &projections {
let Some(fields) = projection.value.as_map() else {
continue;
};
let kind = fields
.get("kind")
.and_then(|kind| kind.value.as_scalar())
.map(|kind| kind.text.as_str());
if kind != Some("graph_export") {
continue;
}
entries.push_str(" - kind: graph_export\n");
for entry in fields.iter().filter(|entry| entry.key.value != "kind") {
let value = entry.value.value.as_scalar().unwrap_or_else(|| {
panic!(
"a graph_export's `{}` is not a scalar, and this reader copies scalars only",
entry.key.value
)
});
entries.push_str(&format!(" {}: \"{}\"\n", entry.key.value, value.text));
}
}
match entries.is_empty() {
true => String::new(),
false => format!("add_to:\n projections:\n{entries}"),
}
}
fn this_repository_s_graph_exports() -> String {
graph_exports_in_lock(&repository())
}
const GOVERNED: &str = "tools/run.sh";
const GOVERNING: &str = "docs/decisions/0001-governs.md";
impl Tree {
fn change_dir(&self) -> PathBuf {
let mut name = self.at.as_os_str().to_owned();
name.push("-change");
PathBuf::from(name)
}
fn governing(&self, last_verified: &str, entry: &str) {
self.write(
GOVERNING,
&format!(
"---\nid: ACME-DR-0001\ntitle: Governs\nstatus: draft\nstatus_since: 2026-01-01\nlast_verified: {last_verified}\nsummary: The decision that governs the run script.\nrelations:\n governs:\n{entry}\n---\n\n# Governs\n\n## Context\n\nA script.\n\n## Decision\n\nIt runs.\n\n## Consequences\n\nIt ran.\n"
),
);
}
fn govern_and_stamp(&self) {
std::fs::create_dir_all(self.at.join("docs/decisions")).expect("the shelf is made");
std::fs::create_dir_all(self.at.join("tools")).expect("the directory is made");
self.write(GOVERNED, "#!/bin/sh\necho one\n");
self.governing("2026-01-02", &format!(" - {GOVERNED}"));
let dir = self.change_dir();
remove_all(&dir);
std::fs::create_dir_all(&dir).expect("the change directory is made");
let manifest = dir.join("manifest");
std::fs::write(
&manifest,
format!("headwater change 1\nadded\t{GOVERNING}\n"),
)
.expect("the manifest writes");
self.headwater_ok(&["check", "--fix", "--change", &manifest.to_string_lossy()]);
remove_all(&dir);
}
fn re_verify_and_restamp(&self) {
let body = self.read(GOVERNING);
self.write(
GOVERNING,
&body.replace("last_verified: 2026-01-02", "last_verified: 2026-01-03"),
);
let dir = self.change_dir();
remove_all(&dir);
self.headwater_ok(&["change", "HEAD", &dir.to_string_lossy()]);
let manifest = dir.join("manifest");
self.headwater_ok(&["check", "--fix", "--change", &manifest.to_string_lossy()]);
remove_all(&dir);
}
fn stamp(&self) -> String {
let body = self.read(GOVERNING);
let line = body
.lines()
.find(|line| line.trim_start().starts_with("verified_revision:"))
.unwrap_or_else(|| panic!("`check --fix` recorded a stamp:\n{body}"));
line.trim_start()
.trim_start_matches("verified_revision:")
.trim()
.to_string()
}
fn restamp_here_and_add_there(&self) -> Output {
self.git(&["checkout", "-q", "-b", "a"]);
self.write(GOVERNED, "#!/bin/sh\necho two\n");
self.re_verify_and_restamp();
self.headwater_ok(&["new", "decision", "--title", "Second"]);
self.headwater_ok(&["generate"]);
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", "a restamp and a decision"]);
self.git(&["checkout", "-q", "main"]);
self.git(&["checkout", "-q", "-b", "b"]);
self.headwater_ok(&["new", "specification", "--title", "Runs"]);
self.headwater_ok(&["generate"]);
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", "a specification"]);
self.git_output(&["merge", "--no-edit", "a"])
}
}
const UNION: &str = ".headwater/capture-cost.jsonl merge=union\n";
const SPEC_ID: &str = " identifier_schemes.spec_id: {pattern: \"{namespace}-SPEC-{slug}\", namespace: ACME, allocation: minted-once}\n kinds.specification.identifier: {scheme: spec_id}\n";
fn governed_tree(label: &str, projections: &str, union: bool) -> Tree {
let tree = Tree::adopted(label);
let overlay = tree.read(".headwater/overlay.yml");
let overlay = overlay.replace(
"add:\n identifier_schemes.decision_id.namespace: ACME\n",
&format!("add:\n identifier_schemes.decision_id.namespace: ACME\n{SPEC_ID}"),
);
tree.write(".headwater/overlay.yml", &format!("{overlay}{projections}"));
tree.headwater_ok(&["taxonomy", "resolve"]);
tree.govern_and_stamp();
tree.headwater_ok(&["generate"]);
tree.headwater_ok(&["init", "--git"]);
let attributes = tree.read(".gitattributes");
assert!(
attributes.contains(UNION),
"`init --git` writes the capture-cost union line:\n{attributes}"
);
if !union {
tree.write(".gitattributes", &attributes.replace(UNION, ""));
}
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "adopt headwater"]);
tree
}
#[test]
fn two_branches_on_different_shelves_merge_with_no_git_config_and_no_conflict_on_any_derived_path()
{
let tree = governed_tree("two-shelves", &this_repository_s_graph_exports(), true);
let before = tree.stamp();
let merge = tree.restamp_here_and_add_there();
assert!(
merge.status.success(),
"the two branches merge with no conflict, and the conflicted paths are {:?}:\n{}",
tree.unmerged(),
String::from_utf8_lossy(&merge.stdout)
);
assert_ne!(
tree.stamp(),
before,
"the merged tree carries branch `a`'s restamp"
);
tree.headwater_ok(&["generate", "--check"]);
}
#[test]
fn the_same_merge_in_a_tree_that_commits_its_graph_export_conflicts_on_the_export() {
let tree = governed_tree("committed-export", COMMITTED_EXPORT, true);
assert!(
tree.at.join(EXPORT).exists(),
"`generate` writes the declared export"
);
let merge = tree.restamp_here_and_add_there();
assert!(!merge.status.success(), "the merge stops on the export");
assert_eq!(
tree.unmerged(),
vec![EXPORT.to_string()],
"the export is the one conflicted path"
);
}
#[test]
fn the_same_merge_without_the_union_line_conflicts_on_the_capture_cost_store() {
let tree = governed_tree("no-union", &this_repository_s_graph_exports(), false);
let merge = tree.restamp_here_and_add_there();
assert!(!merge.status.success(), "the merge stops on the store");
assert_eq!(
tree.unmerged(),
vec![".headwater/capture-cost.jsonl".to_string()],
"the capture-cost store is the one conflicted path"
);
}
#[test]
fn a_graph_export_is_found_in_the_lock_in_every_key_order() {
for (label, entry) in [
(
"kind-first",
" - kind: graph_export\n profile: public\n output: site/graph.json\n",
),
(
"profile-first",
" - profile: public\n kind: graph_export\n output: site/graph.json\n",
),
(
"output-first",
" - output: site/graph.json\n kind: graph_export\n",
),
] {
let tree = Tree::adopted(&format!("order-{label}"));
let overlay = tree.read(".headwater/overlay.yml");
tree.write(
".headwater/overlay.yml",
&format!("{overlay}add_to:\n projections:\n{entry}"),
);
tree.headwater_ok(&["taxonomy", "resolve"]);
let found = graph_exports_in_lock(&tree.at);
assert!(
found.contains("- kind: graph_export\n")
&& found.contains("output: \"site/graph.json\"\n"),
"the {label} entry is read from the lock:\n{found}"
);
}
}
#[test]
fn this_repository_declares_no_graph_export() {
let declared = this_repository_s_graph_exports();
assert!(
declared.is_empty(),
"this repository's lock declares a graph export, which `generate` would commit; \
compute it at build time instead (#1251):\n{declared}"
);
}
#[derive(Clone, Copy, Debug)]
enum Meet {
Merge,
Rebase,
}
fn assert_every_claim_names_a_document(tree: &Tree) {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).expect("the claim directory reads") {
let path = entry.expect("the entry reads").path();
match path.is_dir() {
true => walk(&path, out),
false => out.push(path),
}
}
}
let mut claims = Vec::new();
walk(&tree.at.join(".headwater/ids"), &mut claims);
assert!(!claims.is_empty(), "the tree holds claims");
for claim in claims {
let named = std::fs::read_to_string(&claim).expect("the claim reads");
assert!(
tree.at.join(named.trim_end()).is_file(),
"{} names {}, which is not on the tree",
claim.display(),
named.trim_end()
);
}
}
#[test]
fn two_decisions_minted_on_two_branches_conflict_on_the_index_and_the_claim_and_a_renumber_resolves_them(
) {
for meet in [Meet::Merge, Meet::Rebase] {
mint_twice_and_renumber(meet);
}
}
fn mint_twice_and_renumber(meet: Meet) {
const CLAIM: &str = ".headwater/ids/decision_id/ACME-DR-0002";
let label = format!("same-shelf-{meet:?}").to_lowercase();
let tree = governed_tree(&label, &this_repository_s_graph_exports(), true);
tree.git(&["checkout", "-q", "-b", "a"]);
tree.headwater_ok(&["new", "decision", "--title", "Alpha"]);
tree.headwater_ok(&["generate"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "a decision"]);
tree.git(&["checkout", "-q", "main"]);
tree.git(&["checkout", "-q", "-b", "b"]);
tree.headwater_ok(&["new", "decision", "--title", "Beta"]);
tree.headwater_ok(&["generate"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "another decision"]);
let met = match meet {
Meet::Merge => tree.git_output(&["merge", "--no-edit", "a"]),
Meet::Rebase => tree.git_output(&["rebase", "a"]),
};
assert!(!met.status.success(), "{meet:?}: the two mints conflict");
assert_eq!(
tree.unmerged(),
vec![CLAIM.to_string(), "docs/decisions/README.md".to_string()],
"{meet:?}: the claim and the shelf index are the conflicted paths"
);
tree.git(&["checkout", "a", "--", CLAIM]);
assert_eq!(
tree.read(CLAIM),
"docs/decisions/0002-alpha.md\n",
"{meet:?}: the landed claim names the landed document"
);
tree.headwater_ok(&["generate"]);
tree.git(&["add", "-A"]);
let strict = tree.headwater(&["check", "--strict"]);
assert_eq!(
strict.status.code(),
Some(1),
"{meet:?}: two documents hold ACME-DR-0002, so a strict check refuses the tree"
);
tree.git(&[
"mv",
"docs/decisions/0002-beta.md",
"docs/decisions/0003-beta.md",
]);
let moved = tree.read("docs/decisions/0003-beta.md");
tree.write(
"docs/decisions/0003-beta.md",
&moved.replace("id: ACME-DR-0002\n", "id: ACME-DR-0003\n"),
);
tree.headwater_ok(&["check", "--fix"]);
assert!(
tree.at
.join(".headwater/ids/decision_id/ACME-DR-0003")
.exists(),
"{meet:?}: `check --fix` writes the claim of the new number"
);
tree.headwater_ok(&["generate"]);
tree.git(&["add", "-A"]);
match meet {
Meet::Merge => tree.git(&["commit", "-q", "--no-edit"]),
Meet::Rebase => {
let continued = tree.git_output(&["-c", "core.editor=true", "rebase", "--continue"]);
assert!(
continued.status.success(),
"{meet:?}: the rebase continues:\n{}",
String::from_utf8_lossy(&continued.stderr)
);
String::new()
}
};
tree.headwater_ok(&["check", "--strict"]);
tree.headwater_ok(&["generate", "--check"]);
assert_every_claim_names_a_document(&tree);
}
#[test]
fn the_git_step_selects_the_driver_in_a_clone_that_already_configured_it() {
let tree = Tree::adopted("migrated");
tree.git(&[
"config",
"merge.headwater-regenerate.driver",
"headwater merge-driver %O %A %B %P",
]);
tree.headwater_ok(&["init", "--git"]);
let override_ = std::fs::read_to_string(tree.info_attributes())
.expect("a configured clone gets the override without `--git-config`");
assert!(
override_.contains(&format!("{LOCK} merge=headwater-regenerate\n")),
"the override selects the driver for the lock:\n{override_}"
);
}
#[test]
fn the_git_step_writes_attributes_for_the_two_verb_producers_and_prints_the_config() {
let tree = Tree::adopted("written");
let before = tree.git(&["status", "--porcelain", "--untracked-files=all"]);
let output = tree.headwater_ok(&["init", "--git"]);
let after = tree.git(&["status", "--porcelain", "--untracked-files=all"]);
let added: Vec<&str> = after
.lines()
.filter(|line| !before.lines().any(|seen| seen == *line))
.collect();
assert_eq!(
added,
vec!["?? .gitattributes"],
"the git step writes one file, `.gitattributes`"
);
let attributes = tree.read(".gitattributes");
let declared: Vec<&str> = attributes
.lines()
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
.collect();
assert_eq!(
declared,
vec![
".headwater/taxonomy.lock -merge",
".headwater/capture-cost.jsonl merge=union",
".headwater/adoption.jsonl merge=union",
],
"the attribute lines unset the merge of each fold of the two verb producers, \
the descriptor is one record per entity, and each append-only store takes \
the union:\n{attributes}"
);
assert!(
!tree.info_attributes().exists(),
"without `--git-config` no attribute override is written, because an \
override that names a driver no config defines is a text merge again"
);
for word in ["tools/", ".githooks", "cargo", "python", ".sh"] {
assert!(
!attributes.contains(word),
"`.gitattributes` names nothing an adopter does not hold, and it names `{word}`:\n{attributes}"
);
}
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert!(
stdout.contains(
"git config merge.headwater-regenerate.driver \"headwater merge-driver %O %A %B %P\""
),
"the step prints the driver line:\n{stdout}"
);
assert!(
stdout.contains("git config merge.headwater-regenerate.name"),
"the step prints the name line:\n{stdout}"
);
assert!(
stdout.contains(&format!("{LOCK} merge=headwater-regenerate")),
"the step prints the override line for the lock:\n{stdout}"
);
assert!(
!stdout.contains(".jsonl merge=headwater-regenerate"),
"the step prints no override line for an append-only store, which no producer \
rebuilds:\n{stdout}"
);
assert!(
stdout.contains("git rev-parse --git-path info/attributes"),
"the step names where the override goes:\n{stdout}"
);
let configured = tree.git_output(&["config", "--get", "merge.headwater-regenerate.driver"]);
assert!(
!configured.status.success(),
"without `--git-config` nothing is written to git's configuration"
);
tree.headwater_ok(&["init", "--git"]);
assert_eq!(
tree.read(".gitattributes"),
attributes,
"a second run writes no line the first one wrote"
);
}
#[test]
fn the_description_of_init_says_git_commits_unset_merge_and_selects_the_driver_per_clone() {
let output = Command::new(binary())
.args(["init", "--help"])
.output()
.expect("the binary runs");
assert_eq!(
output.status.code(),
Some(0),
"`headwater init --help` exits 0"
);
let help = String::from_utf8_lossy(&output.stdout).into_owned();
let description = help
.split("Usage:")
.next()
.expect("the help has a description")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(
!description.contains("merge=headwater-regenerate"),
"the description does not say `--git` commits a line that selects the driver, \
because a line naming a driver no config defines is a text merge:\n{description}"
);
for words in [
"`-merge`",
"`.gitattributes`",
"`info/attributes`",
"`git config`",
] {
assert!(
description.contains(words),
"the description names {words}, as the `--git` row of the contract does:\n{description}"
);
}
let sentences: Vec<&str> = description.split(". ").collect();
let committed: Vec<&str> = sentences
.iter()
.copied()
.filter(|sentence| sentence.contains("`.gitattributes`"))
.collect();
let per_clone: Vec<&str> = sentences
.iter()
.copied()
.filter(|sentence| sentence.contains("`info/attributes`"))
.collect();
assert!(
!committed.is_empty() && !per_clone.is_empty(),
"the description names each file in a sentence:\n{description}"
);
for sentence in &committed {
assert!(
sentence.contains("`-merge` line to `.gitattributes`")
&& !sentence.contains("`info/attributes`")
&& !sentence.contains("driver"),
"the sentence that names `.gitattributes` gives it the `-merge` line and \
nothing that selects the driver:\n{sentence}"
);
}
for sentence in &per_clone {
assert!(
sentence.contains("`info/attributes` lines that select the driver")
&& !sentence.contains("`.gitattributes`")
&& !sentence.contains("`-merge`"),
"the sentence that names `info/attributes` gives it the lines that select \
the driver and no `-merge` line:\n{sentence}"
);
}
}
const CAPTURE_COST: &str = ".headwater/capture-cost.jsonl";
const ADOPTION: &str = ".headwater/adoption.jsonl";
#[test]
fn two_branches_that_each_run_new_merge_the_capture_cost_store_without_a_conflict() {
let tree = Tree::adopted("two-news");
tree.headwater_ok(&["init", "--git"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "adopted"]);
tree.git(&["checkout", "-q", "-b", "a"]);
tree.headwater_ok(&["new", "decision", "--title", "A ruling on branch a"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "a ruling"]);
tree.git(&["checkout", "-q", "main"]);
tree.git(&["checkout", "-q", "-b", "b"]);
tree.decide("0005");
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "a decision by hand"]);
tree.headwater_ok(&["new", "decision", "--title", "A ruling on branch b"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "a ruling"]);
let merged = tree.git_output(&["merge", "--no-edit", "a"]);
assert_eq!(
(merged.status.code(), tree.unmerged()),
(Some(0), Vec::<String>::new()),
"the merge of two branches that each ran `new` exits 0 with nothing unmerged:\n{}{}",
String::from_utf8_lossy(&merged.stdout),
String::from_utf8_lossy(&merged.stderr)
);
let store = tree.read(CAPTURE_COST);
let readings: Vec<&str> = store.lines().collect();
assert_eq!(
readings.len(),
2,
"the store holds one reading from each branch:\n{store}"
);
assert!(
readings
.iter()
.any(|line| line.contains("a-ruling-on-branch-a"))
&& readings
.iter()
.any(|line| line.contains("a-ruling-on-branch-b")),
"one reading names each document:\n{store}"
);
}
#[test]
fn the_git_step_leaves_a_declared_store_alone_and_writes_no_override_for_a_store() {
let tree = Tree::adopted("declared-store");
let own = format!("# the adopter's own lines\n{CAPTURE_COST} -merge\n{ADOPTION} merge=union\n");
tree.write(".gitattributes", &own);
tree.headwater_ok(&["init", "--git", "--git-config"]);
let attributes = tree.read(".gitattributes");
assert!(
attributes.starts_with(&own),
"the adopter's lines are kept byte for byte:\n{attributes}"
);
for (store, line) in [
(CAPTURE_COST, format!("{CAPTURE_COST} -merge")),
(ADOPTION, format!("{ADOPTION} merge=union")),
] {
let naming: Vec<&str> = attributes
.lines()
.filter(|seen| seen.starts_with(store))
.collect();
assert_eq!(
naming,
vec![line.as_str()],
"the adopter's line is the one line that names {store}:\n{attributes}"
);
}
let over = std::fs::read_to_string(tree.info_attributes()).expect("the override is written");
for store in [CAPTURE_COST, ADOPTION] {
assert!(
!over.contains(store),
"the override names no store:\n{over}"
);
}
assert!(
over.contains(&format!("{LOCK} merge=headwater-regenerate")),
"the override still names the lock:\n{over}"
);
}
#[test]
fn the_git_step_leaves_every_form_of_a_merge_line_for_a_store_in_effect() {
let rows: [(&str, &str, &str); 9] = [
(
"ours",
".gitattributes",
".headwater/capture-cost.jsonl merge=ours\n",
),
(
"text",
".gitattributes",
".headwater/capture-cost.jsonl merge=text\n",
),
(
"bang",
".gitattributes",
".headwater/capture-cost.jsonl !merge\n",
),
(
"set",
".gitattributes",
".headwater/capture-cost.jsonl merge\n",
),
("glob", ".gitattributes", ".headwater/*.jsonl -merge\n"),
(
"anchored",
".gitattributes",
"/.headwater/capture-cost.jsonl -merge\n",
),
(
"binary",
".gitattributes",
".headwater/capture-cost.jsonl binary\n",
),
(
"nested",
".headwater/.gitattributes",
"capture-cost.jsonl merge=ours\n",
),
(
"nested-bang",
".headwater/.gitattributes",
"capture-cost.jsonl !merge\n",
),
];
let mut wrong: Vec<String> = Vec::new();
for (label, file, line) in rows {
for present in [false, true] {
let tree = Tree::adopted(&format!("form-{label}-{present}"));
if present {
tree.write(CAPTURE_COST, "{\"a\":1}\n");
}
tree.write(file, line);
let before = tree.git(&["check-attr", "merge", "--", CAPTURE_COST]);
tree.headwater_ok(&["init", "--git"]);
let after = tree.git(&["check-attr", "merge", "--", CAPTURE_COST]);
let root = tree.read(".gitattributes");
let appended = root.contains(&format!("{CAPTURE_COST} merge=union"));
let second = tree.headwater_ok(&["init", "--git"]);
let settled = String::from_utf8_lossy(&second.stdout).contains("already declares all");
if after != before || appended || !settled {
wrong.push(format!(
"{label} (`{}` in {file}, store present: {present}): before `{}`, after \
`{}`, union line appended: {appended}, second run wrote nothing: {settled}",
line.trim_end(),
before.trim_end(),
after.trim_end()
));
}
}
}
assert!(
wrong.is_empty(),
"the adopter's line keeps its effect, the step writes no union line for the store, \
and a second run writes nothing:\n{}",
wrong.join("\n")
);
}
#[test]
fn two_branches_that_each_record_an_audit_merge_the_adoption_store_without_a_conflict() {
let tree = Tree::adopted("two-audits");
tree.headwater_ok(&["init", "--git"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "adopted"]);
tree.git(&["checkout", "-q", "-b", "a"]);
tree.headwater_ok(&["taxonomy", "audit", "--record", "--now", "2026-01-01"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "a reading"]);
tree.git(&["checkout", "-q", "main"]);
tree.git(&["checkout", "-q", "-b", "b"]);
tree.headwater_ok(&["taxonomy", "audit", "--record", "--now", "2026-02-02"]);
tree.git(&["add", "-A"]);
tree.git(&["commit", "-q", "-m", "b reading"]);
let merged = tree.git_output(&["merge", "--no-edit", "a"]);
assert_eq!(
(merged.status.code(), tree.unmerged()),
(Some(0), Vec::<String>::new()),
"the merge of two branches that each recorded an audit exits 0 with nothing unmerged:\n{}{}",
String::from_utf8_lossy(&merged.stdout),
String::from_utf8_lossy(&merged.stderr)
);
let store = tree.read(ADOPTION);
let readings: Vec<&str> = store.lines().collect();
assert_eq!(
readings.len(),
2,
"the store holds one reading from each branch:\n{store}"
);
assert!(
readings.iter().any(|line| line.contains("2026-01-01"))
&& readings.iter().any(|line| line.contains("2026-02-02")),
"one reading carries each date:\n{store}"
);
}
#[test]
fn below_the_top_of_the_repository_the_git_step_writes_each_store_line_it_owes() {
let mut wrong: Vec<String> = Vec::new();
for owned in [None, Some(CAPTURE_COST), Some(ADOPTION)] {
for present in [false, true] {
let label = format!(
"below-{}-{present}",
owned.map_or("none", |store| if store == ADOPTION {
"adoption"
} else {
"cost"
})
);
let top = std::env::temp_dir().join(format!(
"headwater-cli-merge-driver-{}-{label}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&top);
let _top = Tree { at: top.clone() };
let tree = Tree::adopted_in(top.join("handbook"), &top);
for store in [CAPTURE_COST, ADOPTION] {
if present {
tree.write(store, "{\"a\":1}\n");
}
}
if let Some(store) = owned {
tree.write(".gitattributes", &format!("{store} merge=ours\n"));
}
let output = tree.headwater(&["init", "--git"]);
let attributes =
std::fs::read_to_string(tree.at.join(".gitattributes")).unwrap_or_default();
for store in [CAPTURE_COST, ADOPTION] {
let answer = tree.git(&["check-attr", "merge", "--", store]);
let expected = if owned == Some(store) {
"ours"
} else {
"union"
};
let lines = attributes
.lines()
.filter(|line| line.starts_with(store))
.count();
if !answer.trim_end().ends_with(&format!(": {expected}")) || lines != 1 {
wrong.push(format!(
"{label}, {store}: git answers `{}`, {lines} lines name it, exit {:?}",
answer.trim_end(),
output.status.code()
));
}
}
if output.status.code() != Some(0) {
wrong.push(format!(
"{label}: exit {:?}:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
));
}
}
}
assert!(
wrong.is_empty(),
"below the top of the repository, each store the file does not name gets its union \
line, and a named store keeps its line:\n{}",
wrong.join("\n")
);
}
#[test]
fn below_a_directory_with_a_special_name_the_git_step_probes_the_store_as_spelled() {
let names = [
("space", "my corpus"),
("hash", "#notes"),
("bang", "!bang"),
("bracket", "br[1]"),
("star", "st*r"),
("quote", "quo\"te"),
("backslash", "back\\slash"),
("newline", "new\nline"),
];
let mut wrong: Vec<String> = Vec::new();
for (label, name) in names {
for owned in [false, true] {
let top = std::env::temp_dir().join(format!(
"headwater-cli-merge-driver-{}-named-{label}-{owned}",
std::process::id()
));
remove_all(&top);
let _top = Tree { at: top.clone() };
let tree = Tree::adopted_in(top.join(name), &top);
if owned {
tree.write(".gitattributes", &format!("{CAPTURE_COST} merge=ours\n"));
}
let output = tree.headwater(&["init", "--git"]);
let attributes =
std::fs::read_to_string(tree.at.join(".gitattributes")).unwrap_or_default();
for store in [CAPTURE_COST, ADOPTION] {
let expected = if owned && store == CAPTURE_COST {
"ours"
} else {
"union"
};
let answer = tree.git(&["check-attr", "merge", "--", store]);
let lines = attributes
.lines()
.filter(|line| line.starts_with(store))
.count();
if !answer.trim_end().ends_with(&format!(": {expected}")) || lines != 1 {
wrong.push(format!(
"{label}, owned {owned}, {store}: git answers `{}`, {lines} lines name it",
answer.trim_end()
));
}
}
if output.status.code() != Some(0) {
wrong.push(format!(
"{label}, owned {owned}: exit {:?}: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim_end()
));
}
}
}
assert!(
wrong.is_empty(),
"below each directory name, the step writes the union line each store is owed, leaves \
the adopter's line alone and exits 0:\n{}",
wrong.join("\n")
);
}
#[test]
fn a_probe_with_no_answer_exits_1_writes_no_store_line_and_says_so_again() {
let tree = Tree::adopted("no-answer");
let missing = tree.at.join("no-such-temporary-directory");
let run = || {
Command::new(binary())
.args(["init", "--git", "--root"])
.arg(&tree.at)
.env("TMPDIR", &missing)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_WORK_TREE")
.output()
.expect("the binary runs")
};
for attempt in ["first", "second"] {
let output = run();
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert_eq!(
output.status.code(),
Some(1),
"the {attempt} run exits 1:\n{stdout}{stderr}"
);
assert!(
stderr.contains("wrote no line for the append-only stores")
&& stderr.contains("cannot write the probe file"),
"the {attempt} run names why on standard error:\n{stderr}"
);
let attributes = tree.read(".gitattributes");
for store in [CAPTURE_COST, ADOPTION] {
assert!(
!attributes.contains(store),
"the {attempt} run writes no line for {store}:\n{attributes}"
);
}
assert!(
!stdout.contains("append-only stores\n") && !stdout.contains("and append-only stores"),
"the {attempt} run does not count the stores as declared:\n{stdout}"
);
assert!(
stdout.contains("no line for the 2 append-only stores"),
"the {attempt} run says on standard output that it wrote no store line:\n{stdout}"
);
}
}
#[test]
fn every_resolve_remedy_the_merge_hooks_print_is_one_the_verb_accepts() {
const VERB: &str = "headwater taxonomy resolve";
let tree = Tree::adopted("remedy");
let mut ran = 0;
for hook in [".githooks/merge-regenerate", ".githooks/merged-fold-check"] {
let text = std::fs::read_to_string(repository().join(hook))
.unwrap_or_else(|error| panic!("{hook} reads: {error}"));
let mut found = 0;
for (at, _) in text.match_indices(VERB) {
let rest = &text[at..];
let end = [
rest.find('"'),
rest.find('`'),
rest.find(')'),
rest.find(" "),
rest.find('\n'),
]
.into_iter()
.flatten()
.min()
.unwrap_or(rest.len());
let command = rest[..end].trim();
let args: Vec<&str> = command.split_whitespace().skip(1).collect();
let output = tree.headwater(&args);
assert_eq!(
output.status.code(),
Some(0),
"`{command}`, printed by {hook}, is a command the verb accepts:\n{}",
String::from_utf8_lossy(&output.stderr)
);
found += 1;
}
assert!(found > 0, "{hook} prints a `{VERB}` remedy");
ran += found;
}
assert!(
ran >= 3,
"the two hooks print three resolve commands, and {ran} ran"
);
}
#[test]
fn inside_a_repository_where_git_does_not_run_derived_says_so() {
let tree = Tree::adopted("no-git");
tree.headwater_ok(&["init", "--git"]);
tree.headwater_ok(&["derived"]);
let empty = Scratch::made(tree.at.with_extension("empty-path"));
let output = Command::new(binary())
.args(["derived", "--root"])
.arg(&tree.at)
.env("PATH", &empty.0)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_WORK_TREE")
.output()
.expect("the binary runs");
let said = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
output.status.code(),
Some(1),
"a repository whose git does not run is not a tree with no repository:\n{said}"
);
assert!(
said.contains("git did not run"),
"the report names that git did not run:\n{said}"
);
}
fn derived_with(tree: &Tree, path: Option<&Path>, extra: &[(&str, &str)]) -> (Option<i32>, String) {
let mut command = Command::new(binary());
command
.args(["derived", "--root"])
.arg(&tree.at)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_WORK_TREE");
if let Some(path) = path {
command.env("PATH", path);
}
for (key, value) in extra {
command.env(key, value);
}
let output = command.output().expect("the binary runs");
let said = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
(output.status.code(), said)
}
#[test]
fn inside_a_repository_that_git_refuses_for_its_owner_derived_says_so() {
let tree = Tree::adopted("dubious-owner");
tree.headwater_ok(&["init", "--git"]);
tree.headwater_ok(&["derived"]);
let (code, said) = derived_with(&tree, None, &[("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1")]);
assert_eq!(
code,
Some(1),
"a repository that git refuses is not a tree with no repository:\n{said}"
);
assert!(
said.contains("git did not answer"),
"the report names that git did not answer:\n{said}"
);
}
#[test]
fn inside_a_worktree_whose_git_directory_is_gone_derived_says_so() {
let tree = Tree::adopted("pruned-worktree");
tree.headwater_ok(&["init", "--git"]);
std::fs::remove_dir_all(tree.at.join(".git")).expect("the git directory is removed");
let gone = tree.at.with_extension("pruned-gitdir");
let _ = std::fs::remove_dir_all(&gone);
tree.write(".git", &format!("gitdir: {}\n", gone.display()));
let (code, said) = derived_with(&tree, None, &[]);
assert_eq!(
code,
Some(1),
"a worktree whose git directory is gone is not a tree with no repository:\n{said}"
);
assert!(
said.contains("git did not answer"),
"the report names that git did not answer:\n{said}"
);
}
#[test]
fn outside_a_repository_with_no_git_derived_reads_the_root_file_and_refuses_nothing() {
let Some(tree) = Tree::adopted_outside("no-git-no-repository") else {
return;
};
tree.headwater_ok(&["init", "--git"]);
std::fs::remove_dir_all(tree.at.join(".git")).expect("the git directory is removed");
assert!(
!tree.at.ancestors().any(|dir| dir.join(".git").exists()),
"no directory above the temporary tree holds a `.git` entry"
);
let empty = Scratch::made(tree.at.with_extension("empty-path-no-repository"));
let (code, said) = derived_with(&tree, Some(&empty.0), &[]);
assert!(
!said.contains("git did not"),
"a tree with no repository names no git failure:\n{said}"
);
assert_eq!(
code,
Some(0),
"the root `.gitattributes` that `init --git` wrote agrees:\n{said}"
);
}
struct Outer(PathBuf);
impl Drop for Outer {
fn drop(&mut self) {
remove_all(&self.0);
}
}
impl Outer {
fn with_tree(label: &str) -> Option<(Outer, Tree)> {
let outer = Outer(Outer::at(label)?);
let _ = std::fs::remove_dir_all(&outer.0);
std::fs::create_dir_all(&outer.0).expect("the outer directory is made");
let tree = Tree::adopted_at(outer.0.join("tree"));
tree.headwater_ok(&["init", "--git"]);
std::fs::remove_dir_all(tree.at.join(".git")).expect("the git directory is removed");
Some((outer, tree))
}
fn at(label: &str) -> Option<PathBuf> {
Some(outside_base()?.join(format!(
"headwater-cli-merge-driver-{}-{label}",
std::process::id()
)))
}
fn git_init(&self) {
let output = Command::new("git")
.args(["init", "-q"])
.arg(&self.0)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.output()
.expect("git runs");
assert!(output.status.success(), "`git init` succeeds");
}
}
fn no_repository_above(label: &str, shape: impl Fn(&Outer), extra: &[(&str, &str)]) {
let Some((outer, tree)) = Outer::with_tree(label) else {
return;
};
shape(&outer);
let (code, said) = derived_with(&tree, None, extra);
assert!(
!said.contains("git did not"),
"git finds no repository here, so nothing is refused:\n{said}"
);
assert_eq!(
code,
Some(0),
"the root `.gitattributes` that `init --git` wrote agrees:\n{said}"
);
}
#[test]
fn a_file_named_git_that_is_not_a_gitfile_above_the_tree_is_no_repository() {
no_repository_above(
"plain-git-file",
|outer| std::fs::write(outer.0.join(".git"), "hello\n").expect("the file writes"),
&[],
);
}
#[test]
fn an_empty_directory_named_git_above_the_tree_is_no_repository() {
no_repository_above(
"empty-git-directory",
|outer| std::fs::create_dir(outer.0.join(".git")).expect("the directory is made"),
&[],
);
}
#[test]
fn a_repository_beyond_a_ceiling_directory_is_no_repository() {
let label = "ceiling";
let Some(ceiling) = Outer::at(label) else {
return;
};
let ceiling = ceiling.to_string_lossy().into_owned();
no_repository_above(
label,
|outer| {
outer.git_init();
std::fs::create_dir_all(outer.0.join(".git/info")).expect("the directory is made");
std::fs::write(
outer.0.join(".git/info/attributes"),
format!("tree/{LOCK} merge=union\n"),
)
.expect("the attributes write");
},
&[("GIT_CEILING_DIRECTORIES", &ceiling)],
);
}
#[test]
fn below_a_repository_that_git_refuses_for_its_owner_derived_says_so() {
let Some((outer, tree)) = Outer::with_tree("dubious-owner-above") else {
return;
};
outer.git_init();
let (code, said) = derived_with(&tree, None, &[("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1")]);
assert_eq!(
code,
Some(1),
"a tree inside a repository that git refuses is not a tree with no repository:\n{said}"
);
assert!(
said.contains("git did not answer"),
"the report names that git did not answer:\n{said}"
);
}
#[test]
fn the_git_step_writes_no_line_for_a_fold_a_nested_file_declares() {
let tree = Tree::adopted("nested");
tree.write(".headwater/.gitattributes", "taxonomy.lock -merge\n");
for run in ["first", "second"] {
let output = tree.headwater_ok(&["init", "--git"]);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert!(
!stdout.contains(&format!("{LOCK} -merge")),
"the {run} run reports no root line for the lock:\n{stdout}"
);
if run == "second" {
assert!(
stdout.contains("already declares all"),
"the {run} run finds every derived artifact declared:\n{stdout}"
);
}
let root = std::fs::read_to_string(tree.at.join(".gitattributes")).unwrap_or_default();
assert!(
!root.contains("taxonomy.lock"),
"the {run} run wrote a root line for the lock, which the nested file declares:\n{root}"
);
}
}
#[test]
fn the_git_step_without_git_names_the_refusal_and_writes_no_line_a_nested_file_could_declare() {
let tree = Tree::adopted("nested-no-git");
tree.write(".headwater/.gitattributes", "taxonomy.lock -merge\n");
let empty = Scratch::made(tree.at.with_extension("nested-no-git-empty-path"));
for run in ["first", "second"] {
let output = Command::new(binary())
.args(["init", "--git", "--root"])
.arg(&tree.at)
.env("PATH", &empty.0)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_WORK_TREE")
.output()
.expect("the binary runs");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
stderr.contains("git did not run"),
"the {run} run names that git did not run, on standard error:\n{stderr}\n--- stdout\n{stdout}"
);
assert!(
stderr.contains(".headwater/.gitattributes"),
"the {run} run names the nested file it could not read:\n{stderr}"
);
let root = std::fs::read_to_string(tree.at.join(".gitattributes")).unwrap_or_default();
assert!(
!root.contains("taxonomy.lock"),
"the {run} run wrote a root line for the lock, which the unread nested file declares:\n{root}"
);
assert_eq!(
output.status.code(),
Some(1),
"the {run} run exits 1, because git did not give the merge attributes:\n{stderr}"
);
}
}
#[test]
fn the_git_step_writes_no_line_for_a_producer_the_adopter_does_not_hold() {
let rows: [(&str, &str, &str, &str, &str); 1] = [(
"blessing-producer",
"engine/crates/a/fixtures/corpus.a",
"426 files\nsha256:0a1b\n",
"HEADWATER_BLESS=1 cargo test",
"engine/Cargo.toml",
)];
for (label, path, body, command, holder) in rows {
let tree = Tree::adopted(label);
let plant = |relative: &str, text: &str| {
let at = tree.at.join(relative);
std::fs::create_dir_all(at.parent().expect("a parent")).expect("the directory is made");
std::fs::write(at, text).expect("the file writes");
};
plant(path, body);
let derived = tree.headwater(&["derived"]);
let report = String::from_utf8_lossy(&derived.stdout).into_owned();
assert!(
!report.contains(path) && !report.contains(command),
"`headwater derived` names {path} or `{command}` in a tree without \
{holder}:\n{report}"
);
tree.headwater_ok(&["init", "--git"]);
let attributes = tree.read(".gitattributes");
assert!(
!attributes.contains(path),
"`init --git` writes a line for {path}, whose producer the adopter does \
not hold:\n{attributes}"
);
assert!(
attributes.contains(".headwater/taxonomy.lock -merge"),
"the verb producers' folds are still written:\n{attributes}"
);
plant(holder, "held\n");
let derived = tree.headwater(&["derived"]);
let report = String::from_utf8_lossy(&derived.stdout).into_owned();
assert!(
report.contains(path) && report.contains(command),
"with {holder} present, `headwater derived` claims {path} for \
`{command}`:\n{report}"
);
tree.headwater_ok(&["init", "--git"]);
let attributes = tree.read(".gitattributes");
assert!(
attributes.contains(&format!("{path} -merge")),
"with {holder} present, `init --git` writes {path}:\n{attributes}"
);
}
}
#[test]
fn the_driver_leaves_the_current_side_and_exits_non_zero() {
let outer = Outer(std::env::temp_dir().join(format!(
"headwater-cli-merge-driver-{}-bare",
std::process::id()
)));
let dir = &outer.0;
let _ = std::fs::remove_dir_all(dir);
std::fs::create_dir_all(dir).expect("the directory is made");
for (name, body) in [("o", "ancestor\n"), ("a", "current\n"), ("b", "other\n")] {
std::fs::write(dir.join(name), body).expect("the side writes");
}
for (path, producer) in [
(LOCK, "headwater taxonomy resolve"),
(DESCRIPTOR, "headwater generate"),
("docs/README.md", "headwater generate"),
] {
let output = Command::new(binary())
.arg("merge-driver")
.arg(dir.join("o"))
.arg(dir.join("a"))
.arg(dir.join("b"))
.arg(path)
.output()
.expect("the binary runs");
assert_eq!(output.status.code(), Some(1), "the driver refuses {path}");
assert!(
output.stdout.is_empty(),
"standard output during a merge is git's"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(path) && stderr.contains(producer),
"the driver names {path} and `{producer}`:\n{stderr}"
);
assert_eq!(
std::fs::read_to_string(dir.join("a")).expect("the current side reads"),
"current\n",
"the current side is left byte for byte"
);
}
}