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}