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/// Force refspec: the local copy is a cache of the remote, never a divergent branch.
25const REFSPEC: &str = "+refs/notes/tak:refs/notes/tak";
26/// Number of fetch/merge/push attempts before giving up on a contended push.
27const PUSH_ATTEMPTS: u32 = 5;
28
29fn git(args: &[&str]) -> Result<String> {
30 let out = Command::new("git")
31 .args(args)
32 .output()
33 .with_context(|| format!("failed to run `git {}`", args.join(" ")))?;
34 if !out.status.success() {
35 bail!(
36 "git {} failed: {}",
37 args.join(" "),
38 String::from_utf8_lossy(&out.stderr).trim()
39 );
40 }
41 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
42}
43
44/// Fallback identity arguments for commands that create a commit.
45///
46/// `git notes add` and `git notes merge` write commits, so git demands an
47/// author. Containers and fresh CI images routinely have none configured, and
48/// aborting a whole backfill for want of a name nobody will ever read is not a
49/// useful failure. Only applied when the user has not set one, so a real
50/// identity is never overridden.
51fn identity_args() -> Vec<&'static str> {
52 let configured = |key: &str| {
53 Command::new("git")
54 .args(["config", "--get", key])
55 .output()
56 .map(|o| o.status.success())
57 .unwrap_or(false)
58 };
59 // Checked independently: a machine with `user.name` set but no email would
60 // otherwise have its real name replaced by the placeholder.
61 let mut args = Vec::new();
62 if !configured("user.name") {
63 args.extend_from_slice(&["-c", "user.name=tak"]);
64 }
65 if !configured("user.email") {
66 args.extend_from_slice(&["-c", "user.email=tak@localhost"]);
67 }
68 args
69}
70
71/// Like [`git`] but prepends a fallback identity, for commands that commit.
72fn git_committing(args: &[&str]) -> Result<String> {
73 let mut full = identity_args();
74 full.extend_from_slice(args);
75 git(&full)
76}
77
78/// Like [`git`] but returns the failure instead of raising, for calls whose
79/// failure is an expected outcome (a rejected push, a missing note).
80fn git_ok(args: &[&str]) -> Result<(bool, String)> {
81 let out = Command::new("git")
82 .args(args)
83 .output()
84 .with_context(|| format!("failed to run `git {}`", args.join(" ")))?;
85 let text = if out.status.success() {
86 String::from_utf8_lossy(&out.stdout).trim_end().to_string()
87 } else {
88 String::from_utf8_lossy(&out.stderr).trim_end().to_string()
89 };
90 Ok((out.status.success(), text))
91}
92
93/// Pull the notes ref from `remote`.
94///
95/// Never fatal: a developer may be offline, or the remote may have no notes yet.
96/// Callers fall back to whatever is in the local ref.
97pub fn fetch(remote: &str) -> Result<bool> {
98 let (ok, _) = git_ok(&["fetch", "--quiet", "--depth", "1", remote, REFSPEC])?;
99 Ok(ok)
100}
101
102/// Read every record attached to `commit`, after refreshing from the remote.
103///
104/// The fetch is deliberately inside the read path. Users must never have to know
105/// that notes exist, let alone that they need a refspec — that seam is the single
106/// biggest usability risk in this design.
107pub fn read(remote: Option<&str>, commit: &str) -> Result<Vec<Record>> {
108 if let Some(r) = remote {
109 let _ = fetch(r);
110 }
111 let (ok, body) = git_ok(&["notes", "--ref", NOTES_REF, "show", commit])?;
112 if !ok {
113 // No note for this commit is a normal state, not an error.
114 return Ok(vec![]);
115 }
116 Ok(parse_note(&body))
117}
118
119/// Append records to `commit`'s note locally, preserving anything already there.
120pub fn append(commit: &str, records: &[Record]) -> Result<()> {
121 if records.is_empty() {
122 return Ok(());
123 }
124 let mut lines: Vec<String> = Vec::new();
125 let (ok, existing) = git_ok(&["notes", "--ref", NOTES_REF, "show", commit])?;
126 if ok {
127 lines.extend(existing.lines().map(str::to_string));
128 }
129 for r in records {
130 lines.push(r.to_line()?);
131 }
132 // Sort and dedupe locally so the note matches what cat_sort_uniq would
133 // produce on merge; otherwise a local write and a remote merge of the same
134 // data yield different bytes.
135 lines.sort();
136 lines.dedup();
137
138 git_committing(&[
139 "notes",
140 "--ref",
141 NOTES_REF,
142 "add",
143 "-f",
144 "-m",
145 &lines.join("\n"),
146 commit,
147 ])?;
148 Ok(())
149}
150
151/// Push the notes ref, resolving races against other CI jobs.
152///
153/// Two runners finishing at once will both try to push; the loser is rejected,
154/// re-fetches, merges with `cat_sort_uniq` and retries. Verified end-to-end: both
155/// runners' measurements survive the merge.
156pub fn push(remote: &str) -> Result<()> {
157 for attempt in 1..=PUSH_ATTEMPTS {
158 let (ok, err) = git_ok(&["push", "--quiet", remote, REFSPEC])?;
159 if ok {
160 return Ok(());
161 }
162 if attempt == PUSH_ATTEMPTS {
163 bail!("could not push {NOTES_REF} after {PUSH_ATTEMPTS} attempts: {err}");
164 }
165 // Fetch the winner's ref to a scratch location, then merge into ours.
166 git(&[
167 "fetch",
168 "--quiet",
169 remote,
170 "+refs/notes/tak:refs/notes/tak-remote",
171 ])?;
172 git_committing(&[
173 "notes",
174 "--ref",
175 NOTES_REF,
176 "merge",
177 "-s",
178 "cat_sort_uniq",
179 "refs/notes/tak-remote",
180 ])?;
181 }
182 unreachable!()
183}
184
185/// Resolve a revision to a full SHA.
186pub fn rev_parse(rev: &str) -> Result<String> {
187 git(&["rev-parse", rev])
188}
189
190/// Teach plain `git fetch` about the notes ref, so the data is visible to users
191/// who never run `tak`. A convenience, not load-bearing — every `tak` read path
192/// fetches for itself.
193pub fn install_refspec(remote: &str) -> Result<()> {
194 let key = format!("remote.{remote}.fetch");
195 let (_, existing) = git_ok(&["config", "--get-all", &key])?;
196 if existing.lines().any(|l| l.trim() == REFSPEC) {
197 return Ok(());
198 }
199 git(&["config", "--add", &key, REFSPEC])?;
200 Ok(())
201}