Skip to main content

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}