Skip to main content

testing_conventions/
e2e.rs

1//! `e2e attest` / `e2e verify` — the e2e decision nudge. `attest` records the runner's chosen
2//! command as a branch-keyed receipt; `verify` confirms a branch changing scoped source has one.
3
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use anyhow::{bail, Context, Result};
9use serde::{Deserialize, Serialize};
10
11/// Where the branch-keyed receipts live, relative to the package root: `<branch_slug>.json`.
12pub const RECEIPTS_DIR: &str = "e2e-attestations";
13
14/// The retired single-file attestation location: never a receipt, never scoped source.
15const LEGACY_ATTESTATION: &str = "e2e-attestation.json";
16
17/// A record of one e2e decision, written to `RECEIPTS_DIR/<branch_slug>.json`. Everything
18/// here is for humans — [`verify`] reads only the receipt's presence in the branch's diff.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Attestation {
21    /// The command that was run (e.g. `pnpm run e2e`) — the judgment itself.
22    pub command: String,
23    /// When it ran, as a Unix timestamp (seconds).
24    pub ran_at: u64,
25    /// The command's exit code — recorded, never gated on.
26    pub exit_code: i32,
27    /// The commit the run was made against (HEAD at attest time).
28    pub commit: String,
29    /// The raw branch name the receipt is keyed by; the filename carries only its slug.
30    #[serde(default)]
31    pub branch: String,
32}
33
34/// The standardized receipt slug for a branch name — the receipt lives at
35/// `e2e-attestations/<slug>.json`. Lowercased; every character outside `[a-z0-9._-]` becomes
36/// `-`; runs collapse; truncated to 80; edges trimmed; an empty result falls back to `branch`.
37pub fn branch_slug(branch: &str) -> String {
38    let mut slug = String::new();
39    for c in branch.to_lowercase().chars() {
40        let mapped = if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' {
41            c
42        } else {
43            '-'
44        };
45        if mapped == '-' && slug.ends_with('-') {
46            continue;
47        }
48        slug.push(mapped);
49    }
50    let slug: String = slug.chars().take(80).collect();
51    let slug = slug.trim_matches(|c| c == '-' || c == '.');
52    if slug.is_empty() {
53        "branch".to_string()
54    } else {
55        slug.to_string()
56    }
57}
58
59/// The checked-out branch of `repo`; a detached HEAD is an error naming the fix.
60pub(crate) fn current_branch(repo: &Path) -> Result<String> {
61    git_capture(repo, &["symbolic-ref", "--short", "-q", "HEAD"]).context(
62        "resolving the current branch — the receipt is keyed by branch, so this \
63         must run on a checked-out branch (a detached HEAD has none): `git switch <branch>`",
64    )
65}
66
67/// Run `command` in `repo` and, when it passes, write and commit the branch's receipt at
68/// `repo`/[`RECEIPTS_DIR`]`/<branch_slug>.json`. A non-zero `command` leaves the receipts
69/// untouched; the returned [`Attestation::exit_code`] carries the failure either way.
70pub fn attest(repo: &Path, command: &str) -> Result<Attestation> {
71    let commit = git_capture(repo, &["rev-parse", "HEAD"])
72        .context("resolving HEAD — `e2e attest` must run inside a git repo with a commit")?;
73    let branch = current_branch(repo)?;
74
75    let status = run_shell(repo, command)?;
76    let exit_code = status.code().unwrap_or(-1);
77
78    let ran_at = SystemTime::now()
79        .duration_since(UNIX_EPOCH)
80        .map(|d| d.as_secs())
81        .unwrap_or(0);
82
83    let attestation = Attestation {
84        command: command.to_string(),
85        ran_at,
86        exit_code,
87        commit,
88        branch: branch.clone(),
89    };
90
91    if exit_code != 0 {
92        return Ok(attestation);
93    }
94
95    // Only ever add: a paired delete reads as a rename to git and conflicts across parallel
96    // branches — `docs/explanation/e2e.md`.
97    let dir = repo.join(RECEIPTS_DIR);
98    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
99    let path = dir.join(format!("{}.json", branch_slug(&branch)));
100    let json = serde_json::to_string_pretty(&attestation).context("serializing the receipt")?;
101    std::fs::write(&path, format!("{json}\n"))
102        .with_context(|| format!("writing {}", path.display()))?;
103    git_run(repo, &["add", "-A", "--", RECEIPTS_DIR])?;
104
105    let message = format!("e2e attestation for {branch}");
106    // A plain commit inherits the repo's signing policy, so a repo requiring verified
107    // signatures gets a signed (mergeable) receipt.
108    git_run(repo, &["commit", "-q", "-m", message.as_str()])?;
109
110    Ok(attestation)
111}
112
113/// Run `command` through `sh -c` in `repo`, returning its exit status.
114fn run_shell(repo: &Path, command: &str) -> Result<std::process::ExitStatus> {
115    Command::new("sh")
116        .arg("-c")
117        .arg(command)
118        .current_dir(repo)
119        .status()
120        .with_context(|| format!("running e2e command `{command}`"))
121}
122
123/// The outcome of [`verify`] — whether a committed receipt answers the branch's e2e nudge.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum Verification {
126    /// The branch owes no decision, or a receipt in its diff answers the one it owes.
127    Fresh,
128    /// No receipt answers the nudge — the gate fails.
129    Missing,
130}
131
132/// Verify the e2e decision at `repo` — the CI side of the nudge. Equivalent to
133/// [`verify_scoped`] with `scope` set to `repo`.
134pub fn verify(repo: &Path) -> Result<Verification> {
135    verify_scoped(repo, repo)
136}
137
138/// Verify the e2e decision at `repo`, with `scope` (rather than all of `repo`) defining what
139/// counts as scoped source; `scope` must be `repo` or a descendant. Equivalent to
140/// [`verify_since`] with no `base`.
141pub fn verify_scoped(repo: &Path, scope: &Path) -> Result<Verification> {
142    verify_since(repo, scope, None)
143}
144
145/// Equivalent to [`verify_extra_scoped`] with no extra roots, no excludes, and no `branch`
146/// override — Question 2 reads the checked-out branch's own receipt.
147pub fn verify_since(repo: &Path, scope: &Path, base: Option<&str>) -> Result<Verification> {
148    verify_extra_scoped(repo, scope, base, &[], &[], None)
149}
150
151/// Verify the e2e decision at `repo`, joining **extra scopes** outside `scope` into what
152/// counts as scoped source and subtracting `excludes`. With `base`, Question 2 scopes to
153/// `branch`'s own receipt (the checked-out branch if absent); without `base`, any receipt suffices.
154pub fn verify_extra_scoped(
155    repo: &Path,
156    scope: &Path,
157    base: Option<&str>,
158    extra_scopes: &[PathBuf],
159    excludes: &[PathBuf],
160    branch: Option<&str>,
161) -> Result<Verification> {
162    let Some(base) = base else {
163        return Ok(if has_receipts(repo) {
164            Verification::Fresh
165        } else {
166            Verification::Missing
167        });
168    };
169    validate_scopes(repo, scope, extra_scopes)?;
170
171    // Question 1 — did this branch change the scoped source?
172    let args = scoped_source_args(repo, scope, base, extra_scopes, excludes);
173    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
174    if !git_diff_changed(repo, &arg_refs)? {
175        return Ok(Verification::Fresh);
176    }
177
178    // Question 2 — does the acting branch's diff add or update *its own* receipt?
179    receipt_freshness(repo, base, branch)
180}
181
182/// Question 2: `Fresh` when `base...HEAD` adds or updates the acting branch's own receipt.
183/// The acting branch is `branch`, else the checked-out one, else none — and with none to
184/// name, every receipt counts. `--diff-filter=ACMRT` drops deletions, so sweeping a stale
185/// receipt by hand never passes as freshness.
186fn receipt_freshness(repo: &Path, base: &str, branch: Option<&str>) -> Result<Verification> {
187    let range = format!("{base}...HEAD");
188    let acting_branch = branch
189        .map(str::to_string)
190        .or_else(|| current_branch(repo).ok());
191    let pathspec = receipt_pathspec(acting_branch.as_deref());
192    let receipt_diff = [
193        "diff",
194        "--name-only",
195        "--diff-filter=ACMRT",
196        &range,
197        "--",
198        &pathspec,
199    ];
200    let out = git_capture(repo, &receipt_diff)?;
201    Ok(if out.is_empty() {
202        Verification::Missing
203    } else {
204        Verification::Fresh
205    })
206}
207
208/// The full `git diff --quiet` argv asking whether `base...HEAD` touched scoped source:
209/// `scope` plus every extra scope, minus `excludes` and minus every receipt path anywhere in
210/// the tree — a receipt is never scoped source, not even a monorepo sibling's.
211fn scoped_source_args(
212    repo: &Path,
213    scope: &Path,
214    base: &str,
215    extra_scopes: &[PathBuf],
216    excludes: &[PathBuf],
217) -> Vec<String> {
218    let mut args: Vec<String> = vec![
219        "diff".into(),
220        "--quiet".into(),
221        format!("{base}...HEAD"),
222        "--".into(),
223        relative_pathspec(repo, scope),
224    ];
225    for extra in extra_scopes {
226        args.push(format!(":(top){}", extra.display()));
227    }
228    args.push(format!(":(exclude){RECEIPTS_DIR}"));
229    args.push(format!(":(exclude){LEGACY_ATTESTATION}"));
230    args.push(format!(":(top,exclude,glob)**/{RECEIPTS_DIR}/**"));
231    args.push(format!(":(top,exclude,glob)**/{LEGACY_ATTESTATION}"));
232    for exclude in excludes {
233        args.push(format!(":(top,exclude){}", exclude.display()));
234    }
235    args
236}
237
238/// The pathspec Question 2 counts receipts under: the acting branch's own receipt file when
239/// there is a branch to name, and otherwise the whole receipts directory, so every receipt
240/// counts.
241fn receipt_pathspec(acting_branch: Option<&str>) -> String {
242    match acting_branch {
243        Some(branch) => format!("{RECEIPTS_DIR}/{}.json", branch_slug(branch)),
244        None => RECEIPTS_DIR.to_string(),
245    }
246}
247
248/// `true` when a receipt (`*.json` under [`RECEIPTS_DIR`]) sits at `repo`.
249fn has_receipts(repo: &Path) -> bool {
250    let Ok(entries) = std::fs::read_dir(repo.join(RECEIPTS_DIR)) else {
251        return false;
252    };
253    entries
254        .flatten()
255        .any(|e| e.path().extension().is_some_and(|ext| ext == "json") && e.path().is_file())
256}
257
258/// `scope` as a pathspec relative to `repo` — git resolves pathspecs against the invocation's
259/// cwd, which is always `repo` here. `.` when `scope` is `repo` itself.
260fn relative_pathspec(repo: &Path, scope: &Path) -> String {
261    if scope == repo {
262        return ".".to_string();
263    }
264    match scope.strip_prefix(repo) {
265        Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().into_owned(),
266        _ => scope.to_string_lossy().into_owned(),
267    }
268}
269
270/// Confirm `scope` and every `extra_scope` name at least one path git tracks under `repo`.
271/// A pathspec matching nothing diffs to empty forever, so a typo'd scope would wave every
272/// branch through; erroring names the bad scope instead.
273fn validate_scopes(repo: &Path, scope: &Path, extra_scopes: &[PathBuf]) -> Result<()> {
274    let scope_spec = relative_pathspec(repo, scope);
275    if !pathspec_matches_tracked(repo, &scope_spec)? {
276        bail!("{}", untracked_scope(repo, scope));
277    }
278    for extra in extra_scopes {
279        let extra_spec = format!(":(top){}", extra.display());
280        if !pathspec_matches_tracked(repo, &extra_spec)? {
281            bail!("{}", untracked_extra_scope(extra));
282        }
283    }
284    Ok(())
285}
286
287/// What a `--scope` matching no tracked path reports, naming the scope git found nothing for.
288fn untracked_scope(repo: &Path, scope: &Path) -> String {
289    format!(
290        "e2e verify: --scope `{}` matches no tracked path under `{}` — \
291         --scope must name `{}` or a directory beneath it that git tracks",
292        scope.display(),
293        repo.display(),
294        repo.display(),
295    )
296}
297
298/// What an `--extra-scope` matching no tracked path reports, naming the scope git found nothing
299/// for.
300fn untracked_extra_scope(extra: &Path) -> String {
301    format!(
302        "e2e verify: --extra-scope `{}` matches no tracked path — \
303         --extra-scope must name a repo-root-relative directory that git tracks",
304        extra.display(),
305    )
306}
307
308/// `true` when git tracks at least one path matching `pathspec` (run with cwd `repo`). A
309/// pathspec git rejects as outside the repository counts as "matches nothing".
310fn pathspec_matches_tracked(repo: &Path, pathspec: &str) -> Result<bool> {
311    let out = Command::new("git")
312        .args(["ls-files", "--", pathspec])
313        .current_dir(repo)
314        .output()
315        .with_context(|| format!("running `git ls-files -- {pathspec}`"))?;
316    Ok(out.status.success() && !out.stdout.is_empty())
317}
318
319/// Run `git diff --quiet …` in `repo`: `false` for no differences, `true` for differences, an
320/// error for anything else — a bad base ref must fail loudly, never read as "no changes".
321fn git_diff_changed(repo: &Path, args: &[&str]) -> Result<bool> {
322    let out = Command::new("git")
323        .args(args)
324        .current_dir(repo)
325        .output()
326        .with_context(|| format!("running `git {}`", args.join(" ")))?;
327    match out.status.code() {
328        Some(0) => Ok(false),
329        Some(1) => Ok(true),
330        _ => bail!(
331            "`git {}` failed: {}",
332            args.join(" "),
333            String::from_utf8_lossy(&out.stderr).trim()
334        ),
335    }
336}
337
338/// Run `git` with `args` in `repo`, returning trimmed stdout; errors if git fails.
339fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
340    let out = Command::new("git")
341        .args(args)
342        .current_dir(repo)
343        .output()
344        .with_context(|| format!("running `git {}`", args.join(" ")))?;
345    if !out.status.success() {
346        bail!(
347            "`git {}` failed: {}",
348            args.join(" "),
349            String::from_utf8_lossy(&out.stderr).trim()
350        );
351    }
352    Ok(String::from_utf8(out.stdout)?.trim().to_string())
353}
354
355/// Run `git` with `args` in `repo` for its side effect; errors if git fails.
356fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
357    let status = Command::new("git")
358        .args(args)
359        .current_dir(repo)
360        .status()
361        .with_context(|| format!("running `git {}`", args.join(" ")))?;
362    if !status.success() {
363        bail!("`git {}` failed", args.join(" "));
364    }
365    Ok(())
366}
367
368#[cfg(test)]
369mod tests {
370    use super::{
371        branch_slug, git_capture, git_diff_changed, git_run, pathspec_matches_tracked,
372        receipt_pathspec, run_shell, scoped_source_args, untracked_extra_scope, untracked_scope,
373        validate_scopes, LEGACY_ATTESTATION, RECEIPTS_DIR,
374    };
375    use std::path::{Path, PathBuf};
376
377    /// A scratch repository with one file staged under `src/`. `ls-files` reads the index, so
378    /// nothing needs committing — and no identity needs configuring.
379    fn repo_tracking_src(slug: &str) -> PathBuf {
380        let repo = std::env::temp_dir().join(format!("tc-e2e-{slug}-{}", std::process::id()));
381        let _ = std::fs::remove_dir_all(&repo);
382        std::fs::create_dir_all(repo.join("src")).unwrap();
383        std::fs::write(repo.join("src").join("a.rs"), "pub fn f() {}\n").unwrap();
384        git_run(&repo, &["init", "-q"]).unwrap();
385        git_run(&repo, &["add", "-A", "--", "src"]).unwrap();
386        repo
387    }
388
389    #[test]
390    fn scope_validation_passes_a_tracked_scope_and_rejects_either_untracked_one() {
391        let repo = repo_tracking_src("validate-scopes");
392        let tracked = repo.join("src");
393
394        validate_scopes(&repo, &tracked, &[PathBuf::from("src")])
395            .expect("a tracked scope and a tracked extra scope both pass");
396
397        // A typo'd `--scope` must error rather than diff to empty and wave the branch through.
398        let err = format!(
399            "{:#}",
400            validate_scopes(&repo, &repo.join("nope"), &[]).unwrap_err()
401        );
402        assert!(err.contains("--scope `"), "got: {err}");
403        assert!(err.contains("matches no tracked path under"), "got: {err}");
404
405        // So must a typo'd `--extra-scope`, even behind a good `--scope`.
406        let err = format!(
407            "{:#}",
408            validate_scopes(&repo, &tracked, &[PathBuf::from("nope")]).unwrap_err()
409        );
410        assert!(err.contains("--extra-scope `nope`"), "got: {err}");
411
412        let _ = std::fs::remove_dir_all(&repo);
413    }
414
415    #[test]
416    fn an_untracked_scope_names_the_scope_the_repository_and_what_scope_must_be() {
417        let message = untracked_scope(Path::new("/repo"), Path::new("/repo/packages/typo"));
418        assert_eq!(
419            message,
420            "e2e verify: --scope `/repo/packages/typo` matches no tracked path under `/repo` — \
421             --scope must name `/repo` or a directory beneath it that git tracks"
422        );
423    }
424
425    #[test]
426    fn an_untracked_extra_scope_names_the_scope_and_that_it_is_repo_root_relative() {
427        let message = untracked_extra_scope(Path::new("internals/typo"));
428        assert_eq!(
429            message,
430            "e2e verify: --extra-scope `internals/typo` matches no tracked path — \
431             --extra-scope must name a repo-root-relative directory that git tracks"
432        );
433    }
434
435    #[test]
436    fn a_named_branch_scopes_question_two_to_its_own_receipt() {
437        assert_eq!(
438            receipt_pathspec(Some("chore/750-ratchet-rust-step4")),
439            format!("{RECEIPTS_DIR}/chore-750-ratchet-rust-step4.json")
440        );
441    }
442
443    #[test]
444    fn with_no_branch_to_name_every_receipt_counts() {
445        assert_eq!(receipt_pathspec(None), RECEIPTS_DIR);
446    }
447
448    #[test]
449    fn the_scoped_source_argv_excludes_every_receipt_path_in_the_tree() {
450        let args = scoped_source_args(
451            Path::new("/repo"),
452            Path::new("/repo/packages/rust"),
453            "main",
454            &[],
455            &[],
456        );
457        assert!(args.starts_with(&[
458            "diff".to_string(),
459            "--quiet".to_string(),
460            "main...HEAD".to_string(),
461            "--".to_string(),
462            "packages/rust".to_string(),
463        ]));
464        // Both the scope-local and the tree-wide form, so a monorepo sibling's receipt is
465        // not scoped source either.
466        assert!(args.contains(&format!(":(exclude){RECEIPTS_DIR}")));
467        assert!(args.contains(&format!(":(top,exclude,glob)**/{RECEIPTS_DIR}/**")));
468        assert!(args.contains(&format!(":(exclude){LEGACY_ATTESTATION}")));
469        assert!(args.contains(&format!(":(top,exclude,glob)**/{LEGACY_ATTESTATION}")));
470    }
471
472    #[test]
473    fn extra_scopes_join_the_argv_and_excludes_subtract_from_it() {
474        let args = scoped_source_args(
475            Path::new("/repo"),
476            Path::new("/repo/packages/rust"),
477            "main",
478            &[PathBuf::from("packages/node")],
479            &[PathBuf::from("packages/rust/docs")],
480        );
481        assert!(args.contains(&":(top)packages/node".to_string()));
482        assert!(args.contains(&":(top,exclude)packages/rust/docs".to_string()));
483    }
484
485    const NOWHERE: &str = "/nonexistent-tc-e2e";
486
487    #[test]
488    fn run_shell_reports_a_spawn_failure_with_the_command() {
489        let err = run_shell(Path::new(NOWHERE), "true").unwrap_err();
490        assert!(format!("{err:#}").contains("running e2e command `true`"));
491    }
492
493    #[test]
494    fn pathspec_check_reports_a_spawn_failure() {
495        let err = pathspec_matches_tracked(Path::new(NOWHERE), "src").unwrap_err();
496        assert!(format!("{err:#}").contains("git ls-files -- src"));
497    }
498
499    #[test]
500    fn diff_check_reports_a_spawn_failure() {
501        let err = git_diff_changed(Path::new(NOWHERE), &["diff", "--quiet"]).unwrap_err();
502        assert!(format!("{err:#}").contains("running `git diff --quiet`"));
503    }
504
505    #[test]
506    fn capture_reports_a_spawn_failure() {
507        let err = git_capture(Path::new(NOWHERE), &["rev-parse", "HEAD"]).unwrap_err();
508        assert!(format!("{err:#}").contains("running `git rev-parse HEAD`"));
509    }
510
511    #[test]
512    fn run_reports_a_spawn_failure() {
513        let err = git_run(Path::new(NOWHERE), &["add", "-A"]).unwrap_err();
514        assert!(format!("{err:#}").contains("running `git add -A`"));
515    }
516
517    #[test]
518    fn slug_lowercases_and_maps_separators() {
519        assert_eq!(branch_slug("feature/one"), "feature-one");
520        assert_eq!(branch_slug("Feature/One"), "feature-one");
521        assert_eq!(
522            branch_slug("claude/e2e-attestation-conflicts-mrkc1b"),
523            "claude-e2e-attestation-conflicts-mrkc1b"
524        );
525    }
526
527    #[test]
528    fn slug_keeps_dots_and_underscores() {
529        assert_eq!(branch_slug("v1.2_rc"), "v1.2_rc");
530    }
531
532    #[test]
533    fn slug_collapses_runs_and_trims_edges() {
534        assert_eq!(branch_slug("wip//Émil's"), "wip-mil-s");
535        assert_eq!(branch_slug("--dashes--"), "dashes");
536        assert_eq!(branch_slug(".hidden."), "hidden");
537    }
538
539    #[test]
540    fn slug_truncates_to_80() {
541        let long = "x".repeat(300);
542        assert_eq!(branch_slug(&long).len(), 80);
543    }
544
545    #[test]
546    fn slug_never_returns_empty() {
547        assert_eq!(branch_slug(""), "branch");
548        assert_eq!(branch_slug("É"), "branch");
549    }
550}