Skip to main content

tak_cli/
notes.rs

1//! Storage: benchmark results as git notes under `refs/notes/tak`.
2//!
3//! Why notes rather than an orphan branch or a committed file:
4//!
5//! - The data is *about a commit*, which is exactly what notes are for.
6//! - `cat_sort_uniq` resolves concurrent writers with no custom merge driver.
7//! - The notes tree is keyed by commit SHA as *path names* and does not reference
8//!   the annotated commits, so a single shallow fetch of this one ref returns the
9//!   entire history without cloning the repository — measured at 36ms / 124K for
10//!   100 commits × 6 benchmarks, with zero project commit objects transferred.
11//!   That property is what makes a hosted dashboard cheap.
12//!
13//! All network operations shell out to `git` on purpose. `actions/checkout` sets
14//! up auth via `http.extraheader`, and users have credential helpers, SSH agents
15//! and corporate proxies; reimplementing any of that is a trap. Local object
16//! access can move to `gix` later without changing this boundary.
17
18use anyhow::{Context, Result, bail};
19use std::process::Command;
20
21use crate::record::{Record, parse_note};
22
23pub const NOTES_REF: &str = "refs/notes/tak";
24
25/// Scratch ref the remote is fetched into, so a fetch never lands directly on
26/// the ref holding records that have not been pushed yet.
27const REMOTE_REF: &str = "refs/notes/tak-remote";
28
29/// Refspec for reading. Forced, which is safe because it only ever overwrites
30/// the scratch ref.
31const FETCH_REFSPEC: &str = "+refs/notes/tak:refs/notes/tak-remote";
32
33/// Refspec for writing, deliberately **not** forced.
34///
35/// A forced push always succeeds. That makes the retry-and-merge loop below
36/// unreachable and lets any writer with a stale or empty local ref replace the
37/// entire remote history in one shot. It is not a hypothetical: a CI job whose
38/// checkout had never fetched notes recorded one measurement, pushed, and
39/// destroyed 60 backfilled ones in jdx/aube.
40///
41/// Without the `+`, that push is rejected as a non-fast-forward, and the loop
42/// fetches the remote, merges it in with cat_sort_uniq, and tries again.
43const PUSH_REFSPEC: &str = "refs/notes/tak:refs/notes/tak";
44
45/// Refspec suggested to users for plain `git fetch`, which has no local records
46/// to lose because it is a read-only convenience for people who never run tak.
47const USER_FETCH_REFSPEC: &str = "+refs/notes/tak:refs/notes/tak";
48/// Number of fetch/merge/push attempts before giving up on a contended push.
49const PUSH_ATTEMPTS: u32 = 5;
50
51fn git(args: &[&str]) -> Result<String> {
52    let out = Command::new("git")
53        .args(args)
54        .output()
55        .with_context(|| format!("failed to run `git {}`", args.join(" ")))?;
56    if !out.status.success() {
57        bail!(
58            "git {} failed: {}",
59            args.join(" "),
60            String::from_utf8_lossy(&out.stderr).trim()
61        );
62    }
63    Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
64}
65
66/// Fallback identity arguments for commands that create a commit.
67///
68/// `git notes add` and `git notes merge` write commits, so git demands an
69/// author. Containers and fresh CI images routinely have none configured, and
70/// aborting a whole backfill for want of a name nobody will ever read is not a
71/// useful failure. Only applied when the user has not set one, so a real
72/// identity is never overridden.
73fn identity_args() -> Vec<&'static str> {
74    let configured = |key: &str| {
75        Command::new("git")
76            .args(["config", "--get", key])
77            .output()
78            .map(|o| o.status.success())
79            .unwrap_or(false)
80    };
81    // Checked independently: a machine with `user.name` set but no email would
82    // otherwise have its real name replaced by the placeholder.
83    let mut args = Vec::new();
84    if !configured("user.name") {
85        args.extend_from_slice(&["-c", "user.name=tak"]);
86    }
87    if !configured("user.email") {
88        args.extend_from_slice(&["-c", "user.email=tak@localhost"]);
89    }
90    args
91}
92
93/// Like [`git`] but prepends a fallback identity, for commands that commit.
94fn git_committing(args: &[&str]) -> Result<String> {
95    let mut full = identity_args();
96    full.extend_from_slice(args);
97    git(&full)
98}
99
100/// Like [`git`] but returns the failure instead of raising, for calls whose
101/// failure is an expected outcome (a rejected push, a missing note).
102fn git_ok(args: &[&str]) -> Result<(bool, String)> {
103    let out = Command::new("git")
104        .args(args)
105        .output()
106        .with_context(|| format!("failed to run `git {}`", args.join(" ")))?;
107    let text = if out.status.success() {
108        String::from_utf8_lossy(&out.stdout).trim_end().to_string()
109    } else {
110        String::from_utf8_lossy(&out.stderr).trim_end().to_string()
111    };
112    Ok((out.status.success(), text))
113}
114
115/// Pull the notes ref from `remote`.
116///
117/// Never fatal: a developer may be offline, or the remote may have no notes yet.
118/// Callers fall back to whatever is in the local ref.
119pub fn fetch(remote: &str) -> Result<bool> {
120    let (ok, _) = git_ok(&["fetch", "--quiet", "--depth", "1", remote, FETCH_REFSPEC])?;
121    if !ok {
122        return Ok(false);
123    }
124    absorb_remote()?;
125    Ok(true)
126}
127
128/// Fold the fetched remote notes into the local ref, keeping both sides.
129///
130/// Reading used to fetch straight onto `refs/notes/tak`, so `tak history` after
131/// a local `tak run --record` threw the local record away before it could be
132/// pushed. Merging instead of overwriting is the same choice the push path
133/// makes, for the same reason: neither side of this ref is authoritative.
134fn absorb_remote() -> Result<()> {
135    // Nothing local yet: adopt the remote wholesale. `notes merge` needs a ref
136    // to merge into and would fail here.
137    let (has_local, _) = git_ok(&["rev-parse", "--verify", "--quiet", NOTES_REF])?;
138    if !has_local {
139        git(&["update-ref", NOTES_REF, REMOTE_REF])?;
140        return Ok(());
141    }
142    git_committing(&[
143        "notes",
144        "--ref",
145        NOTES_REF,
146        "merge",
147        "-s",
148        "cat_sort_uniq",
149        REMOTE_REF,
150    ])?;
151    Ok(())
152}
153
154/// Read every record attached to `commit`, after refreshing from the remote.
155///
156/// The fetch is deliberately inside the read path. Users must never have to know
157/// that notes exist, let alone that they need a refspec — that seam is the single
158/// biggest usability risk in this design.
159pub fn read(remote: Option<&str>, commit: &str) -> Result<Vec<Record>> {
160    if let Some(r) = remote {
161        let _ = fetch(r);
162    }
163    let (ok, body) = git_ok(&["notes", "--ref", NOTES_REF, "show", commit])?;
164    if !ok {
165        // No note for this commit is a normal state, not an error.
166        return Ok(vec![]);
167    }
168    Ok(parse_note(&body))
169}
170
171/// Append records to `commit`'s note locally, preserving anything already there.
172pub fn append(commit: &str, records: &[Record]) -> Result<()> {
173    if records.is_empty() {
174        return Ok(());
175    }
176    let mut lines: Vec<String> = Vec::new();
177    let (ok, existing) = git_ok(&["notes", "--ref", NOTES_REF, "show", commit])?;
178    if ok {
179        lines.extend(existing.lines().map(str::to_string));
180    }
181    for r in records {
182        lines.push(r.to_line()?);
183    }
184    // Sort and dedupe locally so the note matches what cat_sort_uniq would
185    // produce on merge; otherwise a local write and a remote merge of the same
186    // data yield different bytes.
187    lines.sort();
188    lines.dedup();
189
190    git_committing(&[
191        "notes",
192        "--ref",
193        NOTES_REF,
194        "add",
195        "-f",
196        "-m",
197        &lines.join("\n"),
198        commit,
199    ])?;
200    Ok(())
201}
202
203/// Push the notes ref, resolving races against other CI jobs.
204///
205/// Two runners finishing at once will both try to push; the loser is rejected,
206/// re-fetches, merges with `cat_sort_uniq` and retries. Verified end-to-end: both
207/// runners' measurements survive the merge.
208pub fn push(remote: &str) -> Result<()> {
209    for attempt in 1..=PUSH_ATTEMPTS {
210        let (ok, err) = git_ok(&["push", "--quiet", remote, PUSH_REFSPEC])?;
211        if ok {
212            return Ok(());
213        }
214        if attempt == PUSH_ATTEMPTS {
215            bail!("could not push {NOTES_REF} after {PUSH_ATTEMPTS} attempts: {err}");
216        }
217        // Fetch the winner's ref to a scratch location, then merge into ours.
218        git(&["fetch", "--quiet", remote, FETCH_REFSPEC])?;
219        absorb_remote()?;
220    }
221    unreachable!()
222}
223
224/// Resolve a revision to a full SHA.
225pub fn rev_parse(rev: &str) -> Result<String> {
226    git(&["rev-parse", rev])
227}
228
229/// Teach plain `git fetch` about the notes ref, so the data is visible to users
230/// who never run `tak`. A convenience, not load-bearing — every `tak` read path
231/// fetches for itself.
232pub fn install_refspec(remote: &str) -> Result<()> {
233    let key = format!("remote.{remote}.fetch");
234    let (_, existing) = git_ok(&["config", "--get-all", &key])?;
235    if existing.lines().any(|l| l.trim() == USER_FETCH_REFSPEC) {
236        return Ok(());
237    }
238    git(&["config", "--add", &key, USER_FETCH_REFSPEC])?;
239    Ok(())
240}