Skip to main content

speccoder_runtime/attest/
notes.rs

1//! Attestations as git notes under `refs/notes/speccoder`: git's own place
2//! for per-commit metadata, outside the commit it describes.
3
4use std::path::Path;
5
6use crate::git::Git;
7
8/// The notes ref attestations live under.
9pub const REF: &str = "refs/notes/speccoder";
10
11const IDENTITY: [&str; 6] = [
12    "-c",
13    "user.name=speccoder",
14    "-c",
15    "user.email=speccoder@users.noreply.invalid",
16    "-c",
17    "commit.gpgsign=false",
18];
19
20/// One writer of the notes ref at a time in this process.
21static WRITING: std::sync::Mutex<()> = std::sync::Mutex::new(());
22
23/// Holds the notes ref for one writer across processes. `git notes add`
24/// does not guard its ref update: concurrent writers silently lose each
25/// other's notes (parallel lanes, two speccoder runs on one repository).
26/// The lock is a file in git's common directory, created exclusively;
27/// one older than a minute is a dead writer's and is taken over.
28struct Held(Option<std::path::PathBuf>);
29
30impl Drop for Held {
31    fn drop(&mut self) {
32        if let Some(p) = self.0.take() {
33            let _ = std::fs::remove_file(p);
34        }
35    }
36}
37
38fn hold(repo: &Path) -> Held {
39    let dir = Git::at(repo)
40        .line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
41        .ok()
42        .map(std::path::PathBuf::from);
43    let Some(file) = dir.map(|d| d.join("speccoder-notes.lock")) else {
44        return Held(None);
45    };
46    for _ in 0..200 {
47        match std::fs::OpenOptions::new()
48            .write(true)
49            .create_new(true)
50            .open(&file)
51        {
52            Ok(_) => return Held(Some(file)),
53            Err(_) => {
54                let stale = std::fs::metadata(&file)
55                    .and_then(|m| m.modified())
56                    .ok()
57                    .and_then(|t| t.elapsed().ok())
58                    .is_some_and(|age| age > std::time::Duration::from_secs(60));
59                if stale {
60                    let _ = std::fs::remove_file(&file);
61                } else {
62                    std::thread::sleep(std::time::Duration::from_millis(25));
63                }
64            }
65        }
66    }
67    // Five seconds without the lock: write anyway (the reader verifies).
68    Held(None)
69}
70
71/// Writes `bundle` as `object`'s note, replacing speccoder's earlier one.
72pub fn write(repo: &Path, object: &str, bundle: &str) -> Result<(), String> {
73    let _one = WRITING
74        .lock()
75        .unwrap_or_else(std::sync::PoisonError::into_inner);
76    let _held = hold(repo);
77    let mut args = IDENTITY.to_vec();
78    args.extend(["notes", "--ref=speccoder", "add", "-f", "-F", "-", object]);
79    Git::at(repo)
80        .run_with_stdin(&args, bundle.as_bytes())
81        .map(|_| ())
82        .map_err(|e| e.0)
83}
84
85/// Removes `object`'s note, if it has one.
86pub fn remove(repo: &Path, object: &str) {
87    let _one = WRITING
88        .lock()
89        .unwrap_or_else(std::sync::PoisonError::into_inner);
90    let _held = hold(repo);
91    let mut args = IDENTITY.to_vec();
92    args.extend([
93        "notes",
94        "--ref=speccoder",
95        "remove",
96        "--ignore-missing",
97        object,
98    ]);
99    let _ = Git::at(repo).run(&args);
100}
101
102/// `object`'s note; `None` when it has none.
103#[must_use]
104pub fn read(repo: &Path, object: &str) -> Option<String> {
105    Git::at(repo)
106        .run(&["notes", "--ref=speccoder", "show", object])
107        .ok()
108        .map(|b| String::from_utf8_lossy(&b).into_owned())
109}
110
111/// Every object with a note.
112#[must_use]
113pub fn noted(repo: &Path) -> std::collections::BTreeSet<String> {
114    Git::at(repo)
115        .run(&["notes", "--ref=speccoder", "list"])
116        .map(|b| {
117            String::from_utf8_lossy(&b)
118                .lines()
119                .filter_map(|l| l.split_whitespace().nth(1).map(str::to_owned))
120                .collect()
121        })
122        .unwrap_or_default()
123}