1use 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
11pub const RECEIPTS_DIR: &str = "e2e-attestations";
13
14const LEGACY_ATTESTATION: &str = "e2e-attestation.json";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Attestation {
21 pub command: String,
23 pub ran_at: u64,
25 pub exit_code: i32,
27 pub commit: String,
29 #[serde(default)]
31 pub branch: String,
32}
33
34pub 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
59pub(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
67pub 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 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 git_run(repo, &["commit", "-q", "-m", message.as_str()])?;
109
110 Ok(attestation)
111}
112
113fn 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#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum Verification {
126 Fresh,
128 Missing,
130}
131
132pub fn verify(repo: &Path) -> Result<Verification> {
135 verify_scoped(repo, repo)
136}
137
138pub fn verify_scoped(repo: &Path, scope: &Path) -> Result<Verification> {
142 verify_since(repo, scope, None)
143}
144
145pub fn verify_since(repo: &Path, scope: &Path, base: Option<&str>) -> Result<Verification> {
148 verify_extra_scoped(repo, scope, base, &[], &[], None)
149}
150
151pub 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 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 receipt_freshness(repo, base, branch)
180}
181
182fn 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
208fn 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
238fn 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
248fn 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
258fn 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
270fn 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
287fn 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
298fn 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
308fn 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
319fn 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
338fn 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
355fn 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 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 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 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 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}