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> {
147 verify_extra_scoped(repo, scope, base, &[], &[])
148}
149
150pub fn verify_extra_scoped(
154 repo: &Path,
155 scope: &Path,
156 base: Option<&str>,
157 extra_scopes: &[PathBuf],
158 excludes: &[PathBuf],
159) -> Result<Verification> {
160 let Some(base) = base else {
161 return Ok(if has_receipts(repo) {
162 Verification::Fresh
163 } else {
164 Verification::Missing
165 });
166 };
167 validate_scopes(repo, scope, extra_scopes)?;
168
169 let mut args: Vec<String> = vec![
171 "diff".into(),
172 "--quiet".into(),
173 format!("{base}...HEAD"),
174 "--".into(),
175 relative_pathspec(repo, scope),
176 ];
177 for extra in extra_scopes {
178 args.push(format!(":(top){}", extra.display()));
179 }
180 args.push(format!(":(exclude){RECEIPTS_DIR}"));
181 args.push(format!(":(exclude){LEGACY_ATTESTATION}"));
182 args.push(format!(":(top,exclude,glob)**/{RECEIPTS_DIR}/**"));
185 args.push(format!(":(top,exclude,glob)**/{LEGACY_ATTESTATION}"));
186 for exclude in excludes {
187 args.push(format!(":(top,exclude){}", exclude.display()));
188 }
189 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
190 if !git_diff_changed(repo, &arg_refs)? {
191 return Ok(Verification::Fresh);
192 }
193
194 let range = format!("{base}...HEAD");
197 let receipt_diff = [
198 "diff",
199 "--name-only",
200 "--diff-filter=ACMRT",
201 &range,
202 "--",
203 RECEIPTS_DIR,
204 ];
205 let out = git_capture(repo, &receipt_diff)?;
206 Ok(if out.is_empty() {
207 Verification::Missing
208 } else {
209 Verification::Fresh
210 })
211}
212
213fn has_receipts(repo: &Path) -> bool {
215 let Ok(entries) = std::fs::read_dir(repo.join(RECEIPTS_DIR)) else {
216 return false;
217 };
218 entries
219 .flatten()
220 .any(|e| e.path().extension().is_some_and(|ext| ext == "json") && e.path().is_file())
221}
222
223fn relative_pathspec(repo: &Path, scope: &Path) -> String {
226 if scope == repo {
227 return ".".to_string();
228 }
229 match scope.strip_prefix(repo) {
230 Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().into_owned(),
231 _ => scope.to_string_lossy().into_owned(),
232 }
233}
234
235fn validate_scopes(repo: &Path, scope: &Path, extra_scopes: &[PathBuf]) -> Result<()> {
239 let scope_spec = relative_pathspec(repo, scope);
240 if !pathspec_matches_tracked(repo, &scope_spec)? {
241 bail!(
242 "e2e verify: --scope `{}` matches no tracked path under `{}` — \
243 --scope must name `{}` or a directory beneath it that git tracks",
244 scope.display(),
245 repo.display(),
246 repo.display(),
247 );
248 }
249 for extra in extra_scopes {
250 let extra_spec = format!(":(top){}", extra.display());
251 if !pathspec_matches_tracked(repo, &extra_spec)? {
252 bail!(
253 "e2e verify: --extra-scope `{}` matches no tracked path — \
254 --extra-scope must name a repo-root-relative directory that git tracks",
255 extra.display(),
256 );
257 }
258 }
259 Ok(())
260}
261
262fn pathspec_matches_tracked(repo: &Path, pathspec: &str) -> Result<bool> {
265 let out = Command::new("git")
266 .args(["ls-files", "--", pathspec])
267 .current_dir(repo)
268 .output()
269 .with_context(|| format!("running `git ls-files -- {pathspec}`"))?;
270 Ok(out.status.success() && !out.stdout.is_empty())
271}
272
273fn git_diff_changed(repo: &Path, args: &[&str]) -> Result<bool> {
276 let out = Command::new("git")
277 .args(args)
278 .current_dir(repo)
279 .output()
280 .with_context(|| format!("running `git {}`", args.join(" ")))?;
281 match out.status.code() {
282 Some(0) => Ok(false),
283 Some(1) => Ok(true),
284 _ => bail!(
285 "`git {}` failed: {}",
286 args.join(" "),
287 String::from_utf8_lossy(&out.stderr).trim()
288 ),
289 }
290}
291
292fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
294 let out = Command::new("git")
295 .args(args)
296 .current_dir(repo)
297 .output()
298 .with_context(|| format!("running `git {}`", args.join(" ")))?;
299 if !out.status.success() {
300 bail!(
301 "`git {}` failed: {}",
302 args.join(" "),
303 String::from_utf8_lossy(&out.stderr).trim()
304 );
305 }
306 Ok(String::from_utf8(out.stdout)?.trim().to_string())
307}
308
309fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
311 let status = Command::new("git")
312 .args(args)
313 .current_dir(repo)
314 .status()
315 .with_context(|| format!("running `git {}`", args.join(" ")))?;
316 if !status.success() {
317 bail!("`git {}` failed", args.join(" "));
318 }
319 Ok(())
320}
321
322#[cfg(test)]
323mod tests {
324 use super::{
325 branch_slug, git_capture, git_diff_changed, git_run, pathspec_matches_tracked, run_shell,
326 };
327 use std::path::Path;
328
329 const NOWHERE: &str = "/nonexistent-tc-e2e";
330
331 #[test]
332 fn run_shell_reports_a_spawn_failure_with_the_command() {
333 let err = run_shell(Path::new(NOWHERE), "true").unwrap_err();
334 assert!(format!("{err:#}").contains("running e2e command `true`"));
335 }
336
337 #[test]
338 fn pathspec_check_reports_a_spawn_failure() {
339 let err = pathspec_matches_tracked(Path::new(NOWHERE), "src").unwrap_err();
340 assert!(format!("{err:#}").contains("git ls-files -- src"));
341 }
342
343 #[test]
344 fn diff_check_reports_a_spawn_failure() {
345 let err = git_diff_changed(Path::new(NOWHERE), &["diff", "--quiet"]).unwrap_err();
346 assert!(format!("{err:#}").contains("running `git diff --quiet`"));
347 }
348
349 #[test]
350 fn capture_reports_a_spawn_failure() {
351 let err = git_capture(Path::new(NOWHERE), &["rev-parse", "HEAD"]).unwrap_err();
352 assert!(format!("{err:#}").contains("running `git rev-parse HEAD`"));
353 }
354
355 #[test]
356 fn run_reports_a_spawn_failure() {
357 let err = git_run(Path::new(NOWHERE), &["add", "-A"]).unwrap_err();
358 assert!(format!("{err:#}").contains("running `git add -A`"));
359 }
360
361 #[test]
362 fn slug_lowercases_and_maps_separators() {
363 assert_eq!(branch_slug("feature/one"), "feature-one");
364 assert_eq!(branch_slug("Feature/One"), "feature-one");
365 assert_eq!(
366 branch_slug("claude/e2e-attestation-conflicts-mrkc1b"),
367 "claude-e2e-attestation-conflicts-mrkc1b"
368 );
369 }
370
371 #[test]
372 fn slug_keeps_dots_and_underscores() {
373 assert_eq!(branch_slug("v1.2_rc"), "v1.2_rc");
374 }
375
376 #[test]
377 fn slug_collapses_runs_and_trims_edges() {
378 assert_eq!(branch_slug("wip//Émil's"), "wip-mil-s");
379 assert_eq!(branch_slug("--dashes--"), "dashes");
380 assert_eq!(branch_slug(".hidden."), "hidden");
381 }
382
383 #[test]
384 fn slug_truncates_to_80() {
385 let long = "x".repeat(300);
386 assert_eq!(branch_slug(&long).len(), 80);
387 }
388
389 #[test]
390 fn slug_never_returns_empty() {
391 assert_eq!(branch_slug(""), "branch");
392 assert_eq!(branch_slug("É"), "branch");
393 }
394}