Skip to main content

borg/
notes.rs

1//! Reasoning travels with the code: a redacted excerpt per landed commit, as a git note.
2//!
3//! `borg notes attach [<commit>]` writes, for each commit a drone's session landed (or the one
4//! named), the session's task, the drone's last proposal and the session's episode ids to
5//! `refs/notes/borg` in that repo. Notes are local refs: borg never pushes them (an engineer who
6//! wants them on a remote pushes `refs/notes/borg` like any ref). The hook's `end` event runs it in
7//! the background when the session started in a git repo (`<state>/<session>.head` holds the repo
8//! and its HEAD at the session's first prompt).
9//!
10//! **Only the session's own commits** (`landed`): a commit gets this session's note only if HEAD's
11//! reflog says `commit` (or `commit (amend)`, `commit (initial)`, `revert`, `cherry-pick`) created it while the session was
12//! working (between one of its prompts and the stop that followed, `<state>/<session>.active`) and
13//! it is still on HEAD. A `git pull`, a merge, a checkout of another branch, or a commit made while
14//! this session was idle (the engineer, or another session in the same repo) gets nothing from it.
15//! Two sessions working in the same worktree at the same second cannot be told apart.
16//!
17//! **Redaction first** (`redact`): tokens, keys, passwords and `.env`-style values are replaced
18//! before an excerpt is written anywhere, by a conservative regex list (it rather blanks a harmless
19//! value than lets a secret through).
20//!
21//! **Replication** (PLAN.md "Availability"): the same excerpt is appended to the collective's
22//! `reasoning.jsonl`, which the mesh (borg-mesh, sync.rs) hands to two member replicas, so the "why"
23//! survives the author being offline. Only when `collective::may_sync` allows it: a local (private)
24//! collective's excerpts stay in that repo's notes, and an off repo gets nothing at all.
25//! `borg notes fetch [<commit>...]` reads it back: it asks the known members (borg-mesh `fetch`,
26//! author or replica) and writes what they hold as notes (restore.rs).
27//!
28//! **Links** (regress.rs): after `attach`, the landed commits are matched against the earlier
29//! decisions whose lines they changed; a note then lists them under `borg fixed in:` (on the
30//! decision) and `borg fixes:` (on the fix), links.rs.
31
32use std::path::{Path, PathBuf};
33use std::process::{Command, Stdio};
34use std::sync::LazyLock;
35
36use regex::{Captures, Regex};
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39
40use crate::{collective, learn};
41
42pub mod links;
43pub mod reasoning;
44pub mod regress;
45pub mod regress_cli;
46pub mod regress_cosmetic;
47pub mod regress_diff;
48pub mod regress_git;
49pub mod regress_learn;
50pub mod regress_signals;
51pub mod regret;
52pub mod restore;
53
54use links::LinkRef;
55pub use restore::{restore, wanted};
56
57pub const NOTES_REF: &str = "refs/notes/borg";
58/// At most this many commits get a note per run (a session that landed more is unusual; the
59/// newest are the ones whose reasoning is still in the session).
60const MAX_COMMITS: usize = 20;
61
62const REDACTED: &str = "[REDACTED]";
63
64/// (pattern, replacement): applied in order.
65static SECRETS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
66    [
67        // PEM private keys, also when cut off before their END line.
68        (r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----(?s:.*?)(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)", "[REDACTED PRIVATE KEY]"),
69        // ... and when the text starts inside one (a window cut before its BEGIN line): an END
70        // left over blanks everything before it.
71        (r"(?s)\A.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", "[REDACTED PRIVATE KEY]"),
72        // Provider tokens with a recognisable shape.
73        (r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b", REDACTED),
74        (r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|\bgithub_pat_[A-Za-z0-9_]{20,}", REDACTED),
75        (r"\bglpat-[A-Za-z0-9_-]{20,}", REDACTED),
76        (r"\bxox[abposr]-[A-Za-z0-9-]{10,}", REDACTED),
77        (r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}", REDACTED),
78        (r"\bAIza[0-9A-Za-z_-]{35}", REDACTED),
79        (r"\bnpm_[A-Za-z0-9]{36}\b", REDACTED),
80        (r"\bhf_[A-Za-z0-9]{30,}", REDACTED),
81        (r"\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}", REDACTED),
82        (r"\bya29\.[A-Za-z0-9_-]{20,}", REDACTED),
83        (r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}", REDACTED),
84        // Credentials in URLs and auth headers.
85        (r"(?i)\b([a-z][a-z0-9+.-]*://[^\s:/@]+):[^\s@/]+@", "$1:[REDACTED]@"),
86        (r"(?i)\b(bearer|basic|token)(\s+)[A-Za-z0-9._~+/=-]{12,}", "$1$2[REDACTED]"),
87        // A password glued to its flag on a database client's command line (`mysql -pS3cret`).
88        (r"(?i)\b((?:mysql|mysqldump|mysqladmin|mariadb|psql)\b[^\n]*?\s-p)[^\s-]\S*", "${1}[REDACTED]"),
89        // key = value / "key": "value" where the key names a secret: also `pass`, `pw` and any name
90        // ending in `key` (`signing_key`, `secretKey`), whatever the value looks like (lowercase hex too).
91        (
92            r#"(?i)\b([A-Za-z0-9_.-]*?(?:password|passwd|passphrase|pass|pwd|pw|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|key|credentials?|authorization|auth)(?:[_.-][A-Za-z0-9_.-]*|(?-i:[A-Z])[A-Za-z0-9_.-]*)?)(["']?\s*[:=]\s*)("[^"\n]*"|'[^'\n]*'|[^\s,;}]+)"#,
93            "$1$2[REDACTED]",
94        ),
95        // .env lines: any upper-case name assigned at the start of a line.
96        (r"(?m)^(\s*(?:export\s+)?[A-Z][A-Z0-9_]*\s*=\s*)\S.*$", "$1[REDACTED]"),
97    ]
98    .into_iter()
99    .map(|(p, r)| (Regex::new(p).expect("secret pattern"), r))
100    .collect()
101});
102
103/// Candidates for an unlabelled random token (checked by `looks_random`).
104static TOKENISH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[A-Za-z0-9+/_=-]{32,}").unwrap());
105
106/// A long run of mixed case and digits that is not a path or an identifier: some `/`-free part of
107/// at least 24 characters with upper, lower and at least two digits; or, since base64 secrets
108/// contain `/` too, a whole run of 40 or more with upper, lower, a digit and at least 30 % of its
109/// letters upper case (paths and CamelCase names have far fewer). Lowercase hex (commit ids,
110/// hashes) and UUIDs are kept: they identify, they do not authorise.
111fn looks_random(s: &str) -> bool {
112    let mixed = |p: &str, digits: usize| {
113        p.chars().any(|c| c.is_ascii_uppercase()) && p.chars().any(|c| c.is_ascii_lowercase()) && p.chars().filter(|c| c.is_ascii_digit()).count() >= digits
114    };
115    let upper = s.chars().filter(|c| c.is_ascii_uppercase()).count();
116    let letters = s.chars().filter(|c| c.is_ascii_alphabetic()).count();
117    (s.len() >= 40 && mixed(s, 1) && upper * 10 >= letters * 3) || s.split('/').any(|p| p.len() >= 24 && mixed(p, 2))
118}
119
120/// `text` with every secret the list recognises replaced (module doc).
121pub fn redact(text: &str) -> String {
122    let mut out = text.to_string();
123    for (re, with) in SECRETS.iter() {
124        out = re.replace_all(&out, *with).into_owned();
125    }
126    TOKENISH.replace_all(&out, |c: &Captures| if looks_random(&c[0]) { REDACTED.to_string() } else { c[0].to_string() }).into_owned()
127}
128
129/// The per-session scratch directory (as the hook uses it).
130pub fn state_dir() -> PathBuf {
131    PathBuf::from(std::env::var("BORG_STATE").unwrap_or_else(|_| "/tmp/borg".into()))
132}
133
134/// `session` as a file name in the state dir.
135pub(crate) fn safe(session: &str) -> String {
136    session.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect()
137}
138
139fn state(dir: &Path, session: &str, ext: &str) -> PathBuf {
140    dir.join(format!("{}.{ext}", safe(session)))
141}
142
143pub(crate) fn git(repo: &Path, args: &[&str]) -> Option<String> {
144    Command::new("git")
145        .arg("-C")
146        .arg(repo)
147        .args(args)
148        .stdin(Stdio::null())
149        .stderr(Stdio::null())
150        .output()
151        .ok()
152        .filter(|o| o.status.success())
153        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
154}
155
156/// Where a session started: the repo's top level and its HEAD then.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct Start {
159    pub repo: String,
160    pub head: String,
161    pub ms: i64,
162}
163
164/// Records where `session` started, once (hook, first prompt in a git repo).
165pub fn remember_start(dir: &Path, session: &str, cwd: &str) {
166    let p = state(dir, session, "head");
167    if cwd.is_empty() || p.exists() {
168        return;
169    }
170    let start = git(Path::new(cwd), &["rev-parse", "--show-toplevel"])
171        .and_then(|repo| Some(Start { head: git(Path::new(&repo), &["rev-parse", "HEAD"])?, repo, ms: learn::now_ms() }));
172    let _ = std::fs::create_dir_all(dir);
173    // Outside a git repo (or before its first commit) a `null` marker keeps later prompts from
174    // asking git again; `end` then has nothing to do.
175    let _ = std::fs::write(p, serde_json::to_string(&start).unwrap());
176}
177
178/// Marks the session working (`prompt`) or idle again (`stop`), for `landed` (hook).
179pub fn mark(dir: &Path, session: &str, what: &str) {
180    use std::io::Write;
181    let _ = std::fs::create_dir_all(dir);
182    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state(dir, session, "active")) {
183        let _ = writeln!(f, "{what} {}", learn::now_ms());
184    }
185}
186
187/// When the session was working, as `[from, to]` epoch ms: from each prompt to the stop after it
188/// (open-ended while it works). Without marks, everything since the session started.
189pub fn windows(dir: &Path, session: &str, start: &Start) -> Vec<(i64, i64)> {
190    let text = std::fs::read_to_string(state(dir, session, "active")).unwrap_or_default();
191    let mut out = Vec::new();
192    let mut open: Option<i64> = None;
193    for (what, ms) in text.lines().filter_map(|l| l.split_once(' ')).filter_map(|(w, m)| Some((w, m.trim().parse::<i64>().ok()?))) {
194        match what {
195            "prompt" => {
196                open.get_or_insert(ms);
197            }
198            "stop" => {
199                if let Some(from) = open.take() {
200                    out.push((from, ms));
201                }
202            }
203            _ => {}
204        }
205    }
206    if let Some(from) = open {
207        out.push((from, i64::MAX));
208    }
209    if out.is_empty() {
210        out.push((start.ms, i64::MAX));
211    }
212    out
213}
214
215/// Keeps the drone's latest proposal for the excerpt (hook, at stop and end): redacted whole, then
216/// cut to its last 2000 characters, so a cut never lands inside a secret that was not redacted.
217pub fn remember_proposal(dir: &Path, session: &str, text: &str) {
218    if text.trim().is_empty() {
219        return;
220    }
221    let text = redact(text);
222    let tail: String = text.chars().rev().take(2000).collect::<Vec<_>>().into_iter().rev().collect();
223    let _ = std::fs::create_dir_all(dir);
224    let _ = std::fs::write(state(dir, session, "proposal"), tail);
225}
226
227/// The redacted reasoning of one landed commit.
228#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
229pub struct Excerpt {
230    pub kind: String,
231    pub commit: String,
232    pub repo: String,
233    pub session: String,
234    pub task: String,
235    pub proposal: String,
236    pub episodes: Vec<String>,
237    pub ms: i64,
238    /// The collective the session worked in (`borg why` shows a note only inside it); empty in
239    /// excerpts from before it was recorded.
240    #[serde(default, skip_serializing_if = "String::is_empty")]
241    pub collective: String,
242    /// Later commits that fixed (or revisited) this commit's lines (regress.rs).
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub fixed_by: Vec<LinkRef>,
245    /// The earlier decisions this commit fixed.
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub fixes: Vec<LinkRef>,
248}
249
250impl Excerpt {
251    /// What `git notes show` prints.
252    pub fn note(&self) -> String {
253        let within = if self.collective.is_empty() { String::new() } else { format!(", collective {}", self.collective) };
254        let mut s = format!("borg reasoning (session {}{within})\n\nTask:\n{}\n\nLast proposal:\n{}\n", self.session, self.task.trim(), self.proposal.trim());
255        if !self.episodes.is_empty() {
256            s.push_str(&format!("\nCorrections (episodes): {}\n", self.episodes.join(", ")));
257        }
258        s.push_str(&links::sections(&self.fixed_by, &self.fixes));
259        s
260    }
261
262    /// The excerpt a borg note holds (the inverse of `note`), for `commit`; `None` if the text is
263    /// not a borg reasoning note.
264    pub fn from_note(commit: &str, text: &str) -> Option<Excerpt> {
265        let (text, fixed_by, fixes) = links::split(text);
266        let (head, rest) = text.split_once('\n')?;
267        let inner = head.trim().strip_prefix("borg reasoning (session ")?.strip_suffix(')')?;
268        let (session, collective) = inner.split_once(", collective ").unwrap_or((inner, ""));
269        // git drops the blank lines an empty task or proposal leaves, and `git` trims the end.
270        let rest = rest.trim_start().strip_prefix("Task:")?;
271        let (task, rest) = rest.split_once("\nLast proposal:").map_or((rest, ""), |(t, p)| (t, p.strip_prefix('\n').unwrap_or(p)));
272        let (proposal, episodes) = match rest.rsplit_once("\nCorrections (episodes): ") {
273            Some((p, e)) => (p, e.trim().split(", ").filter(|e| !e.is_empty()).map(str::to_string).collect()),
274            None => (rest, Vec::new()),
275        };
276        Some(Excerpt {
277            kind: "reasoning".into(),
278            commit: commit.into(),
279            repo: String::new(),
280            session: session.into(),
281            task: task.trim().into(),
282            proposal: proposal.trim().into(),
283            episodes,
284            ms: 0,
285            collective: collective.into(),
286            fixed_by,
287            fixes,
288        })
289    }
290}
291
292/// The session's task, last proposal and episode ids, redacted, for `commit` of `repo`.
293pub fn excerpt(dir: &Path, session: &str, repo: &str, commit: &str, now: i64) -> Excerpt {
294    let read = |ext| std::fs::read_to_string(state(dir, session, ext)).unwrap_or_default();
295    let episodes = read("ctx").lines().filter_map(|l| l.strip_prefix("#borg-episode ")).map(|s| s.trim().to_string()).collect();
296    Excerpt {
297        kind: "reasoning".into(),
298        commit: commit.into(),
299        repo: repo.into(),
300        session: session.into(),
301        task: redact(&read("task").chars().take(3000).collect::<String>()),
302        proposal: redact(&read("proposal")),
303        episodes,
304        ms: now,
305        ..Excerpt::default()
306    }
307}
308
309/// The commits the session itself made (module doc), newest first, at most `MAX_COMMITS`: HEAD's
310/// reflog entries of a `commit` within one of `windows`, still reachable from HEAD but not from
311/// the session's starting HEAD.
312pub fn landed(repo: &Path, start: &Start, windows: &[(i64, i64)]) -> Vec<String> {
313    let on_head: std::collections::HashSet<String> =
314        git(repo, &["rev-list", "--max-count=1000", &format!("{}..HEAD", start.head)]).map(|t| t.lines().map(str::to_string).collect()).unwrap_or_default();
315    // `HEAD@{<unix seconds>}` with --date=unix; the reflog keeps seconds, the marks milliseconds.
316    let within = |secs: i64| windows.iter().any(|&(from, to)| secs >= from.div_euclid(1000) && secs <= to.saturating_add(999) / 1000);
317    let reflog = git(repo, &["log", "-g", "-n", "1000", "--date=unix", "--format=%H%x09%gd%x09%gs", "HEAD"]).unwrap_or_default();
318    let mut out: Vec<String> = Vec::new();
319    for l in reflog.lines() {
320        let mut f = l.splitn(3, '\t');
321        let (Some(c), Some(at), Some(subject)) = (f.next(), f.next(), f.next()) else { continue };
322        let own = ["commit:", "commit (amend):", "commit (initial):", "revert:", "cherry-pick:"].iter().any(|p| subject.starts_with(p));
323        let secs = at.rsplit_once('{').and_then(|(_, t)| t.trim_end_matches('}').parse::<i64>().ok());
324        if own && secs.is_some_and(within) && on_head.contains(c) && !out.iter().any(|o| o == c) {
325            out.push(c.to_string());
326        }
327        if out.len() >= MAX_COMMITS {
328            break;
329        }
330    }
331    out
332}
333
334/// The commits `session` landed so far (`landed`, from its start recorded in `dir`); empty when it
335/// started outside git or left no record here.
336pub fn landed_by(dir: &Path, session: &str) -> Vec<String> {
337    start_of(dir, session).map(|s| landed(Path::new(&s.repo), &s, &windows(dir, session, &s))).unwrap_or_default()
338}
339
340/// Where `session` started (`remember_start`, recorded in `dir`); None outside git or without a record.
341pub fn start_of(dir: &Path, session: &str) -> Option<Start> {
342    std::fs::read_to_string(state(dir, session, "head")).ok().and_then(|t| serde_json::from_str::<Start>(&t).ok())
343}
344
345/// The top level of the git repo `dir` is in.
346pub fn toplevel(dir: &str) -> Option<PathBuf> {
347    git(Path::new(dir), &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
348}
349
350/// The borg note of `commit`, parsed; `None` without one.
351#[must_use]
352pub fn note_of(repo: &Path, commit: &str) -> Option<Excerpt> {
353    Excerpt::from_note(commit, &git(repo, &["notes", &format!("--ref={NOTES_REF}"), "show", commit])?)
354}
355
356pub(crate) fn has_note(repo: &Path, commit: &str) -> bool {
357    git(repo, &["notes", &format!("--ref={NOTES_REF}"), "list", commit]).is_some()
358}
359
360/// Writes `e` as the borg note of its commit (replacing an older borg note). Never pushes.
361pub fn write_note(repo: &Path, e: &Excerpt) -> Result<(), String> {
362    use std::io::Write;
363    let mut child = Command::new("git")
364        .arg("-C")
365        .arg(repo)
366        .args(["notes", &format!("--ref={NOTES_REF}"), "add", "-f", "-F", "-", &e.commit])
367        .stdin(Stdio::piped())
368        .stdout(Stdio::null())
369        .stderr(Stdio::piped())
370        .spawn()
371        .map_err(|err| format!("git notes: {err}"))?;
372    child.stdin.take().ok_or("git notes: no stdin")?.write_all(e.note().as_bytes()).map_err(|err| format!("git notes: {err}"))?;
373    let out = child.wait_with_output().map_err(|err| format!("git notes: {err}"))?;
374    if !out.status.success() {
375        return Err(format!("git notes add {}: {}", e.commit, String::from_utf8_lossy(&out.stderr).trim()));
376    }
377    Ok(())
378}
379
380/// What one run did.
381#[derive(Debug, Default, PartialEq, Eq)]
382pub struct Attached {
383    pub notes: Vec<String>,
384    /// Excerpts queued for the mesh's replicas (0 for a local collective).
385    pub replicated: usize,
386}
387
388/// Notes for `l.commits` from `l.session` in the collective `l.route` names (an off route attaches
389/// nothing); excerpts go to `l.store`'s reasoning.jsonl only if the route may sync. `force`
390/// replaces an existing borg note (an explicitly named commit), keeping the links it lists.
391///
392/// # Errors
393/// A note git would not write, or a store that cannot be appended to.
394pub fn attach(l: &Landed, force: bool) -> Result<Attached, String> {
395    let collective::Route::Collective { name: within, .. } = l.route else { return Ok(Attached::default()) };
396    let may_sync = collective::may_sync(l.route);
397    let name = l.repo.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
398    let mut done = Attached::default();
399    for c in l.commits {
400        if !force && has_note(l.repo, c) {
401            continue;
402        }
403        let mut e = Excerpt { collective: within.clone(), ..excerpt(l.dir, l.session, &name, c, learn::now_ms()) };
404        if let Some(old) = note_of(l.repo, c).filter(|_| force) {
405            (e.fixed_by, e.fixes) = (old.fixed_by, old.fixes);
406        }
407        write_note(l.repo, &e)?;
408        if may_sync {
409            append_reasoning(l.store, &e).map_err(|err| format!("{}: {err}", l.store.display()))?;
410            done.replicated += 1;
411        }
412        done.notes.push(c.clone());
413    }
414    Ok(done)
415}
416
417pub(crate) fn append_reasoning(store: &Path, e: &Excerpt) -> std::io::Result<()> {
418    use std::io::Write;
419    std::fs::create_dir_all(store)?;
420    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(store.join("reasoning.jsonl"))?;
421    writeln!(f, "{}", serde_json::to_string(e).unwrap())
422}
423
424/// The session that last started in `repo`, from the `.head` files in `dir`.
425#[must_use]
426pub fn latest_session(dir: &Path, repo: &str) -> Option<(String, Start)> {
427    std::fs::read_dir(dir)
428        .ok()?
429        .flatten()
430        .filter_map(|f| {
431            let p = f.path();
432            let session = p.file_name()?.to_str()?.strip_suffix(".head")?.to_string();
433            let start: Start = serde_json::from_str(&std::fs::read_to_string(&p).ok()?).ok()?;
434            (start.repo == repo).then_some((session, start))
435        })
436        .max_by_key(|(_, s)| s.ms)
437}
438
439/// Hook, `end`: if the session started in a git repo, attach notes in the background (never delays
440/// the drone; any failure is silent), and `borg learn --session` after them (`--then-learn`), so
441/// learning sees the regret episodes the links made. False when no job was started: the caller
442/// then starts `borg learn` itself.
443#[must_use]
444pub fn on_end(session: &str, input: &Value) -> bool {
445    let dir = state_dir();
446    let started = std::fs::read_to_string(state(&dir, session, "head")).ok().and_then(|t| serde_json::from_str::<Start>(&t).ok());
447    if started.is_none() {
448        return false;
449    }
450    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
451    let (Ok(exe), Some(store)) = (std::env::current_exe(), std::env::var_os("BORG_HOME")) else { return false };
452    Command::new(exe)
453        .args(["notes", "attach", "--session", session, "--dir", cwd, "--then-learn", "--store"])
454        .arg(store)
455        .stdin(Stdio::null())
456        .stdout(Stdio::null())
457        .stderr(Stdio::null())
458        .spawn()
459        .is_ok()
460}
461
462/// `--then-learn`: `borg learn [--session ID]` in the background once attach is done, whatever it
463/// did, on the store the run used (`--store`, else the caller's `BORG_HOME`).
464fn then_learn(args: &[&str]) {
465    let flag = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
466    let Ok(exe) = std::env::current_exe() else { return };
467    let mut c = Command::new(exe);
468    c.arg("learn").args(flag("--session").map(|s| ["--session", s]).into_iter().flatten());
469    if let Some(store) = flag("--store") {
470        c.env("BORG_HOME", store);
471    }
472    let _ = c.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
473}
474
475/// A run's commits and where they go: what `attach` notes, and what runs after it (main.rs passes
476/// `regress_learn.rs`, which links them to the earlier decisions they fixed).
477pub struct Landed<'a> {
478    pub dir: &'a Path,
479    pub session: &'a str,
480    pub repo: &'a Path,
481    pub store: &'a Path,
482    pub route: &'a collective::Route,
483    pub commits: &'a [String],
484    /// Where the session started (its work windows come from it).
485    pub start: Option<&'a Start>,
486}
487
488/// Runs after `attach` for the session's landed commits (not for a named one); its text ends the
489/// run's message.
490pub type After = fn(&Landed) -> String;
491
492/// `borg notes attach [<commit>] [--session ID] [--dir DIR] [--store DIR] [--then-learn]`, or
493/// `borg notes fetch [<commit>...] [--dir DIR]` (runs `borg-mesh fetch`: the network is there).
494pub fn main(args: &[&str], after: Option<After>) -> i32 {
495    if args.first() == Some(&"fetch") {
496        let beside = std::env::current_exe().ok().map(|e| e.with_file_name("borg-mesh")).filter(|p| p.exists());
497        return match Command::new(beside.unwrap_or_else(|| "borg-mesh".into())).args(args).status() {
498            Ok(s) => s.code().unwrap_or(1),
499            Err(e) => {
500                eprintln!("borg notes: cannot run borg-mesh ({e}); cargo install borg-mesh --locked");
501                1
502            }
503        };
504    }
505    // A panic in what runs after attach must not keep `--then-learn` from starting learning.
506    let ran = std::panic::catch_unwind(|| run(args, after)).unwrap_or_else(|_| Err("internal error; the notes written so far are kept".into()));
507    let code = match ran {
508        Ok(msg) => {
509            println!("{msg}");
510            0
511        }
512        Err(e) => {
513            eprintln!("borg notes: {e}");
514            1
515        }
516    };
517    if args.contains(&"--then-learn") {
518        then_learn(args);
519    }
520    code
521}
522
523const USAGE: &str = "usage: borg notes attach [<commit>] [--session ID] [--dir DIR] | borg notes fetch [<commit>...] [--dir DIR]";
524
525/// Where an attach run works: the collective `cwd` routes to, its store and state dir, the repo.
526struct Place {
527    route: collective::Route,
528    store: PathBuf,
529    dir: PathBuf,
530    repo: String,
531}
532
533/// The place of `--dir` (else the cwd), with `--store` in place of the collective's home.
534fn place(dir: Option<&str>, store: Option<&str>) -> Result<Place, String> {
535    let cwd = dir.map(str::to_string).unwrap_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string());
536    // The gate is the config as it is now: the share decides replication whatever the caller says.
537    let cfg = collective::load().map_err(|e| format!("invalid config, nothing is attached: {e}"))?;
538    let route = collective::route(cfg.as_ref(), &cwd, &learn::home());
539    let collective::Route::Collective { home, .. } = &route else {
540        return Err(format!("{cwd} belongs to no collective (off): nothing is attached"));
541    };
542    let store = store.map_or_else(|| home.clone(), PathBuf::from);
543    let dir = std::env::var_os("BORG_STATE").map_or_else(|| store.join("state"), PathBuf::from);
544    let repo = git(Path::new(&cwd), &["rev-parse", "--show-toplevel"]).ok_or(format!("{cwd} is not in a git repo"))?;
545    Ok(Place { route, store, dir, repo })
546}
547
548/// The session named (`--session`), else the latest one that started in the repo, and its start.
549fn session_of(named: Option<&str>, p: &Place) -> Result<(String, Option<Start>), String> {
550    match named {
551        Some(s) => Ok((s.to_string(), std::fs::read_to_string(state(&p.dir, s, "head")).ok().and_then(|t| serde_json::from_str::<Start>(&t).ok()))),
552        None => latest_session(&p.dir, &p.repo).map(|(s, st)| (s, Some(st))).ok_or_else(|| "no drone session started in this repo; pass --session ID".into()),
553    }
554}
555
556/// The commit named, else the session's landed commits.
557fn commits_of(named: Option<&str>, start: Option<&Start>, p: &Place, session: &str) -> Result<Vec<String>, String> {
558    match (named, start) {
559        (Some(c), _) => Ok(vec![git(Path::new(&p.repo), &["rev-parse", "--verify", &format!("{c}^{{commit}}")]).ok_or(format!("{c} is not a commit"))?]),
560        (None, Some(st)) if st.repo == p.repo => Ok(landed(Path::new(&p.repo), st, &windows(&p.dir, session, st))),
561        _ => Err("the session did not start in this repo; name a commit".into()),
562    }
563}
564
565/// The run's message: the notes written, where the excerpts went, and what ran after (`then`).
566fn message(done: &Attached, route: &collective::Route, then: &str) -> String {
567    let queued = if collective::may_sync(route) {
568        format!("{} excerpt(s) queued for the mesh's replicas", done.replicated)
569    } else {
570        "local collective: nothing leaves this machine".into()
571    };
572    format!("{} note(s) in {NOTES_REF} (not pushed); {queued}{then}", done.notes.len())
573}
574
575fn run(args: &[&str], after: Option<After>) -> Result<String, String> {
576    let flag = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
577    if args.first() != Some(&"attach") {
578        return Err(USAGE.into());
579    }
580    let commit = args.get(1).filter(|a| !a.starts_with("--")).copied();
581    let p = place(flag("--dir"), flag("--store"))?;
582    let (session, start) = session_of(flag("--session"), &p)?;
583    let commits = commits_of(commit, start.as_ref(), &p, &session)?;
584    let repo = Path::new(&p.repo);
585    let landed = Landed { dir: &p.dir, session: &session, repo, store: &p.store, route: &p.route, commits: &commits, start: start.as_ref() };
586    let done = attach(&landed, commit.is_some())?;
587    // What runs after (regress.rs) is for the session's landed commits, not for a named one.
588    let then = after.filter(|_| commit.is_none()).map(|f| f(&landed)).unwrap_or_default();
589    Ok(message(&done, &p.route, &then))
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use links::Link;
596
597    #[test]
598    fn redacts_secrets_and_keeps_the_reasoning() {
599        let cases = [
600            ("export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "wJalr"),
601            ("use AKIAIOSFODNN7EXAMPLE for s3", "AKIAIOSFODNN7EXAMPLE"),
602            ("token ghp_abcdefghijklmnopqrstuvwxyz0123456789 in CI", "ghp_abc"),
603            ("set ANTHROPIC key sk-ant-api03-abcdefghijklmnopqrstuvwx", "sk-ant-api03"),
604            ("curl -H 'Authorization: Bearer abc.def.ghijklmnopqrstu'", "abc.def"),
605            ("postgres://admin:hunter2@db:5432/app", "hunter2"),
606            (r#"{"password": "correct horse battery"}"#, "correct horse"),
607            ("db_password = s3cr3t!", "s3cr3t"),
608            ("API_KEY: 12345abcdef", "12345abcdef"),
609            ("DATABASE_URL=mysql://u@h/db\nNODE_ENV=production", "production"),
610            ("-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----", "MIIEow"),
611            ("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk (truncated", "b3Blbn"),
612            ("jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N", "eyJhbGci"),
613            ("random Zx9Qk2LmPw8RtYv3NbHs6JdFgKa1Bc4D blob", "Zx9Qk2"),
614            ("slack xoxb-123456789012-abcdefghij", "xoxb-1234"),
615            // Labels the first list missed.
616            ("const config = { secretKey: 'hunter2hunter2' }", "hunter2"),
617            (r#"{"user": "ann", "pass": "hunter2"}"#, "hunter2"),
618            ("db_pass=hunter2", "hunter2"),
619            ("then set DB_PASS=hunter2 and rerun", "hunter2"),
620            ("mysql -u root -pS3cretPass app", "S3cretPass"),
621            ("signing_key = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "9f86d0"),
622            ("pw: tr0ub4dor", "tr0ub4dor"),
623            // Unlabelled shapes.
624            ("the secret is wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY ok", "wJalrXUtnFEMI"),
625            ("HF hf_abcdefghijklmnopqrstuvwxyzABCDEFGH works", "hf_abc"),
626            ("stripe sk_live_abcdefghijklmnopqrstuvwx and rk_live_abcdefghijklmnopqrstuvwx", "_live_abc"),
627            ("publishable pk_test_abcdefghijklmnopqrstuvwx", "pk_test_abc"),
628            ("google ya29.a0AfH6SMBabcdefghijklmnopqrstuv", "ya29.a0Af"),
629            // A window that starts inside a key: no BEGIN line left, only its body and END.
630            ("bnBlbnNzaC1rZXktdjEAAAAABG5vbmU\nAAAAAAAAAAEAAAAzAAAAC3NzaC1lZDI1\n-----END OPENSSH PRIVATE KEY-----\nthen I rotated it", "bnBlbn"),
631        ];
632        for (text, secret) in cases {
633            let r = redact(text);
634            assert!(!r.contains(secret), "{text:?} -> {r:?}");
635            assert!(r.contains("[REDACTED"), "{text:?} -> {r:?}");
636        }
637        let plain = "Take the lock before reading the flag (commit 3f2a9c1e5b7d4f6a8c0e2b4d6f8a0c2e4b6d8f0a, \
638                     file /home/user/src/borg-ai/crates/borg-mesh/src/sync.rs, author: Ann, 12 tokens: fine). \
639                     id 123e4567-e89b-12d3-a456-426614174000; HashMapWithLongCamelCaseName stays.";
640        assert_eq!(redact(plain), plain, "reasoning without secrets is untouched");
641        assert!(redact("-----END RSA PRIVATE KEY-----\nthen I rotated it").ends_with("then I rotated it"), "what follows the key stays");
642        let paths = "see /Users/Ann/Projects/MyApp/src/main.rs and crates/borg-mesh/src/sync.rs for Lock2Order";
643        assert_eq!(redact(paths), paths, "paths are not secrets");
644    }
645
646    /// The proposal is redacted whole before it is cut to its last 2000 characters: a cut inside a
647    /// PEM block (BEGIN gone) never leaves the key body behind.
648    #[test]
649    fn a_long_proposal_is_redacted_before_it_is_cut() {
650        let dir = std::env::temp_dir().join(format!("borg-notes-cut-{}", std::process::id()));
651        let _ = std::fs::remove_dir_all(&dir);
652        let body: String = (0..60).map(|i| format!("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAAB{i:02}\n")).collect();
653        let text = format!("-----BEGIN OPENSSH PRIVATE KEY-----\n{body}-----END OPENSSH PRIVATE KEY-----\nI will use the deploy key above.");
654        assert!(text.len() > 2000 && !text[text.len() - 2000..].contains("BEGIN"), "the cut lands inside the key");
655        remember_proposal(&dir, "s-cut", &text);
656        let kept = std::fs::read_to_string(dir.join("s-cut.proposal")).unwrap();
657        assert!(!kept.contains("b3BlbnNzaC1rZXkt"), "{kept}");
658        assert!(kept.contains("I will use the deploy key above."));
659        let _ = std::fs::remove_dir_all(&dir);
660    }
661
662    fn route(name: &str, share: collective::Share) -> collective::Route {
663        collective::Route::Collective { name: name.into(), home: PathBuf::from("/unused"), share }
664    }
665
666    fn sh(repo: &Path, args: &[&str]) -> String {
667        let o = Command::new("git").arg("-C").arg(repo).args(args).env("GIT_CONFIG_GLOBAL", "/dev/null").env("GIT_CONFIG_NOSYSTEM", "1").output().unwrap();
668        assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
669        String::from_utf8_lossy(&o.stdout).trim().to_string()
670    }
671
672    fn repo(tag: &str) -> (PathBuf, PathBuf, PathBuf) {
673        let base = std::env::temp_dir().join(format!("borg-notes-{tag}-{}", std::process::id()));
674        let _ = std::fs::remove_dir_all(&base);
675        let (repo, state, store) = (base.join("repo"), base.join("state"), base.join("store"));
676        std::fs::create_dir_all(&repo).unwrap();
677        sh(&repo, &["init", "-q"]);
678        sh(&repo, &["config", "user.email", "ann@example.com"]);
679        sh(&repo, &["config", "user.name", "Ann"]);
680        sh(&repo, &["commit", "-q", "--allow-empty", "-m", "base"]);
681        (repo, state, store)
682    }
683
684    #[test]
685    fn notes_attach_writes_a_redacted_note_per_landed_commit() {
686        let (repo, dir, store) = repo("attach");
687        remember_start(&dir, "s-1", &repo.display().to_string());
688        let start: Start = serde_json::from_str(&std::fs::read_to_string(dir.join("s-1.head")).unwrap()).unwrap();
689        std::fs::write(dir.join("s-1.task"), "fix the login; the staging password=hunter22 must not leak").unwrap();
690        std::fs::write(dir.join("s-1.ctx"), "src/login.rs\n#borg-episode s-1-100\n#borg-asked L1\n#borg-episode s-1-200\n").unwrap();
691        remember_proposal(&dir, "s-1", "I will rotate ghp_abcdefghijklmnopqrstuvwxyz0123456789 and take the lock first.");
692        sh(&repo, &["commit", "-q", "--allow-empty", "-m", "one"]);
693        sh(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
694        let commits = landed(&repo, &start, &windows(&dir, "s-1", &start));
695        assert_eq!(commits.len(), 2, "only the session's commits");
696
697        let corp = route("corp", collective::Share::Team);
698        let l = Landed { dir: &dir, session: "s-1", repo: &repo, store: &store, route: &corp, commits: &commits, start: None };
699        let done = attach(&l, false).unwrap();
700        assert_eq!((done.notes.len(), done.replicated), (2, 2));
701        let note = sh(&repo, &["notes", "--ref=refs/notes/borg", "show", &commits[0]]);
702        assert!(note.contains("fix the login") && note.contains("take the lock first") && note.contains("s-1-100, s-1-200"), "{note}");
703        let back = Excerpt::from_note(&commits[0], &note).unwrap();
704        assert_eq!((back.session.as_str(), back.collective.as_str(), back.episodes.len()), ("s-1", "corp", 2));
705        assert!(back.task.starts_with("fix the login") && back.proposal.ends_with("take the lock first."), "{back:?}");
706        assert!(!note.contains("hunter22") && !note.contains("ghp_abc"), "{note}");
707        let lines = std::fs::read_to_string(store.join("reasoning.jsonl")).unwrap();
708        assert_eq!(lines.lines().count(), 2);
709        assert!(!lines.contains("hunter22"));
710        assert_eq!(sh(&repo, &["remote"]), "", "nothing to push to, nothing pushed");
711
712        // Idempotent: a second run (another `end`) adds nothing.
713        assert_eq!(attach(&l, false).unwrap(), Attached::default());
714        let _ = std::fs::remove_dir_all(repo.parent().unwrap());
715    }
716
717    /// The veto's "after merge" (escalation.rs `merged`): a session that landed a commit of its own.
718    #[test]
719    fn landed_by_reads_the_sessions_recorded_start() {
720        let (repo, dir, _) = repo("landed-by");
721        assert!(landed_by(&dir, "s-3").is_empty(), "no start recorded");
722        remember_start(&dir, "s-3", &repo.display().to_string());
723        assert!(landed_by(&dir, "s-3").is_empty(), "nothing landed yet");
724        assert!(!crate::escalation::merged(&dir, "s-3"));
725        sh(&repo, &["commit", "-q", "--allow-empty", "-m", "landed"]);
726        assert_eq!(landed_by(&dir, "s-3"), vec![sh(&repo, &["rev-parse", "HEAD"])]);
727        assert!(crate::escalation::merged(&dir, "s-3"));
728        let _ = std::fs::remove_dir_all(repo.parent().unwrap());
729    }
730
731    #[test]
732    fn a_private_collective_keeps_its_notes_local() {
733        let (repo, dir, store) = repo("private");
734        remember_start(&dir, "s-2", &repo.display().to_string());
735        std::fs::write(dir.join("s-2.task"), "private refactor").unwrap();
736        sh(&repo, &["commit", "-q", "--allow-empty", "-m", "private work"]);
737        let head = sh(&repo, &["rev-parse", "HEAD"]);
738        let private = route("private", collective::Share::Local);
739        let l = Landed { dir: &dir, session: "s-2", repo: &repo, store: &store, route: &private, commits: std::slice::from_ref(&head), start: None };
740        let done = attach(&l, false).unwrap();
741        assert_eq!((done.notes.len(), done.replicated), (1, 0));
742        assert!(sh(&repo, &["notes", "--ref=refs/notes/borg", "show", &head]).contains("private refactor"), "the note itself is written");
743        assert!(!store.join("reasoning.jsonl").exists(), "nothing is queued for any peer");
744        let _ = std::fs::remove_dir_all(repo.parent().unwrap());
745    }
746
747    /// The CLI decides replication from the config, not from its caller.
748    #[test]
749    fn share_comes_from_the_config() {
750        let cfg: collective::Config =
751            serde_json::from_str(r#"{"collectives": [{"name": "private", "paths": ["/p"], "share": "local"}, {"name": "corp", "paths": ["/c"]}]}"#).unwrap();
752        let base = Path::new("/irrelevant");
753        assert!(!collective::may_sync(&collective::route(Some(&cfg), "/p/repo", base)));
754        assert!(collective::may_sync(&collective::route(Some(&cfg), "/c/repo", base)));
755    }
756
757    /// git as `sh`, at a fixed time (the reflog records the committer date).
758    fn sh_at(repo: &Path, secs: i64, args: &[&str]) {
759        let date = format!("@{secs} +0000");
760        let o = Command::new("git")
761            .arg("-C")
762            .arg(repo)
763            .args(args)
764            .env("GIT_CONFIG_GLOBAL", "/dev/null")
765            .env("GIT_CONFIG_NOSYSTEM", "1")
766            .env("GIT_COMMITTER_DATE", &date)
767            .env("GIT_AUTHOR_DATE", &date)
768            .output()
769            .unwrap();
770        assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
771    }
772
773    /// Only commits this session made get its note: not a colleague's commits a fast-forward
774    /// brought in, not a commit made while the session was idle (another session, the engineer),
775    /// not a commit on a branch checked out and left.
776    #[test]
777    fn only_the_sessions_own_commits_land() {
778        let (repo, dir, _) = repo("own");
779        let t0 = 1_700_000_000i64;
780        let main = sh(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]);
781        sh_at(&repo, t0 + 10, &["checkout", "-q", "-b", "colleague"]);
782        sh_at(&repo, t0 + 20, &["commit", "-q", "--allow-empty", "-m", "colleague's work"]);
783        let theirs = sh(&repo, &["rev-parse", "HEAD"]);
784        sh_at(&repo, t0 + 30, &["checkout", "-q", &main]);
785        let start = Start { repo: repo.display().to_string(), head: sh(&repo, &["rev-parse", "HEAD"]), ms: (t0 + 100) * 1000 };
786        std::fs::create_dir_all(&dir).unwrap();
787        std::fs::write(dir.join("s-3.active"), format!("prompt {}\nstop {}\nprompt {}\n", (t0 + 100) * 1000, (t0 + 200) * 1000 + 500, (t0 + 300) * 1000))
788            .unwrap();
789        let w = windows(&dir, "s-3", &start);
790        assert_eq!(w, vec![((t0 + 100) * 1000, (t0 + 200) * 1000 + 500), ((t0 + 300) * 1000, i64::MAX)]);
791
792        sh_at(&repo, t0 + 150, &["merge", "-q", "--ff-only", "colleague"]); // git pull, in effect
793        sh_at(&repo, t0 + 160, &["commit", "-q", "--allow-empty", "-m", "one"]);
794        let one = sh(&repo, &["rev-parse", "HEAD"]);
795        sh_at(&repo, t0 + 250, &["commit", "-q", "--allow-empty", "-m", "another session's"]);
796        sh_at(&repo, t0 + 310, &["checkout", "-q", "-b", "side"]);
797        sh_at(&repo, t0 + 320, &["commit", "-q", "--allow-empty", "-m", "abandoned"]);
798        sh_at(&repo, t0 + 330, &["checkout", "-q", &main]);
799        sh_at(&repo, t0 + 340, &["commit", "-q", "--allow-empty", "-m", "three"]);
800        let three = sh(&repo, &["rev-parse", "HEAD"]);
801        sh_at(&repo, t0 + 350, &["commit", "-q", "--amend", "--allow-empty", "-m", "three, amended"]);
802        let amended = sh(&repo, &["rev-parse", "HEAD"]);
803
804        let got = landed(&repo, &start, &w);
805        assert_eq!(got, vec![amended.clone(), one.clone()], "only the session's commits still on HEAD");
806        assert!(!got.contains(&theirs) && !got.contains(&three));
807        // Without marks: everything the session committed since it started, still not the pull.
808        let all = landed(&repo, &start, &[(start.ms, i64::MAX)]);
809        assert_eq!(all.len(), 3, "{all:?}");
810        assert!(!all.contains(&theirs));
811        let _ = std::fs::remove_dir_all(repo.parent().unwrap());
812    }
813
814    /// The link sections survive a note's round trip; a note without them parses as before, and a
815    /// proposal that merely says "fixed in" is left alone.
816    #[test]
817    fn link_sections_round_trip() {
818        let r =
819            |c: &str, s: &str| LinkRef { commit: c.into(), session: "s-7".into(), task: "fix the race".into(), date: "2026-10-12".into(), strength: s.into() };
820        let e = Excerpt {
821            kind: "reasoning".into(),
822            commit: "c".into(),
823            session: "s-1".into(),
824            task: "take the lock".into(),
825            proposal: "Fixed in the next commit, maybe.".into(),
826            episodes: vec!["s-1-1".into()],
827            collective: "work".into(),
828            fixed_by: vec![r("f1", links::FIX), r("f2", links::REVISITED)],
829            fixes: vec![r("d0", links::FIX)],
830            ..Excerpt::default()
831        };
832        assert_eq!(Excerpt::from_note("c", &e.note()), Some(e.clone()));
833        let old = Excerpt { fixed_by: vec![], fixes: vec![], ..e.clone() };
834        assert_eq!(Excerpt::from_note("c", &old.note()), Some(old.clone()));
835        assert_eq!(Excerpt::from_note("c", old.note().trim_end()).map(|x| x.episodes), Some(vec!["s-1-1".to_string()]));
836        // As git hands it back: an empty proposal (or task) loses its blank lines, the end is trimmed.
837        let empty = Excerpt { proposal: String::new(), episodes: vec![], ..old.clone() };
838        let stored = empty.note().replace("\n\n\n", "\n\n");
839        assert_eq!(Excerpt::from_note("c", stored.trim_end()), Some(empty.clone()), "{stored:?}");
840        let linked = Excerpt { fixes: vec![r("d0", links::FIX)], ..empty.clone() };
841        assert_eq!(Excerpt::from_note("c", linked.note().replace("\n\n\n", "\n\n").trim_end()), Some(linked));
842        let no_task = Excerpt { task: String::new(), ..empty };
843        assert_eq!(Excerpt::from_note("c", no_task.note().replace("\n\n\n", "\n\n").trim_end()), Some(no_task));
844    }
845
846    /// A forced re-attach (a named commit) rewrites the reasoning and keeps the links the note lists;
847    /// a `git revert` the session made is one of its commits.
848    #[test]
849    fn a_forced_attach_keeps_the_links_and_a_revert_lands() {
850        let (repo, dir, store) = repo("force");
851        remember_start(&dir, "s-1", &repo.display().to_string());
852        let start: Start = serde_json::from_str(&std::fs::read_to_string(dir.join("s-1.head")).unwrap()).unwrap();
853        std::fs::write(dir.join("s-1.task"), "use a ttl cache").unwrap();
854        std::fs::write(repo.join("cache.rs"), "ttl\n").unwrap();
855        sh(&repo, &["add", "cache.rs"]);
856        sh(&repo, &["commit", "-q", "-m", "use a ttl cache"]);
857        sh(&repo, &["revert", "--no-edit", "HEAD"]);
858        let head = sh(&repo, &["rev-parse", "HEAD"]);
859        assert_eq!(landed(&repo, &start, &windows(&dir, "s-1", &start)).first(), Some(&head), "the revert is the session's");
860        let r = |c: &str| LinkRef { commit: c.into(), session: "s-7".into(), task: "fix it".into(), date: "2026-10-12".into(), strength: links::FIX.into() };
861        let linked = Excerpt {
862            kind: "reasoning".into(),
863            commit: head.clone(),
864            session: "s-0".into(),
865            fixed_by: vec![r("f1")],
866            fixes: vec![r("d0")],
867            ..Excerpt::default()
868        };
869        write_note(&repo, &linked).unwrap();
870        let route = route("private", collective::Share::Local);
871        let l = Landed { dir: &dir, session: "s-1", repo: &repo, store: &store, route: &route, commits: std::slice::from_ref(&head), start: None };
872        attach(&l, true).unwrap();
873        let e = note_of(&repo, &head).unwrap();
874        assert_eq!((e.session.as_str(), e.task.as_str()), ("s-1", "use a ttl cache"));
875        assert_eq!((e.fixed_by, e.fixes), (vec![r("f1")], vec![r("d0")]));
876        let _ = std::fs::remove_dir_all(repo.parent().unwrap());
877    }
878
879    /// `restore` writes the excerpts the store holds for the asked commits as notes, once.
880    #[test]
881    fn restore_writes_held_excerpts_as_notes() {
882        let (repo, _, store) = repo("restore");
883        let head = sh(&repo, &["rev-parse", "HEAD"]);
884        let e = Excerpt {
885            kind: "reasoning".into(),
886            commit: head.clone(),
887            repo: "repo".into(),
888            session: "s-4".into(),
889            task: "why the lock".into(),
890            proposal: "take it first".into(),
891            episodes: vec![],
892            ms: 1,
893            ..Excerpt::default()
894        };
895        let other = Excerpt { commit: "0".repeat(40), ..e.clone() };
896        append_reasoning(&store, &other).unwrap();
897        append_reasoning(&store, &e).unwrap();
898        assert_eq!(wanted(&repo, &[]).unwrap(), vec![head.clone()]);
899        assert_eq!(restore(&repo, &store, std::slice::from_ref(&head)).unwrap(), vec![head.clone()]);
900        assert!(sh(&repo, &["notes", "--ref=refs/notes/borg", "show", &head]).contains("take it first"));
901        assert!(restore(&repo, &store, std::slice::from_ref(&head)).unwrap().is_empty(), "an existing note is kept");
902        assert_eq!(wanted(&repo, &[]).unwrap(), vec![head.clone()], "noted commits are asked too: links to them come later");
903        let _ = std::fs::remove_dir_all(repo.parent().unwrap());
904    }
905
906    #[test]
907    fn restore_brings_the_fetched_links_into_both_notes() {
908        let (repo, _, store) = repo("relink");
909        let mut shas = Vec::new();
910        for msg in ["decide", "fix", "other"] {
911            sh(&repo, &["commit", "-q", "--allow-empty", "-m", msg]);
912            shas.push(sh(&repo, &["rev-parse", "HEAD"]));
913        }
914        let (dec, fix, other) = (&shas[0], &shas[1], &shas[2]);
915        let e = |c: &str, session: &str| Excerpt {
916            kind: "reasoning".into(),
917            commit: c.into(),
918            session: session.into(),
919            task: "take the lock".into(),
920            collective: "work".into(),
921            ..Excerpt::default()
922        };
923        // What `borg notes fetch` received: the decision's excerpt and its links, one of another collective.
924        append_reasoning(&store, &e(dec, "s-1")).unwrap();
925        for (to, collective) in [(fix, "work"), (other, "private")] {
926            let side = links::Side { session: "s-7".into(), task: "fix it".into(), date: "2026-10-12".into(), ..links::Side::default() };
927            let l = Link {
928                kind: links::KIND.into(),
929                commit: dec.clone(),
930                fix: to.clone(),
931                collective: collective.into(),
932                strength: links::FIX.into(),
933                fixed: side,
934                ..Link::default()
935            };
936            learn::append_jsonl(&store.join("reasoning.jsonl"), &l).unwrap();
937        }
938        // The fix's note is there already (this machine wrote it); the decision's is not.
939        write_note(&repo, &e(fix, "s-7")).unwrap();
940        write_note(&repo, &e(other, "s-8")).unwrap();
941        assert_eq!(restore(&repo, &store, std::slice::from_ref(dec)).unwrap(), [dec.clone(), fix.clone()]);
942        let decision = note_of(&repo, dec).unwrap();
943        assert_eq!((decision.fixed_by.len(), decision.fixed_by[0].commit.as_str(), decision.fixed_by[0].task.as_str()), (1, fix.as_str(), "fix it"));
944        assert_eq!(note_of(&repo, fix).unwrap().fixes[0].commit, *dec);
945        assert!(note_of(&repo, other).unwrap().fixes.is_empty(), "a record of another collective changes no note here");
946        assert!(restore(&repo, &store, std::slice::from_ref(dec)).unwrap().is_empty(), "once");
947        let _ = std::fs::remove_dir_all(repo.parent().unwrap());
948    }
949}