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`, 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}