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` and, when it passes, write the branch's receipt to
83/// `repo`/[`RECEIPTS_DIR`]`/<branch_slug>.json` and commit it. Returns the
84/// attestation either way.
85///
86/// The commit only ever **adds**: every other branch's receipt, and any retired
87/// single-file attestation, is left exactly where it is. See the write site for
88/// why a paired delete is unsafe.
89///
90/// A receipt records a run that **passed**, so a non-zero `command` leaves the
91/// receipts directory exactly as it was — the branch's earlier receipt, if any,
92/// stays committed and unmodified. The returned [`Attestation::exit_code`]
93/// carries the failure for the caller to propagate.
94pub fn attest(repo: &Path, command: &str) -> Result<Attestation> {
95 let commit = git_capture(repo, &["rev-parse", "HEAD"])
96 .context("resolving HEAD — `e2e attest` must run inside a git repo with a commit")?;
97 let branch = current_branch(repo)?;
98
99 // Run the e2e command via the shell, streaming its output through.
100 let status = Command::new("sh")
101 .arg("-c")
102 .arg(command)
103 .current_dir(repo)
104 .status()
105 .with_context(|| format!("running e2e command `{command}`"))?;
106 let exit_code = status.code().unwrap_or(-1);
107
108 let ran_at = SystemTime::now()
109 .duration_since(UNIX_EPOCH)
110 .map(|d| d.as_secs())
111 .unwrap_or(0);
112
113 let attestation = Attestation {
114 command: command.to_string(),
115 ran_at,
116 exit_code,
117 commit,
118 branch: branch.clone(),
119 };
120
121 // A failing run writes nothing: a receipt records a suite that passed, and
122 // overwriting an earlier one with a failure would leave a branch that had
123 // attested worse off than before it re-ran.
124 if exit_code != 0 {
125 return Ok(attestation);
126 }
127
128 // Only ever add. Deleting other branches' receipts pairs the delete with
129 // this branch's add, and git's rename detection reads that pair as a rename
130 // whenever the two receipts look alike — which they do, since `command` is
131 // usually byte-identical across a repo's branches and is the longest field.
132 // Two branches off one parent then rename the same source, which is an
133 // unresolvable rename/rename conflict for anyone stacking or working
134 // parallel slices. A pure add has no delete to pair with, so the property
135 // holds regardless of what a receipt contains.
136 //
137 // The same reasoning retires collecting LEGACY_ATTESTATION: that `git rm`
138 // was a delete beside this add too.
139 //
140 // Stale files are inert, not harmful: `verify` asks only whether the
141 // branch's own diff touches any receipt, and excludes both RECEIPTS_DIR and
142 // LEGACY_ATTESTATION from the scope it measures.
143 let dir = repo.join(RECEIPTS_DIR);
144 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
145 let path = dir.join(format!("{}.json", branch_slug(&branch)));
146 let json = serde_json::to_string_pretty(&attestation).context("serializing the receipt")?;
147 std::fs::write(&path, format!("{json}\n"))
148 .with_context(|| format!("writing {}", path.display()))?;
149 git_run(repo, &["add", "-A", "--", RECEIPTS_DIR])?;
150
151 let message = format!("e2e attestation for {branch}");
152 // A plain commit that inherits the repo's signing policy: a repo requiring
153 // verified signatures gets a signed (mergeable) receipt, instead of the
154 // unsigned commit a forced `commit.gpgsign=false` would leave behind.
155 git_run(repo, &["commit", "-q", "-m", message.as_str()])?;
156
157 Ok(attestation)
158}
159
160/// The outcome of [`verify`] — whether a committed receipt answers the branch's
161/// e2e nudge.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum Verification {
164 /// The branch owes no decision (its scoped diff is empty), or a receipt in
165 /// its diff answers the one it owes — the gate passes.
166 Fresh,
167 /// No receipt answers the nudge: the branch changed the scoped source and
168 /// its diff adds or updates no receipt (or, without a base, no committed
169 /// receipt is present at all) — the gate fails.
170 Missing,
171}
172
173/// Verify the e2e decision at `repo` — the CI side of the nudge. Reads only
174/// receipt presence and content diffs: never runs e2e, never inspects a
175/// recorded command or exit code, never compares commit SHAs.
176///
177/// Equivalent to [`verify_scoped`] with `scope` set to `repo`.
178pub fn verify(repo: &Path) -> Result<Verification> {
179 verify_scoped(repo, repo)
180}
181
182/// Verify the e2e decision at `repo`, with `scope` (rather than all of `repo`)
183/// defining what counts as scoped source.
184///
185/// `repo` and `scope` serve different roles: `repo` is where the receipts live
186/// (the package root — a manifest's own natural home), while `scope` is what
187/// counts as "code" (the directory a `source`-scoped call actually named, which
188/// can be narrower — a package root commonly also holds `tests/`, docs, and
189/// config files that aren't the attestable source). `scope` must be `repo` or
190/// a descendant of it.
191///
192/// Equivalent to [`verify_since`] with no `base`.
193pub fn verify_scoped(repo: &Path, scope: &Path) -> Result<Verification> {
194 verify_since(repo, scope, None)
195}
196
197/// Equivalent to [`verify_extra_scoped`] with no extra roots and no excludes.
198pub fn verify_since(repo: &Path, scope: &Path, base: Option<&str>) -> Result<Verification> {
199 verify_extra_scoped(repo, scope, base, &[], &[])
200}
201
202/// Verify the e2e decision at `repo`, joining **extra scopes** outside `scope`
203/// into what counts as scoped source.
204///
205/// With `base`, both checks are content diffs of `<base>...HEAD`, read from
206/// the merge base — indifferent to commit identity, so rebases and squash
207/// merges never disturb a receipt:
208///
209/// 1. A branch whose diff leaves the scoped source untouched owes no decision
210/// and passes. The scoped source is the union of `scope` and every
211/// repo-root-relative `extra_scopes` entry (a shared source tree beside the
212/// package — a native core bound into several bindings — which no `scope`
213/// at-or-below `repo` can reach), minus the `excludes` (feature-gated
214/// subtrees compiled out of the package). Receipts and the retired
215/// single-file attestation are never scoped source.
216/// 2. Otherwise the branch passes when its diff **adds or updates** a receipt
217/// under `repo`'s receipts directory. A deletion is not a decision.
218///
219/// Without `base` there is no branch diff to read, so presence is the check: a
220/// committed receipt at `repo` passes.
221pub fn verify_extra_scoped(
222 repo: &Path,
223 scope: &Path,
224 base: Option<&str>,
225 extra_scopes: &[PathBuf],
226 excludes: &[PathBuf],
227) -> Result<Verification> {
228 let Some(base) = base else {
229 return Ok(if has_receipts(repo) {
230 Verification::Fresh
231 } else {
232 Verification::Missing
233 });
234 };
235 validate_scopes(repo, scope, extra_scopes)?;
236
237 // Question 1 — did this branch change the scoped source? `<base>...HEAD`
238 // diffs from the merge base, so only the branch's own changes count.
239 let mut args: Vec<String> = vec![
240 "diff".into(),
241 "--quiet".into(),
242 format!("{base}...HEAD"),
243 "--".into(),
244 relative_pathspec(repo, scope),
245 ];
246 for extra in extra_scopes {
247 args.push(format!(":(top){}", extra.display()));
248 }
249 args.push(format!(":(exclude){RECEIPTS_DIR}"));
250 args.push(format!(":(exclude){LEGACY_ATTESTATION}"));
251 // Receipts and legacy files anywhere in the tree (a monorepo sibling's, an
252 // extra scope's) are never scoped source either.
253 args.push(format!(":(top,exclude,glob)**/{RECEIPTS_DIR}/**"));
254 args.push(format!(":(top,exclude,glob)**/{LEGACY_ATTESTATION}"));
255 for exclude in excludes {
256 args.push(format!(":(top,exclude){}", exclude.display()));
257 }
258 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
259 if !git_diff_changed(repo, &arg_refs)? {
260 return Ok(Verification::Fresh);
261 }
262
263 // Question 2 — does the branch's diff add or update a receipt? The
264 // diff-filter drops deletions, so sweeping a stale receipt by hand never
265 // counts as a decision.
266 let out = git_capture(
267 repo,
268 &[
269 "diff",
270 "--name-only",
271 "--diff-filter=ACMRT",
272 &format!("{base}...HEAD"),
273 "--",
274 RECEIPTS_DIR,
275 ],
276 )?;
277 Ok(if out.is_empty() {
278 Verification::Missing
279 } else {
280 Verification::Fresh
281 })
282}
283
284/// `true` when a receipt (`*.json` under [`RECEIPTS_DIR`]) sits at `repo`.
285fn has_receipts(repo: &Path) -> bool {
286 let Ok(entries) = std::fs::read_dir(repo.join(RECEIPTS_DIR)) else {
287 return false;
288 };
289 entries
290 .flatten()
291 .any(|e| e.path().extension().is_some_and(|ext| ext == "json") && e.path().is_file())
292}
293
294/// `scope` as a pathspec relative to `repo` (git resolves pathspecs relative to
295/// the invocation's cwd, which is always `repo` here). `.` when `scope` is
296/// `repo` itself.
297fn relative_pathspec(repo: &Path, scope: &Path) -> String {
298 if scope == repo {
299 return ".".to_string();
300 }
301 match scope.strip_prefix(repo) {
302 Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().into_owned(),
303 _ => scope.to_string_lossy().into_owned(),
304 }
305}
306
307/// Confirm `scope` and every `extra_scope` name at least one path git tracks under
308/// `repo`, erroring loudly on one that matches nothing (#391).
309///
310/// A typo'd or outside `scope` otherwise falls through [`relative_pathspec`] as a
311/// pathspec matching nothing, and a diff over nothing is always empty — a branch
312/// that changed real source would pass forever. Each `extra_scope` has the same
313/// failure mode: a misspelled shared-tree root silently drops out of the scoped
314/// diff. Confirming the pathspec matches a tracked path first turns both into an
315/// honest error naming the bad scope.
316fn validate_scopes(repo: &Path, scope: &Path, extra_scopes: &[PathBuf]) -> Result<()> {
317 let scope_spec = relative_pathspec(repo, scope);
318 if !pathspec_matches_tracked(repo, &scope_spec)? {
319 bail!(
320 "e2e verify: --scope `{}` matches no tracked path under `{}` — \
321 --scope must name `{}` or a directory beneath it that git tracks",
322 scope.display(),
323 repo.display(),
324 repo.display(),
325 );
326 }
327 for extra in extra_scopes {
328 let extra_spec = format!(":(top){}", extra.display());
329 if !pathspec_matches_tracked(repo, &extra_spec)? {
330 bail!(
331 "e2e verify: --extra-scope `{}` matches no tracked path — \
332 --extra-scope must name a repo-root-relative directory that git tracks",
333 extra.display(),
334 );
335 }
336 }
337 Ok(())
338}
339
340/// `true` when git tracks at least one path matching `pathspec` (run with cwd
341/// `repo`). A pathspec git rejects as outside the repository exits non-zero; that
342/// is treated identically to "matches nothing" — either way the scope names no
343/// tracked path.
344fn pathspec_matches_tracked(repo: &Path, pathspec: &str) -> Result<bool> {
345 let out = Command::new("git")
346 .args(["ls-files", "--", pathspec])
347 .current_dir(repo)
348 .output()
349 .with_context(|| format!("running `git ls-files -- {pathspec}`"))?;
350 Ok(out.status.success() && !out.stdout.is_empty())
351}
352
353/// Run `git diff --quiet …` in `repo`: `false` for no differences, `true` for
354/// differences, an error (with git's stderr) for anything else — a bad base
355/// ref must fail loudly, never read as "no changes".
356fn git_diff_changed(repo: &Path, args: &[&str]) -> Result<bool> {
357 let out = Command::new("git")
358 .args(args)
359 .current_dir(repo)
360 .output()
361 .with_context(|| format!("running `git {}`", args.join(" ")))?;
362 match out.status.code() {
363 Some(0) => Ok(false),
364 Some(1) => Ok(true),
365 _ => bail!(
366 "`git {}` failed: {}",
367 args.join(" "),
368 String::from_utf8_lossy(&out.stderr).trim()
369 ),
370 }
371}
372
373/// Run `git` with `args` in `repo`, returning trimmed stdout; errors if git fails.
374fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
375 let out = Command::new("git")
376 .args(args)
377 .current_dir(repo)
378 .output()
379 .with_context(|| format!("running `git {}`", args.join(" ")))?;
380 if !out.status.success() {
381 bail!(
382 "`git {}` failed: {}",
383 args.join(" "),
384 String::from_utf8_lossy(&out.stderr).trim()
385 );
386 }
387 Ok(String::from_utf8(out.stdout)?.trim().to_string())
388}
389
390/// Run `git` with `args` in `repo` for its side effect; errors if git fails.
391fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
392 let status = Command::new("git")
393 .args(args)
394 .current_dir(repo)
395 .status()
396 .with_context(|| format!("running `git {}`", args.join(" ")))?;
397 if !status.success() {
398 bail!("`git {}` failed", args.join(" "));
399 }
400 Ok(())
401}
402
403#[cfg(test)]
404mod tests {
405 use super::branch_slug;
406
407 #[test]
408 fn slug_lowercases_and_maps_separators() {
409 assert_eq!(branch_slug("feature/one"), "feature-one");
410 assert_eq!(branch_slug("Feature/One"), "feature-one");
411 assert_eq!(
412 branch_slug("claude/e2e-attestation-conflicts-mrkc1b"),
413 "claude-e2e-attestation-conflicts-mrkc1b"
414 );
415 }
416
417 #[test]
418 fn slug_keeps_dots_and_underscores() {
419 assert_eq!(branch_slug("v1.2_rc"), "v1.2_rc");
420 }
421
422 #[test]
423 fn slug_collapses_runs_and_trims_edges() {
424 assert_eq!(branch_slug("wip//Émil's"), "wip-mil-s");
425 assert_eq!(branch_slug("--dashes--"), "dashes");
426 assert_eq!(branch_slug(".hidden."), "hidden");
427 }
428
429 #[test]
430 fn slug_truncates_to_80() {
431 let long = "x".repeat(300);
432 assert_eq!(branch_slug(&long).len(), 80);
433 }
434
435 #[test]
436 fn slug_never_returns_empty() {
437 assert_eq!(branch_slug(""), "branch");
438 assert_eq!(branch_slug("É"), "branch");
439 }
440}