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