testing_conventions/e2e.rs
1//! `e2e attest` / `e2e verify` — the e2e decision nudge.
2//!
3//! `attest` runs the e2e command of the runner's choosing and records the
4//! decision as a branch-keyed receipt; `verify` confirms in CI that a branch
5//! changing the scoped source carries a receipt in its own diff. CI never runs
6//! e2e, and the command is unrestricted — the choice of command (the full
7//! suite, a targeted subset, a no-op) *is* the judgment the receipt records.
8
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use anyhow::{bail, Context, Result};
14use serde::{Deserialize, Serialize};
15
16/// Where the branch-keyed receipts live, relative to the package root. Each
17/// receipt is `<branch_slug>.json`, so parallel branches write distinct files.
18pub const RECEIPTS_DIR: &str = "e2e-attestations";
19
20/// The retired single-file attestation location: never read as a receipt and
21/// never counted as scoped source, so a branch deleting it owes nothing.
22const LEGACY_ATTESTATION: &str = "e2e-attestation.json";
23
24/// A record of one e2e decision — written to `RECEIPTS_DIR/<branch_slug>.json`
25/// and committed by [`attest`].
26///
27/// Everything here is information for humans — [`verify`] reads only the
28/// receipt's presence in the branch's diff, never its contents.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Attestation {
31 /// The command that was run (e.g. `pnpm run e2e`) — the judgment itself.
32 pub command: String,
33 /// When it ran, as a Unix timestamp (seconds).
34 pub ran_at: u64,
35 /// The command's exit code — recorded, never gated on.
36 pub exit_code: i32,
37 /// The commit the run was made against (HEAD at attest time).
38 pub commit: String,
39 /// The raw branch name the receipt is keyed by (the filename carries only
40 /// its sanitized slug).
41 #[serde(default)]
42 pub branch: String,
43}
44
45/// The standardized receipt slug for a branch name — the receipt lives at
46/// `e2e-attestations/<slug>.json`. Lowercased; every character outside
47/// `[a-z0-9._-]` becomes `-`; runs of `-` collapse to one; truncated to 80
48/// characters; leading/trailing `-` and `.` trimmed; an empty result falls
49/// back to `branch`. Deterministic and git-free, so a script can locate a
50/// branch's receipt; exposed on the CLI as `e2e slug`.
51pub fn branch_slug(branch: &str) -> String {
52 let mut slug = String::new();
53 for c in branch.to_lowercase().chars() {
54 let mapped = if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' {
55 c
56 } else {
57 '-'
58 };
59 if mapped == '-' && slug.ends_with('-') {
60 continue;
61 }
62 slug.push(mapped);
63 }
64 let slug: String = slug.chars().take(80).collect();
65 let slug = slug.trim_matches(|c| c == '-' || c == '.');
66 if slug.is_empty() {
67 "branch".to_string()
68 } else {
69 slug.to_string()
70 }
71}
72
73/// The checked-out branch of `repo`, or an error naming the fix on a detached
74/// HEAD — the receipt is keyed by branch, so attest needs one.
75pub(crate) fn current_branch(repo: &Path) -> Result<String> {
76 git_capture(repo, &["symbolic-ref", "--short", "-q", "HEAD"]).context(
77 "resolving the current branch — the receipt is keyed by branch, so this \
78 must run on a checked-out branch (a detached HEAD has none): `git switch <branch>`",
79 )
80}
81
82/// Run `command` in `repo`, write the branch's receipt to
83/// `repo`/[`RECEIPTS_DIR`]`/<branch_slug>.json`, prune the receipts other
84/// branches left behind (and the retired single-file attestation), and commit.
85/// Returns the attestation.
86///
87/// Writes regardless of the command's exit code — the record is the decision
88/// and what ran, and the honest result is part of the record.
89pub fn attest(repo: &Path, command: &str) -> Result<Attestation> {
90 let commit = git_capture(repo, &["rev-parse", "HEAD"])
91 .context("resolving HEAD — `e2e attest` must run inside a git repo with a commit")?;
92 let branch = current_branch(repo)?;
93
94 // Run the e2e command via the shell, streaming its output through.
95 let status = Command::new("sh")
96 .arg("-c")
97 .arg(command)
98 .current_dir(repo)
99 .status()
100 .with_context(|| format!("running e2e command `{command}`"))?;
101 let exit_code = status.code().unwrap_or(-1);
102
103 let ran_at = SystemTime::now()
104 .duration_since(UNIX_EPOCH)
105 .map(|d| d.as_secs())
106 .unwrap_or(0);
107
108 let attestation = Attestation {
109 command: command.to_string(),
110 ran_at,
111 exit_code,
112 commit,
113 branch: branch.clone(),
114 };
115
116 // Prune sibling receipts — dead weight once their branches merge, since
117 // `verify` reads only the current branch's diff. Write-once files make the
118 // deletions merge-clean (delete/delete or delete/absent, never a conflict).
119 let dir = repo.join(RECEIPTS_DIR);
120 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
121 for entry in std::fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? {
122 let path = entry?.path();
123 if path.extension().is_some_and(|e| e == "json") {
124 std::fs::remove_file(&path).with_context(|| format!("pruning {}", path.display()))?;
125 }
126 }
127 let path = dir.join(format!("{}.json", branch_slug(&branch)));
128 let json = serde_json::to_string_pretty(&attestation).context("serializing the receipt")?;
129 std::fs::write(&path, format!("{json}\n"))
130 .with_context(|| format!("writing {}", path.display()))?;
131 git_run(repo, &["add", "-A", "--", RECEIPTS_DIR])?;
132
133 // The retired single-file attestation is dead weight too; collect it here
134 // so the migration is one attest away.
135 if pathspec_matches_tracked(repo, LEGACY_ATTESTATION)? {
136 git_run(
137 repo,
138 &["rm", "-q", "--ignore-unmatch", "--", LEGACY_ATTESTATION],
139 )?;
140 }
141
142 let message = format!("e2e attestation for {branch}");
143 // A plain commit that inherits the repo's signing policy: a repo requiring
144 // verified signatures gets a signed (mergeable) receipt, instead of the
145 // unsigned commit a forced `commit.gpgsign=false` would leave behind.
146 git_run(repo, &["commit", "-q", "-m", message.as_str()])?;
147
148 Ok(attestation)
149}
150
151/// The outcome of [`verify`] — whether a committed receipt answers the branch's
152/// e2e nudge.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum Verification {
155 /// The branch owes no decision (its scoped diff is empty), or a receipt in
156 /// its diff answers the one it owes — the gate passes.
157 Fresh,
158 /// No receipt answers the nudge: the branch changed the scoped source and
159 /// its diff adds or updates no receipt (or, without a base, no committed
160 /// receipt is present at all) — the gate fails.
161 Missing,
162}
163
164/// Verify the e2e decision at `repo` — the CI side of the nudge. Reads only
165/// receipt presence and content diffs: never runs e2e, never inspects a
166/// recorded command or exit code, never compares commit SHAs.
167///
168/// Equivalent to [`verify_scoped`] with `scope` set to `repo`.
169pub fn verify(repo: &Path) -> Result<Verification> {
170 verify_scoped(repo, repo)
171}
172
173/// Verify the e2e decision at `repo`, with `scope` (rather than all of `repo`)
174/// defining what counts as scoped source.
175///
176/// `repo` and `scope` serve different roles: `repo` is where the receipts live
177/// (the package root — a manifest's own natural home), while `scope` is what
178/// counts as "code" (the directory a `path`-scoped call actually named, which
179/// can be narrower — a package root commonly also holds `tests/`, docs, and
180/// config files that aren't the attestable source). `scope` must be `repo` or
181/// a descendant of it.
182///
183/// Equivalent to [`verify_since`] with no `base`.
184pub fn verify_scoped(repo: &Path, scope: &Path) -> Result<Verification> {
185 verify_since(repo, scope, None)
186}
187
188/// Equivalent to [`verify_extra_scoped`] with no extra roots and no excludes.
189pub fn verify_since(repo: &Path, scope: &Path, base: Option<&str>) -> Result<Verification> {
190 verify_extra_scoped(repo, scope, base, &[], &[])
191}
192
193/// Verify the e2e decision at `repo`, joining **extra scopes** outside `scope`
194/// into what counts as scoped source.
195///
196/// With `base`, both checks are content diffs of `<base>...HEAD`, read from
197/// the merge base — indifferent to commit identity, so rebases and squash
198/// merges never disturb a receipt:
199///
200/// 1. A branch whose diff leaves the scoped source untouched owes no decision
201/// and passes. The scoped source is the union of `scope` and every
202/// repo-root-relative `extra_scopes` entry (a shared source tree beside the
203/// package — a native core bound into several bindings — which no `scope`
204/// at-or-below `repo` can reach), minus the `excludes` (feature-gated
205/// subtrees compiled out of the package). Receipts and the retired
206/// single-file attestation are never scoped source.
207/// 2. Otherwise the branch passes when its diff **adds or updates** a receipt
208/// under `repo`'s receipts directory. A deletion (the prune) is not a
209/// decision.
210///
211/// Without `base` there is no branch diff to read, so presence is the check: a
212/// committed receipt at `repo` passes.
213pub fn verify_extra_scoped(
214 repo: &Path,
215 scope: &Path,
216 base: Option<&str>,
217 extra_scopes: &[PathBuf],
218 excludes: &[PathBuf],
219) -> Result<Verification> {
220 let Some(base) = base else {
221 return Ok(if has_receipts(repo) {
222 Verification::Fresh
223 } else {
224 Verification::Missing
225 });
226 };
227 validate_scopes(repo, scope, extra_scopes)?;
228
229 // Question 1 — did this branch change the scoped source? `<base>...HEAD`
230 // diffs from the merge base, so only the branch's own changes count.
231 let mut args: Vec<String> = vec![
232 "diff".into(),
233 "--quiet".into(),
234 format!("{base}...HEAD"),
235 "--".into(),
236 relative_pathspec(repo, scope),
237 ];
238 for extra in extra_scopes {
239 args.push(format!(":(top){}", extra.display()));
240 }
241 args.push(format!(":(exclude){RECEIPTS_DIR}"));
242 args.push(format!(":(exclude){LEGACY_ATTESTATION}"));
243 // Receipts and legacy files anywhere in the tree (a monorepo sibling's, an
244 // extra scope's) are never scoped source either.
245 args.push(format!(":(top,exclude,glob)**/{RECEIPTS_DIR}/**"));
246 args.push(format!(":(top,exclude,glob)**/{LEGACY_ATTESTATION}"));
247 for exclude in excludes {
248 args.push(format!(":(top,exclude){}", exclude.display()));
249 }
250 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
251 if !git_diff_changed(repo, &arg_refs)? {
252 return Ok(Verification::Fresh);
253 }
254
255 // Question 2 — does the branch's diff add or update a receipt? The
256 // diff-filter drops deletions, so the prune never counts as a decision.
257 let out = git_capture(
258 repo,
259 &[
260 "diff",
261 "--name-only",
262 "--diff-filter=ACMRT",
263 &format!("{base}...HEAD"),
264 "--",
265 RECEIPTS_DIR,
266 ],
267 )?;
268 Ok(if out.is_empty() {
269 Verification::Missing
270 } else {
271 Verification::Fresh
272 })
273}
274
275/// `true` when a receipt (`*.json` under [`RECEIPTS_DIR`]) sits at `repo`.
276fn has_receipts(repo: &Path) -> bool {
277 let Ok(entries) = std::fs::read_dir(repo.join(RECEIPTS_DIR)) else {
278 return false;
279 };
280 entries
281 .flatten()
282 .any(|e| e.path().extension().is_some_and(|ext| ext == "json") && e.path().is_file())
283}
284
285/// `scope` as a pathspec relative to `repo` (git resolves pathspecs relative to
286/// the invocation's cwd, which is always `repo` here). `.` when `scope` is
287/// `repo` itself.
288fn relative_pathspec(repo: &Path, scope: &Path) -> String {
289 if scope == repo {
290 return ".".to_string();
291 }
292 match scope.strip_prefix(repo) {
293 Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().into_owned(),
294 _ => scope.to_string_lossy().into_owned(),
295 }
296}
297
298/// Confirm `scope` and every `extra_scope` name at least one path git tracks under
299/// `repo`, erroring loudly on one that matches nothing (#391).
300///
301/// A typo'd or outside `scope` otherwise falls through [`relative_pathspec`] as a
302/// pathspec matching nothing, and a diff over nothing is always empty — a branch
303/// that changed real source would pass forever. Each `extra_scope` has the same
304/// failure mode: a misspelled shared-tree root silently drops out of the scoped
305/// diff. Confirming the pathspec matches a tracked path first turns both into an
306/// honest error naming the bad scope.
307fn validate_scopes(repo: &Path, scope: &Path, extra_scopes: &[PathBuf]) -> Result<()> {
308 let scope_spec = relative_pathspec(repo, scope);
309 if !pathspec_matches_tracked(repo, &scope_spec)? {
310 bail!(
311 "e2e verify: --scope `{}` matches no tracked path under `{}` — \
312 --scope must name `{}` or a directory beneath it that git tracks",
313 scope.display(),
314 repo.display(),
315 repo.display(),
316 );
317 }
318 for extra in extra_scopes {
319 let extra_spec = format!(":(top){}", extra.display());
320 if !pathspec_matches_tracked(repo, &extra_spec)? {
321 bail!(
322 "e2e verify: --extra-scope `{}` matches no tracked path — \
323 --extra-scope must name a repo-root-relative directory that git tracks",
324 extra.display(),
325 );
326 }
327 }
328 Ok(())
329}
330
331/// `true` when git tracks at least one path matching `pathspec` (run with cwd
332/// `repo`). A pathspec git rejects as outside the repository exits non-zero; that
333/// is treated identically to "matches nothing" — either way the scope names no
334/// tracked path.
335fn pathspec_matches_tracked(repo: &Path, pathspec: &str) -> Result<bool> {
336 let out = Command::new("git")
337 .args(["ls-files", "--", pathspec])
338 .current_dir(repo)
339 .output()
340 .with_context(|| format!("running `git ls-files -- {pathspec}`"))?;
341 Ok(out.status.success() && !out.stdout.is_empty())
342}
343
344/// Run `git diff --quiet …` in `repo`: `false` for no differences, `true` for
345/// differences, an error (with git's stderr) for anything else — a bad base
346/// ref must fail loudly, never read as "no changes".
347fn git_diff_changed(repo: &Path, args: &[&str]) -> Result<bool> {
348 let out = Command::new("git")
349 .args(args)
350 .current_dir(repo)
351 .output()
352 .with_context(|| format!("running `git {}`", args.join(" ")))?;
353 match out.status.code() {
354 Some(0) => Ok(false),
355 Some(1) => Ok(true),
356 _ => bail!(
357 "`git {}` failed: {}",
358 args.join(" "),
359 String::from_utf8_lossy(&out.stderr).trim()
360 ),
361 }
362}
363
364/// Run `git` with `args` in `repo`, returning trimmed stdout; errors if git fails.
365fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
366 let out = Command::new("git")
367 .args(args)
368 .current_dir(repo)
369 .output()
370 .with_context(|| format!("running `git {}`", args.join(" ")))?;
371 if !out.status.success() {
372 bail!(
373 "`git {}` failed: {}",
374 args.join(" "),
375 String::from_utf8_lossy(&out.stderr).trim()
376 );
377 }
378 Ok(String::from_utf8(out.stdout)?.trim().to_string())
379}
380
381/// Run `git` with `args` in `repo` for its side effect; errors if git fails.
382fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
383 let status = Command::new("git")
384 .args(args)
385 .current_dir(repo)
386 .status()
387 .with_context(|| format!("running `git {}`", args.join(" ")))?;
388 if !status.success() {
389 bail!("`git {}` failed", args.join(" "));
390 }
391 Ok(())
392}
393
394#[cfg(test)]
395mod tests {
396 use super::branch_slug;
397
398 #[test]
399 fn slug_lowercases_and_maps_separators() {
400 assert_eq!(branch_slug("feature/one"), "feature-one");
401 assert_eq!(branch_slug("Feature/One"), "feature-one");
402 assert_eq!(
403 branch_slug("claude/e2e-attestation-conflicts-mrkc1b"),
404 "claude-e2e-attestation-conflicts-mrkc1b"
405 );
406 }
407
408 #[test]
409 fn slug_keeps_dots_and_underscores() {
410 assert_eq!(branch_slug("v1.2_rc"), "v1.2_rc");
411 }
412
413 #[test]
414 fn slug_collapses_runs_and_trims_edges() {
415 assert_eq!(branch_slug("wip//Émil's"), "wip-mil-s");
416 assert_eq!(branch_slug("--dashes--"), "dashes");
417 assert_eq!(branch_slug(".hidden."), "hidden");
418 }
419
420 #[test]
421 fn slug_truncates_to_80() {
422 let long = "x".repeat(300);
423 assert_eq!(branch_slug(&long).len(), 80);
424 }
425
426 #[test]
427 fn slug_never_returns_empty() {
428 assert_eq!(branch_slug(""), "branch");
429 assert_eq!(branch_slug("É"), "branch");
430 }
431}