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/// 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}