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 = Command::new("sh")
76 .arg("-c")
77 .arg(command)
78 .current_dir(repo)
79 .status()
80 .with_context(|| format!("running e2e command `{command}`"))?;
81 let exit_code = status.code().unwrap_or(-1);
82
83 let ran_at = SystemTime::now()
84 .duration_since(UNIX_EPOCH)
85 .map(|d| d.as_secs())
86 .unwrap_or(0);
87
88 let attestation = Attestation {
89 command: command.to_string(),
90 ran_at,
91 exit_code,
92 commit,
93 branch: branch.clone(),
94 };
95
96 if exit_code != 0 {
97 return Ok(attestation);
98 }
99
100 let dir = repo.join(RECEIPTS_DIR);
103 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
104 let path = dir.join(format!("{}.json", branch_slug(&branch)));
105 let json = serde_json::to_string_pretty(&attestation).context("serializing the receipt")?;
106 std::fs::write(&path, format!("{json}\n"))
107 .with_context(|| format!("writing {}", path.display()))?;
108 git_run(repo, &["add", "-A", "--", RECEIPTS_DIR])?;
109
110 let message = format!("e2e attestation for {branch}");
111 git_run(repo, &["commit", "-q", "-m", message.as_str()])?;
114
115 Ok(attestation)
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Verification {
121 Fresh,
123 Missing,
125}
126
127pub fn verify(repo: &Path) -> Result<Verification> {
130 verify_scoped(repo, repo)
131}
132
133pub fn verify_scoped(repo: &Path, scope: &Path) -> Result<Verification> {
137 verify_since(repo, scope, None)
138}
139
140pub fn verify_since(repo: &Path, scope: &Path, base: Option<&str>) -> Result<Verification> {
142 verify_extra_scoped(repo, scope, base, &[], &[])
143}
144
145pub fn verify_extra_scoped(
149 repo: &Path,
150 scope: &Path,
151 base: Option<&str>,
152 extra_scopes: &[PathBuf],
153 excludes: &[PathBuf],
154) -> Result<Verification> {
155 let Some(base) = base else {
156 return Ok(if has_receipts(repo) {
157 Verification::Fresh
158 } else {
159 Verification::Missing
160 });
161 };
162 validate_scopes(repo, scope, extra_scopes)?;
163
164 let mut args: Vec<String> = vec![
166 "diff".into(),
167 "--quiet".into(),
168 format!("{base}...HEAD"),
169 "--".into(),
170 relative_pathspec(repo, scope),
171 ];
172 for extra in extra_scopes {
173 args.push(format!(":(top){}", extra.display()));
174 }
175 args.push(format!(":(exclude){RECEIPTS_DIR}"));
176 args.push(format!(":(exclude){LEGACY_ATTESTATION}"));
177 args.push(format!(":(top,exclude,glob)**/{RECEIPTS_DIR}/**"));
180 args.push(format!(":(top,exclude,glob)**/{LEGACY_ATTESTATION}"));
181 for exclude in excludes {
182 args.push(format!(":(top,exclude){}", exclude.display()));
183 }
184 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
185 if !git_diff_changed(repo, &arg_refs)? {
186 return Ok(Verification::Fresh);
187 }
188
189 let out = git_capture(
192 repo,
193 &[
194 "diff",
195 "--name-only",
196 "--diff-filter=ACMRT",
197 &format!("{base}...HEAD"),
198 "--",
199 RECEIPTS_DIR,
200 ],
201 )?;
202 Ok(if out.is_empty() {
203 Verification::Missing
204 } else {
205 Verification::Fresh
206 })
207}
208
209fn has_receipts(repo: &Path) -> bool {
211 let Ok(entries) = std::fs::read_dir(repo.join(RECEIPTS_DIR)) else {
212 return false;
213 };
214 entries
215 .flatten()
216 .any(|e| e.path().extension().is_some_and(|ext| ext == "json") && e.path().is_file())
217}
218
219fn relative_pathspec(repo: &Path, scope: &Path) -> String {
222 if scope == repo {
223 return ".".to_string();
224 }
225 match scope.strip_prefix(repo) {
226 Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().into_owned(),
227 _ => scope.to_string_lossy().into_owned(),
228 }
229}
230
231fn validate_scopes(repo: &Path, scope: &Path, extra_scopes: &[PathBuf]) -> Result<()> {
235 let scope_spec = relative_pathspec(repo, scope);
236 if !pathspec_matches_tracked(repo, &scope_spec)? {
237 bail!(
238 "e2e verify: --scope `{}` matches no tracked path under `{}` — \
239 --scope must name `{}` or a directory beneath it that git tracks",
240 scope.display(),
241 repo.display(),
242 repo.display(),
243 );
244 }
245 for extra in extra_scopes {
246 let extra_spec = format!(":(top){}", extra.display());
247 if !pathspec_matches_tracked(repo, &extra_spec)? {
248 bail!(
249 "e2e verify: --extra-scope `{}` matches no tracked path — \
250 --extra-scope must name a repo-root-relative directory that git tracks",
251 extra.display(),
252 );
253 }
254 }
255 Ok(())
256}
257
258fn pathspec_matches_tracked(repo: &Path, pathspec: &str) -> Result<bool> {
261 let out = Command::new("git")
262 .args(["ls-files", "--", pathspec])
263 .current_dir(repo)
264 .output()
265 .with_context(|| format!("running `git ls-files -- {pathspec}`"))?;
266 Ok(out.status.success() && !out.stdout.is_empty())
267}
268
269fn git_diff_changed(repo: &Path, args: &[&str]) -> Result<bool> {
272 let out = Command::new("git")
273 .args(args)
274 .current_dir(repo)
275 .output()
276 .with_context(|| format!("running `git {}`", args.join(" ")))?;
277 match out.status.code() {
278 Some(0) => Ok(false),
279 Some(1) => Ok(true),
280 _ => bail!(
281 "`git {}` failed: {}",
282 args.join(" "),
283 String::from_utf8_lossy(&out.stderr).trim()
284 ),
285 }
286}
287
288fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
290 let out = Command::new("git")
291 .args(args)
292 .current_dir(repo)
293 .output()
294 .with_context(|| format!("running `git {}`", args.join(" ")))?;
295 if !out.status.success() {
296 bail!(
297 "`git {}` failed: {}",
298 args.join(" "),
299 String::from_utf8_lossy(&out.stderr).trim()
300 );
301 }
302 Ok(String::from_utf8(out.stdout)?.trim().to_string())
303}
304
305fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
307 let status = Command::new("git")
308 .args(args)
309 .current_dir(repo)
310 .status()
311 .with_context(|| format!("running `git {}`", args.join(" ")))?;
312 if !status.success() {
313 bail!("`git {}` failed", args.join(" "));
314 }
315 Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320 use super::branch_slug;
321
322 #[test]
323 fn slug_lowercases_and_maps_separators() {
324 assert_eq!(branch_slug("feature/one"), "feature-one");
325 assert_eq!(branch_slug("Feature/One"), "feature-one");
326 assert_eq!(
327 branch_slug("claude/e2e-attestation-conflicts-mrkc1b"),
328 "claude-e2e-attestation-conflicts-mrkc1b"
329 );
330 }
331
332 #[test]
333 fn slug_keeps_dots_and_underscores() {
334 assert_eq!(branch_slug("v1.2_rc"), "v1.2_rc");
335 }
336
337 #[test]
338 fn slug_collapses_runs_and_trims_edges() {
339 assert_eq!(branch_slug("wip//Émil's"), "wip-mil-s");
340 assert_eq!(branch_slug("--dashes--"), "dashes");
341 assert_eq!(branch_slug(".hidden."), "hidden");
342 }
343
344 #[test]
345 fn slug_truncates_to_80() {
346 let long = "x".repeat(300);
347 assert_eq!(branch_slug(&long).len(), 80);
348 }
349
350 #[test]
351 fn slug_never_returns_empty() {
352 assert_eq!(branch_slug(""), "branch");
353 assert_eq!(branch_slug("É"), "branch");
354 }
355}