1use 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";
58const MAX_COMMITS: usize = 20;
62
63const REDACTED: &str = "[REDACTED]";
64
65static SECRETS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
67 [
68 (r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----(?s:.*?)(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)", "[REDACTED PRIVATE KEY]"),
70 (r"(?s)\A.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", "[REDACTED PRIVATE KEY]"),
73 (r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b", REDACTED),
75 (r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|\bgithub_pat_[A-Za-z0-9_]{20,}", REDACTED),
76 (r"\bglpat-[A-Za-z0-9_-]{20,}", REDACTED),
77 (r"\bxox[abposr]-[A-Za-z0-9-]{10,}", REDACTED),
78 (r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}", REDACTED),
79 (r"\bAIza[0-9A-Za-z_-]{35}", REDACTED),
80 (r"\bnpm_[A-Za-z0-9]{36}\b", REDACTED),
81 (r"\bhf_[A-Za-z0-9]{30,}", REDACTED),
82 (r"\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}", REDACTED),
83 (r"\bya29\.[A-Za-z0-9_-]{20,}", REDACTED),
84 (r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}", REDACTED),
85 (r"(?i)\b([a-z][a-z0-9+.-]*://[^\s:/@]+):[^\s@/]+@", "$1:[REDACTED]@"),
87 (r"(?i)\b(bearer|basic|token)(\s+)[A-Za-z0-9._~+/=-]{12,}", "$1$2[REDACTED]"),
88 (r"(?i)\b((?:mysql|mysqldump|mysqladmin|mariadb|psql)\b[^\n]*?\s-p)[^\s-]\S*", "${1}[REDACTED]"),
90 (
93 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,;}]+)"#,
94 "$1$2[REDACTED]",
95 ),
96 (r"(?m)^(\s*(?:export\s+)?[A-Z][A-Z0-9_]*\s*=\s*)\S.*$", "$1[REDACTED]"),
98 ]
99 .into_iter()
100 .map(|(p, r)| (Regex::new(p).expect("secret pattern"), r))
101 .collect()
102});
103
104static TOKENISH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[A-Za-z0-9+/_=-]{32,}").unwrap());
106
107fn looks_random(s: &str) -> bool {
113 let mixed = |p: &str, digits: usize| {
114 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
115 };
116 let upper = s.chars().filter(|c| c.is_ascii_uppercase()).count();
117 let letters = s.chars().filter(|c| c.is_ascii_alphabetic()).count();
118 (s.len() >= 40 && mixed(s, 1) && upper * 10 >= letters * 3) || s.split('/').any(|p| p.len() >= 24 && mixed(p, 2))
119}
120
121pub fn redact(text: &str) -> String {
123 let mut out = text.to_string();
124 for (re, with) in SECRETS.iter() {
125 out = re.replace_all(&out, *with).into_owned();
126 }
127 TOKENISH.replace_all(&out, |c: &Captures| if looks_random(&c[0]) { REDACTED.to_string() } else { c[0].to_string() }).into_owned()
128}
129
130pub fn state_dir() -> PathBuf {
132 PathBuf::from(std::env::var("BORG_STATE").unwrap_or_else(|_| "/tmp/borg".into()))
133}
134
135pub(crate) fn safe(session: &str) -> String {
137 session.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect()
138}
139
140fn state(dir: &Path, session: &str, ext: &str) -> PathBuf {
141 dir.join(format!("{}.{ext}", safe(session)))
142}
143
144pub(crate) fn git(repo: &Path, args: &[&str]) -> Option<String> {
145 Command::new("git")
146 .arg("-C")
147 .arg(repo)
148 .args(args)
149 .stdin(Stdio::null())
150 .stderr(Stdio::null())
151 .output()
152 .ok()
153 .filter(|o| o.status.success())
154 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159pub struct Start {
160 pub repo: String,
161 pub head: String,
162 pub ms: i64,
163}
164
165pub fn remember_start(dir: &Path, session: &str, cwd: &str) {
167 let p = state(dir, session, "head");
168 if cwd.is_empty() || p.exists() {
169 return;
170 }
171 let start = git(Path::new(cwd), &["rev-parse", "--show-toplevel"])
172 .and_then(|repo| Some(Start { head: git(Path::new(&repo), &["rev-parse", "HEAD"])?, repo, ms: learn::now_ms() }));
173 let _ = std::fs::create_dir_all(dir);
174 let _ = std::fs::write(p, serde_json::to_string(&start).unwrap());
177}
178
179pub fn mark(dir: &Path, session: &str, what: &str) {
181 use std::io::Write;
182 let _ = std::fs::create_dir_all(dir);
183 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state(dir, session, "active")) {
184 let _ = writeln!(f, "{what} {}", learn::now_ms());
185 }
186}
187
188pub fn windows(dir: &Path, session: &str, start: &Start) -> Vec<(i64, i64)> {
191 let text = std::fs::read_to_string(state(dir, session, "active")).unwrap_or_default();
192 let mut out = Vec::new();
193 let mut open: Option<i64> = None;
194 for (what, ms) in text.lines().filter_map(|l| l.split_once(' ')).filter_map(|(w, m)| Some((w, m.trim().parse::<i64>().ok()?))) {
195 match what {
196 "prompt" => {
197 open.get_or_insert(ms);
198 }
199 "stop" => {
200 if let Some(from) = open.take() {
201 out.push((from, ms));
202 }
203 }
204 _ => {}
205 }
206 }
207 if let Some(from) = open {
208 out.push((from, i64::MAX));
209 }
210 if out.is_empty() {
211 out.push((start.ms, i64::MAX));
212 }
213 out
214}
215
216pub fn remember_proposal(dir: &Path, session: &str, text: &str) {
219 if text.trim().is_empty() {
220 return;
221 }
222 let text = redact(text);
223 let tail: String = text.chars().rev().take(crate::learn::PROPOSAL_CHARS).collect::<Vec<_>>().into_iter().rev().collect();
224 let _ = std::fs::create_dir_all(dir);
225 let _ = std::fs::write(state(dir, session, "proposal"), tail);
226}
227
228#[must_use]
231pub fn proposal(dir: &Path, session: &str) -> String {
232 std::fs::read_to_string(state(dir, session, "proposal")).unwrap_or_default()
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
237pub struct Excerpt {
238 pub kind: String,
239 pub commit: String,
240 pub repo: String,
241 pub session: String,
242 pub task: String,
243 pub proposal: String,
244 pub episodes: Vec<String>,
245 pub ms: i64,
246 #[serde(default, skip_serializing_if = "String::is_empty")]
249 pub collective: String,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub fixed_by: Vec<LinkRef>,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub fixes: Vec<LinkRef>,
256}
257
258impl Excerpt {
259 pub fn note(&self) -> String {
261 let within = if self.collective.is_empty() { String::new() } else { format!(", collective {}", self.collective) };
262 let mut s = format!("borg reasoning (session {}{within})\n\nTask:\n{}\n\nLast proposal:\n{}\n", self.session, self.task.trim(), self.proposal.trim());
263 if !self.episodes.is_empty() {
264 s.push_str(&format!("\nCorrections (episodes): {}\n", self.episodes.join(", ")));
265 }
266 s.push_str(&links::sections(&self.fixed_by, &self.fixes));
267 s
268 }
269
270 pub fn from_note(commit: &str, text: &str) -> Option<Excerpt> {
273 let (text, fixed_by, fixes) = links::split(text);
274 let (head, rest) = text.split_once('\n')?;
275 let inner = head.trim().strip_prefix("borg reasoning (session ")?.strip_suffix(')')?;
276 let (session, collective) = inner.split_once(", collective ").unwrap_or((inner, ""));
277 let rest = rest.trim_start().strip_prefix("Task:")?;
279 let (task, rest) = rest.split_once("\nLast proposal:").map_or((rest, ""), |(t, p)| (t, p.strip_prefix('\n').unwrap_or(p)));
280 let (proposal, episodes) = match rest.rsplit_once("\nCorrections (episodes): ") {
281 Some((p, e)) => (p, e.trim().split(", ").filter(|e| !e.is_empty()).map(str::to_string).collect()),
282 None => (rest, Vec::new()),
283 };
284 Some(Excerpt {
285 kind: "reasoning".into(),
286 commit: commit.into(),
287 repo: String::new(),
288 session: session.into(),
289 task: task.trim().into(),
290 proposal: proposal.trim().into(),
291 episodes,
292 ms: 0,
293 collective: collective.into(),
294 fixed_by,
295 fixes,
296 })
297 }
298}
299
300pub fn excerpt(dir: &Path, session: &str, repo: &str, commit: &str, now: i64) -> Excerpt {
302 let read = |ext| std::fs::read_to_string(state(dir, session, ext)).unwrap_or_default();
303 let episodes = read("ctx").lines().filter_map(|l| l.strip_prefix("#borg-episode ")).map(|s| s.trim().to_string()).collect();
304 Excerpt {
305 kind: "reasoning".into(),
306 commit: commit.into(),
307 repo: repo.into(),
308 session: session.into(),
309 task: redact(&read("task").chars().take(crate::learn::TASK_CHARS).collect::<String>()),
310 proposal: redact(&read("proposal")),
311 episodes,
312 ms: now,
313 ..Excerpt::default()
314 }
315}
316
317pub fn landed(repo: &Path, start: &Start, windows: &[(i64, i64)]) -> Vec<String> {
321 let on_head: std::collections::HashSet<String> =
322 git(repo, &["rev-list", "--max-count=1000", &format!("{}..HEAD", start.head)]).map(|t| t.lines().map(str::to_string).collect()).unwrap_or_default();
323 let within = |secs: i64| windows.iter().any(|&(from, to)| secs >= from.div_euclid(1000) && secs <= to.saturating_add(999) / 1000);
325 let reflog = git(repo, &["log", "-g", "-n", "1000", "--date=unix", "--format=%H%x09%gd%x09%gs", "HEAD"]).unwrap_or_default();
326 let mut out: Vec<String> = Vec::new();
327 for l in reflog.lines() {
328 let mut f = l.splitn(3, '\t');
329 let (Some(c), Some(at), Some(subject)) = (f.next(), f.next(), f.next()) else { continue };
330 let own = ["commit:", "commit (amend):", "commit (initial):", "revert:", "cherry-pick:"].iter().any(|p| subject.starts_with(p));
331 let secs = at.rsplit_once('{').and_then(|(_, t)| t.trim_end_matches('}').parse::<i64>().ok());
332 if own && secs.is_some_and(within) && on_head.contains(c) && !out.iter().any(|o| o == c) {
333 out.push(c.to_string());
334 }
335 if out.len() >= MAX_COMMITS {
336 break;
337 }
338 }
339 out
340}
341
342pub fn landed_by(dir: &Path, session: &str) -> Vec<String> {
345 start_of(dir, session).map(|s| landed(Path::new(&s.repo), &s, &windows(dir, session, &s))).unwrap_or_default()
346}
347
348pub fn start_of(dir: &Path, session: &str) -> Option<Start> {
350 std::fs::read_to_string(state(dir, session, "head")).ok().and_then(|t| serde_json::from_str::<Start>(&t).ok())
351}
352
353pub fn toplevel(dir: &str) -> Option<PathBuf> {
355 git(Path::new(dir), &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
356}
357
358#[must_use]
360pub fn note_of(repo: &Path, commit: &str) -> Option<Excerpt> {
361 Excerpt::from_note(commit, &git(repo, &["notes", &format!("--ref={NOTES_REF}"), "show", commit])?)
362}
363
364pub(crate) fn has_note(repo: &Path, commit: &str) -> bool {
365 git(repo, &["notes", &format!("--ref={NOTES_REF}"), "list", commit]).is_some()
366}
367
368pub fn write_note(repo: &Path, e: &Excerpt) -> Result<(), String> {
370 use std::io::Write;
371 let mut child = Command::new("git")
372 .arg("-C")
373 .arg(repo)
374 .args(["notes", &format!("--ref={NOTES_REF}"), "add", "-f", "-F", "-", &e.commit])
375 .stdin(Stdio::piped())
376 .stdout(Stdio::null())
377 .stderr(Stdio::piped())
378 .spawn()
379 .map_err(|err| format!("git notes: {err}"))?;
380 child.stdin.take().ok_or("git notes: no stdin")?.write_all(e.note().as_bytes()).map_err(|err| format!("git notes: {err}"))?;
381 let out = child.wait_with_output().map_err(|err| format!("git notes: {err}"))?;
382 if !out.status.success() {
383 return Err(format!("git notes add {}: {}", e.commit, String::from_utf8_lossy(&out.stderr).trim()));
384 }
385 Ok(())
386}
387
388#[derive(Debug, Default, PartialEq, Eq)]
390pub struct Attached {
391 pub notes: Vec<String>,
392 pub replicated: usize,
394}
395
396pub fn attach(l: &Landed, force: bool) -> Result<Attached, String> {
403 let collective::Route::Collective { name: within, .. } = l.route else { return Ok(Attached::default()) };
404 let may_sync = collective::may_sync(l.route);
405 let name = l.repo.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
406 let mut done = Attached::default();
407 for c in l.commits {
408 if !force && has_note(l.repo, c) {
409 continue;
410 }
411 let mut e = Excerpt { collective: within.clone(), ..excerpt(l.dir, l.session, &name, c, learn::now_ms()) };
412 if let Some(old) = note_of(l.repo, c).filter(|_| force) {
413 (e.fixed_by, e.fixes) = (old.fixed_by, old.fixes);
414 }
415 write_note(l.repo, &e)?;
416 if may_sync {
417 append_reasoning(l.store, &e).map_err(|err| format!("{}: {err}", l.store.display()))?;
418 done.replicated += 1;
419 }
420 done.notes.push(c.clone());
421 }
422 Ok(done)
423}
424
425pub(crate) fn append_reasoning(store: &Path, e: &Excerpt) -> std::io::Result<()> {
426 use std::io::Write;
427 std::fs::create_dir_all(store)?;
428 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(store.join("reasoning.jsonl"))?;
429 writeln!(f, "{}", serde_json::to_string(e).unwrap())
430}
431
432#[must_use]
434pub fn latest_session(dir: &Path, repo: &str) -> Option<(String, Start)> {
435 std::fs::read_dir(dir)
436 .ok()?
437 .flatten()
438 .filter_map(|f| {
439 let p = f.path();
440 let session = p.file_name()?.to_str()?.strip_suffix(".head")?.to_string();
441 let start: Start = serde_json::from_str(&std::fs::read_to_string(&p).ok()?).ok()?;
442 (start.repo == repo).then_some((session, start))
443 })
444 .max_by_key(|(_, s)| s.ms)
445}
446
447#[must_use]
452pub fn on_end(session: &str, input: &Value) -> bool {
453 let dir = state_dir();
454 let started = std::fs::read_to_string(state(&dir, session, "head")).ok().and_then(|t| serde_json::from_str::<Start>(&t).ok());
455 if started.is_none() {
456 return false;
457 }
458 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
459 let (Ok(exe), Some(store)) = (std::env::current_exe(), std::env::var_os("BORG_HOME")) else { return false };
460 Command::new(exe)
461 .args(["notes", "attach", "--session", session, "--dir", cwd, "--then-learn", "--store"])
462 .arg(store)
463 .stdin(Stdio::null())
464 .stdout(Stdio::null())
465 .stderr(Stdio::null())
466 .spawn()
467 .is_ok()
468}
469
470fn then_learn(args: &[&str]) {
473 let flag = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
474 let Ok(exe) = std::env::current_exe() else { return };
475 let mut c = Command::new(exe);
476 c.arg("learn").args(flag("--session").map(|s| ["--session", s]).into_iter().flatten());
477 if let Some(store) = flag("--store") {
478 c.env("BORG_HOME", store);
479 }
480 let _ = c.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
481}
482
483pub struct Landed<'a> {
486 pub dir: &'a Path,
487 pub session: &'a str,
488 pub repo: &'a Path,
489 pub store: &'a Path,
490 pub route: &'a collective::Route,
491 pub commits: &'a [String],
492 pub start: Option<&'a Start>,
494}
495
496pub type After = fn(&Landed) -> String;
499
500pub fn main(args: &[&str], after: Option<After>) -> i32 {
503 if args.first() == Some(&"fetch") {
504 let beside = std::env::current_exe().ok().map(|e| e.with_file_name("borg-mesh")).filter(|p| p.exists());
505 return match Command::new(beside.unwrap_or_else(|| "borg-mesh".into())).args(args).status() {
506 Ok(s) => s.code().unwrap_or(1),
507 Err(e) => {
508 eprintln!("borg notes: cannot run borg-mesh ({e}); cargo install borg-mesh --locked");
509 1
510 }
511 };
512 }
513 let ran = std::panic::catch_unwind(|| run(args, after)).unwrap_or_else(|_| Err("internal error; the notes written so far are kept".into()));
515 let code = match ran {
516 Ok(msg) => {
517 println!("{msg}");
518 0
519 }
520 Err(e) => {
521 eprintln!("borg notes: {e}");
522 1
523 }
524 };
525 if args.contains(&"--then-learn") {
526 then_learn(args);
527 }
528 code
529}
530
531const USAGE: &str = "usage: borg notes attach [<commit>] [--session ID] [--dir DIR] | borg notes fetch [<commit>...] [--dir DIR]";
532
533struct Place {
535 route: collective::Route,
536 store: PathBuf,
537 dir: PathBuf,
538 repo: String,
539}
540
541fn place(dir: Option<&str>, store: Option<&str>) -> Result<Place, String> {
543 let cwd = dir.map(str::to_string).unwrap_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string());
544 let cfg = collective::load().map_err(|e| format!("invalid config, nothing is attached: {e}"))?;
546 let route = collective::route(cfg.as_ref(), &cwd, &learn::home());
547 let collective::Route::Collective { home, .. } = &route else {
548 return Err(format!("{cwd} belongs to no collective (off): nothing is attached"));
549 };
550 let store = store.map_or_else(|| home.clone(), PathBuf::from);
551 let dir = std::env::var_os("BORG_STATE").map_or_else(|| store.join("state"), PathBuf::from);
552 let repo = git(Path::new(&cwd), &["rev-parse", "--show-toplevel"]).ok_or(format!("{cwd} is not in a git repo"))?;
553 Ok(Place { route, store, dir, repo })
554}
555
556fn session_of(named: Option<&str>, p: &Place) -> Result<(String, Option<Start>), String> {
558 match named {
559 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()))),
560 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()),
561 }
562}
563
564fn commits_of(named: Option<&str>, start: Option<&Start>, p: &Place, session: &str) -> Result<Vec<String>, String> {
566 match (named, start) {
567 (Some(c), _) => Ok(vec![git(Path::new(&p.repo), &["rev-parse", "--verify", &format!("{c}^{{commit}}")]).ok_or(format!("{c} is not a commit"))?]),
568 (None, Some(st)) if st.repo == p.repo => Ok(landed(Path::new(&p.repo), st, &windows(&p.dir, session, st))),
569 _ => Err("the session did not start in this repo; name a commit".into()),
570 }
571}
572
573fn message(done: &Attached, route: &collective::Route, then: &str) -> String {
575 let queued = if collective::may_sync(route) {
576 format!("{} excerpt(s) queued for the mesh's replicas", done.replicated)
577 } else {
578 "local collective: nothing leaves this machine".into()
579 };
580 format!("{} note(s) in {NOTES_REF} (not pushed); {queued}{then}", done.notes.len())
581}
582
583fn run(args: &[&str], after: Option<After>) -> Result<String, String> {
584 let flag = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
585 if args.first() != Some(&"attach") {
586 return Err(USAGE.into());
587 }
588 let commit = args.get(1).filter(|a| !a.starts_with("--")).copied();
589 let p = place(flag("--dir"), flag("--store"))?;
590 let (session, start) = session_of(flag("--session"), &p)?;
591 let commits = commits_of(commit, start.as_ref(), &p, &session)?;
592 let repo = Path::new(&p.repo);
593 let landed = Landed { dir: &p.dir, session: &session, repo, store: &p.store, route: &p.route, commits: &commits, start: start.as_ref() };
594 let done = attach(&landed, commit.is_some())?;
595 let then = after.filter(|_| commit.is_none()).map(|f| f(&landed)).unwrap_or_default();
597 Ok(message(&done, &p.route, &then))
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use links::Link;
604
605 #[test]
606 fn redacts_secrets_and_keeps_the_reasoning() {
607 let cases = [
608 ("export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "wJalr"),
609 ("use AKIAIOSFODNN7EXAMPLE for s3", "AKIAIOSFODNN7EXAMPLE"),
610 ("token ghp_abcdefghijklmnopqrstuvwxyz0123456789 in CI", "ghp_abc"),
611 ("set ANTHROPIC key sk-ant-api03-abcdefghijklmnopqrstuvwx", "sk-ant-api03"),
612 ("curl -H 'Authorization: Bearer abc.def.ghijklmnopqrstu'", "abc.def"),
613 ("postgres://admin:hunter2@db:5432/app", "hunter2"),
614 (r#"{"password": "correct horse battery"}"#, "correct horse"),
615 ("db_password = s3cr3t!", "s3cr3t"),
616 ("API_KEY: 12345abcdef", "12345abcdef"),
617 ("DATABASE_URL=mysql://u@h/db\nNODE_ENV=production", "production"),
618 ("-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----", "MIIEow"),
619 ("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk (truncated", "b3Blbn"),
620 ("jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N", "eyJhbGci"),
621 ("random Zx9Qk2LmPw8RtYv3NbHs6JdFgKa1Bc4D blob", "Zx9Qk2"),
622 ("slack xoxb-123456789012-abcdefghij", "xoxb-1234"),
623 ("const config = { secretKey: 'hunter2hunter2' }", "hunter2"),
625 (r#"{"user": "ann", "pass": "hunter2"}"#, "hunter2"),
626 ("db_pass=hunter2", "hunter2"),
627 ("then set DB_PASS=hunter2 and rerun", "hunter2"),
628 ("mysql -u root -pS3cretPass app", "S3cretPass"),
629 ("signing_key = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "9f86d0"),
630 ("pw: tr0ub4dor", "tr0ub4dor"),
631 ("the secret is wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY ok", "wJalrXUtnFEMI"),
633 ("HF hf_abcdefghijklmnopqrstuvwxyzABCDEFGH works", "hf_abc"),
634 ("stripe sk_live_abcdefghijklmnopqrstuvwx and rk_live_abcdefghijklmnopqrstuvwx", "_live_abc"),
635 ("publishable pk_test_abcdefghijklmnopqrstuvwx", "pk_test_abc"),
636 ("google ya29.a0AfH6SMBabcdefghijklmnopqrstuv", "ya29.a0Af"),
637 ("bnBlbnNzaC1rZXktdjEAAAAABG5vbmU\nAAAAAAAAAAEAAAAzAAAAC3NzaC1lZDI1\n-----END OPENSSH PRIVATE KEY-----\nthen I rotated it", "bnBlbn"),
639 ];
640 for (text, secret) in cases {
641 let r = redact(text);
642 assert!(!r.contains(secret), "{text:?} -> {r:?}");
643 assert!(r.contains("[REDACTED"), "{text:?} -> {r:?}");
644 }
645 let plain = "Take the lock before reading the flag (commit 3f2a9c1e5b7d4f6a8c0e2b4d6f8a0c2e4b6d8f0a, \
646 file /home/user/src/borg-ai/crates/borg-mesh/src/sync.rs, author: Ann, 12 tokens: fine). \
647 id 123e4567-e89b-12d3-a456-426614174000; HashMapWithLongCamelCaseName stays.";
648 assert_eq!(redact(plain), plain, "reasoning without secrets is untouched");
649 assert!(redact("-----END RSA PRIVATE KEY-----\nthen I rotated it").ends_with("then I rotated it"), "what follows the key stays");
650 let paths = "see /Users/Ann/Projects/MyApp/src/main.rs and crates/borg-mesh/src/sync.rs for Lock2Order";
651 assert_eq!(redact(paths), paths, "paths are not secrets");
652 }
653
654 #[test]
657 fn a_long_proposal_is_redacted_before_it_is_cut() {
658 let dir = std::env::temp_dir().join(format!("borg-notes-cut-{}", std::process::id()));
659 let _ = std::fs::remove_dir_all(&dir);
660 let body: String = (0..60).map(|i| format!("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAAB{i:02}\n")).collect();
661 let text = format!("-----BEGIN OPENSSH PRIVATE KEY-----\n{body}-----END OPENSSH PRIVATE KEY-----\nI will use the deploy key above.");
662 assert!(text.len() > 2000 && !text[text.len() - 2000..].contains("BEGIN"), "the cut lands inside the key");
663 remember_proposal(&dir, "s-cut", &text);
664 let kept = std::fs::read_to_string(dir.join("s-cut.proposal")).unwrap();
665 assert!(!kept.contains("b3BlbnNzaC1rZXkt"), "{kept}");
666 assert!(kept.contains("I will use the deploy key above."));
667 let _ = std::fs::remove_dir_all(&dir);
668 }
669
670 fn route(name: &str, share: collective::Share) -> collective::Route {
671 collective::Route::Collective { name: name.into(), home: PathBuf::from("/unused"), share }
672 }
673
674 fn sh(repo: &Path, args: &[&str]) -> String {
675 let o = Command::new("git").arg("-C").arg(repo).args(args).env("GIT_CONFIG_GLOBAL", "/dev/null").env("GIT_CONFIG_NOSYSTEM", "1").output().unwrap();
676 assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
677 String::from_utf8_lossy(&o.stdout).trim().to_string()
678 }
679
680 fn repo(tag: &str) -> (PathBuf, PathBuf, PathBuf) {
681 let base = std::env::temp_dir().join(format!("borg-notes-{tag}-{}", std::process::id()));
682 let _ = std::fs::remove_dir_all(&base);
683 let (repo, state, store) = (base.join("repo"), base.join("state"), base.join("store"));
684 std::fs::create_dir_all(&repo).unwrap();
685 sh(&repo, &["init", "-q"]);
686 sh(&repo, &["config", "user.email", "ann@example.com"]);
687 sh(&repo, &["config", "user.name", "Ann"]);
688 sh(&repo, &["commit", "-q", "--allow-empty", "-m", "base"]);
689 (repo, state, store)
690 }
691
692 #[test]
693 fn notes_attach_writes_a_redacted_note_per_landed_commit() {
694 let (repo, dir, store) = repo("attach");
695 remember_start(&dir, "s-1", &repo.display().to_string());
696 let start: Start = serde_json::from_str(&std::fs::read_to_string(dir.join("s-1.head")).unwrap()).unwrap();
697 std::fs::write(dir.join("s-1.task"), "fix the login; the staging password=hunter22 must not leak").unwrap();
698 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();
699 remember_proposal(&dir, "s-1", "I will rotate ghp_abcdefghijklmnopqrstuvwxyz0123456789 and take the lock first.");
700 sh(&repo, &["commit", "-q", "--allow-empty", "-m", "one"]);
701 sh(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
702 let commits = landed(&repo, &start, &windows(&dir, "s-1", &start));
703 assert_eq!(commits.len(), 2, "only the session's commits");
704
705 let corp = route("corp", collective::Share::Team);
706 let l = Landed { dir: &dir, session: "s-1", repo: &repo, store: &store, route: &corp, commits: &commits, start: None };
707 let done = attach(&l, false).unwrap();
708 assert_eq!((done.notes.len(), done.replicated), (2, 2));
709 let note = sh(&repo, &["notes", "--ref=refs/notes/borg", "show", &commits[0]]);
710 assert!(note.contains("fix the login") && note.contains("take the lock first") && note.contains("s-1-100, s-1-200"), "{note}");
711 let back = Excerpt::from_note(&commits[0], ¬e).unwrap();
712 assert_eq!((back.session.as_str(), back.collective.as_str(), back.episodes.len()), ("s-1", "corp", 2));
713 assert!(back.task.starts_with("fix the login") && back.proposal.ends_with("take the lock first."), "{back:?}");
714 assert!(!note.contains("hunter22") && !note.contains("ghp_abc"), "{note}");
715 let lines = std::fs::read_to_string(store.join("reasoning.jsonl")).unwrap();
716 assert_eq!(lines.lines().count(), 2);
717 assert!(!lines.contains("hunter22"));
718 assert_eq!(sh(&repo, &["remote"]), "", "nothing to push to, nothing pushed");
719
720 assert_eq!(attach(&l, false).unwrap(), Attached::default());
722 let _ = std::fs::remove_dir_all(repo.parent().unwrap());
723 }
724
725 #[test]
727 fn landed_by_reads_the_sessions_recorded_start() {
728 let (repo, dir, _) = repo("landed-by");
729 assert!(landed_by(&dir, "s-3").is_empty(), "no start recorded");
730 remember_start(&dir, "s-3", &repo.display().to_string());
731 assert!(landed_by(&dir, "s-3").is_empty(), "nothing landed yet");
732 assert!(!crate::escalation::merged(&dir, "s-3"));
733 sh(&repo, &["commit", "-q", "--allow-empty", "-m", "landed"]);
734 assert_eq!(landed_by(&dir, "s-3"), vec![sh(&repo, &["rev-parse", "HEAD"])]);
735 assert!(crate::escalation::merged(&dir, "s-3"));
736 let _ = std::fs::remove_dir_all(repo.parent().unwrap());
737 }
738
739 #[test]
740 fn a_private_collective_keeps_its_notes_local() {
741 let (repo, dir, store) = repo("private");
742 remember_start(&dir, "s-2", &repo.display().to_string());
743 std::fs::write(dir.join("s-2.task"), "private refactor").unwrap();
744 sh(&repo, &["commit", "-q", "--allow-empty", "-m", "private work"]);
745 let head = sh(&repo, &["rev-parse", "HEAD"]);
746 let private = route("private", collective::Share::Local);
747 let l = Landed { dir: &dir, session: "s-2", repo: &repo, store: &store, route: &private, commits: std::slice::from_ref(&head), start: None };
748 let done = attach(&l, false).unwrap();
749 assert_eq!((done.notes.len(), done.replicated), (1, 0));
750 assert!(sh(&repo, &["notes", "--ref=refs/notes/borg", "show", &head]).contains("private refactor"), "the note itself is written");
751 assert!(!store.join("reasoning.jsonl").exists(), "nothing is queued for any peer");
752 let _ = std::fs::remove_dir_all(repo.parent().unwrap());
753 }
754
755 #[test]
757 fn share_comes_from_the_config() {
758 let cfg: collective::Config =
759 serde_json::from_str(r#"{"collectives": [{"name": "private", "paths": ["/p"], "share": "local"}, {"name": "corp", "paths": ["/c"]}]}"#).unwrap();
760 let base = Path::new("/irrelevant");
761 assert!(!collective::may_sync(&collective::route(Some(&cfg), "/p/repo", base)));
762 assert!(collective::may_sync(&collective::route(Some(&cfg), "/c/repo", base)));
763 }
764
765 fn sh_at(repo: &Path, secs: i64, args: &[&str]) {
767 let date = format!("@{secs} +0000");
768 let o = Command::new("git")
769 .arg("-C")
770 .arg(repo)
771 .args(args)
772 .env("GIT_CONFIG_GLOBAL", "/dev/null")
773 .env("GIT_CONFIG_NOSYSTEM", "1")
774 .env("GIT_COMMITTER_DATE", &date)
775 .env("GIT_AUTHOR_DATE", &date)
776 .output()
777 .unwrap();
778 assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
779 }
780
781 #[test]
785 fn only_the_sessions_own_commits_land() {
786 let (repo, dir, _) = repo("own");
787 let t0 = 1_700_000_000i64;
788 let main = sh(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]);
789 sh_at(&repo, t0 + 10, &["checkout", "-q", "-b", "colleague"]);
790 sh_at(&repo, t0 + 20, &["commit", "-q", "--allow-empty", "-m", "colleague's work"]);
791 let theirs = sh(&repo, &["rev-parse", "HEAD"]);
792 sh_at(&repo, t0 + 30, &["checkout", "-q", &main]);
793 let start = Start { repo: repo.display().to_string(), head: sh(&repo, &["rev-parse", "HEAD"]), ms: (t0 + 100) * 1000 };
794 std::fs::create_dir_all(&dir).unwrap();
795 std::fs::write(dir.join("s-3.active"), format!("prompt {}\nstop {}\nprompt {}\n", (t0 + 100) * 1000, (t0 + 200) * 1000 + 500, (t0 + 300) * 1000))
796 .unwrap();
797 let w = windows(&dir, "s-3", &start);
798 assert_eq!(w, vec![((t0 + 100) * 1000, (t0 + 200) * 1000 + 500), ((t0 + 300) * 1000, i64::MAX)]);
799
800 sh_at(&repo, t0 + 150, &["merge", "-q", "--ff-only", "colleague"]); sh_at(&repo, t0 + 160, &["commit", "-q", "--allow-empty", "-m", "one"]);
802 let one = sh(&repo, &["rev-parse", "HEAD"]);
803 sh_at(&repo, t0 + 250, &["commit", "-q", "--allow-empty", "-m", "another session's"]);
804 sh_at(&repo, t0 + 310, &["checkout", "-q", "-b", "side"]);
805 sh_at(&repo, t0 + 320, &["commit", "-q", "--allow-empty", "-m", "abandoned"]);
806 sh_at(&repo, t0 + 330, &["checkout", "-q", &main]);
807 sh_at(&repo, t0 + 340, &["commit", "-q", "--allow-empty", "-m", "three"]);
808 let three = sh(&repo, &["rev-parse", "HEAD"]);
809 sh_at(&repo, t0 + 350, &["commit", "-q", "--amend", "--allow-empty", "-m", "three, amended"]);
810 let amended = sh(&repo, &["rev-parse", "HEAD"]);
811
812 let got = landed(&repo, &start, &w);
813 assert_eq!(got, vec![amended.clone(), one.clone()], "only the session's commits still on HEAD");
814 assert!(!got.contains(&theirs) && !got.contains(&three));
815 let all = landed(&repo, &start, &[(start.ms, i64::MAX)]);
817 assert_eq!(all.len(), 3, "{all:?}");
818 assert!(!all.contains(&theirs));
819 let _ = std::fs::remove_dir_all(repo.parent().unwrap());
820 }
821
822 #[test]
825 fn link_sections_round_trip() {
826 let r =
827 |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() };
828 let e = Excerpt {
829 kind: "reasoning".into(),
830 commit: "c".into(),
831 session: "s-1".into(),
832 task: "take the lock".into(),
833 proposal: "Fixed in the next commit, maybe.".into(),
834 episodes: vec!["s-1-1".into()],
835 collective: "work".into(),
836 fixed_by: vec![r("f1", links::FIX), r("f2", links::REVISITED)],
837 fixes: vec![r("d0", links::FIX)],
838 ..Excerpt::default()
839 };
840 assert_eq!(Excerpt::from_note("c", &e.note()), Some(e.clone()));
841 let old = Excerpt { fixed_by: vec![], fixes: vec![], ..e.clone() };
842 assert_eq!(Excerpt::from_note("c", &old.note()), Some(old.clone()));
843 assert_eq!(Excerpt::from_note("c", old.note().trim_end()).map(|x| x.episodes), Some(vec!["s-1-1".to_string()]));
844 let empty = Excerpt { proposal: String::new(), episodes: vec![], ..old.clone() };
846 let stored = empty.note().replace("\n\n\n", "\n\n");
847 assert_eq!(Excerpt::from_note("c", stored.trim_end()), Some(empty.clone()), "{stored:?}");
848 let linked = Excerpt { fixes: vec![r("d0", links::FIX)], ..empty.clone() };
849 assert_eq!(Excerpt::from_note("c", linked.note().replace("\n\n\n", "\n\n").trim_end()), Some(linked));
850 let no_task = Excerpt { task: String::new(), ..empty };
851 assert_eq!(Excerpt::from_note("c", no_task.note().replace("\n\n\n", "\n\n").trim_end()), Some(no_task));
852 }
853
854 #[test]
857 fn a_forced_attach_keeps_the_links_and_a_revert_lands() {
858 let (repo, dir, store) = repo("force");
859 remember_start(&dir, "s-1", &repo.display().to_string());
860 let start: Start = serde_json::from_str(&std::fs::read_to_string(dir.join("s-1.head")).unwrap()).unwrap();
861 std::fs::write(dir.join("s-1.task"), "use a ttl cache").unwrap();
862 std::fs::write(repo.join("cache.rs"), "ttl\n").unwrap();
863 sh(&repo, &["add", "cache.rs"]);
864 sh(&repo, &["commit", "-q", "-m", "use a ttl cache"]);
865 sh(&repo, &["revert", "--no-edit", "HEAD"]);
866 let head = sh(&repo, &["rev-parse", "HEAD"]);
867 assert_eq!(landed(&repo, &start, &windows(&dir, "s-1", &start)).first(), Some(&head), "the revert is the session's");
868 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() };
869 let linked = Excerpt {
870 kind: "reasoning".into(),
871 commit: head.clone(),
872 session: "s-0".into(),
873 fixed_by: vec![r("f1")],
874 fixes: vec![r("d0")],
875 ..Excerpt::default()
876 };
877 write_note(&repo, &linked).unwrap();
878 let route = route("private", collective::Share::Local);
879 let l = Landed { dir: &dir, session: "s-1", repo: &repo, store: &store, route: &route, commits: std::slice::from_ref(&head), start: None };
880 attach(&l, true).unwrap();
881 let e = note_of(&repo, &head).unwrap();
882 assert_eq!((e.session.as_str(), e.task.as_str()), ("s-1", "use a ttl cache"));
883 assert_eq!((e.fixed_by, e.fixes), (vec![r("f1")], vec![r("d0")]));
884 let _ = std::fs::remove_dir_all(repo.parent().unwrap());
885 }
886
887 #[test]
889 fn restore_writes_held_excerpts_as_notes() {
890 let (repo, _, store) = repo("restore");
891 let head = sh(&repo, &["rev-parse", "HEAD"]);
892 let e = Excerpt {
893 kind: "reasoning".into(),
894 commit: head.clone(),
895 repo: "repo".into(),
896 session: "s-4".into(),
897 task: "why the lock".into(),
898 proposal: "take it first".into(),
899 episodes: vec![],
900 ms: 1,
901 ..Excerpt::default()
902 };
903 let other = Excerpt { commit: "0".repeat(40), ..e.clone() };
904 append_reasoning(&store, &other).unwrap();
905 append_reasoning(&store, &e).unwrap();
906 assert_eq!(wanted(&repo, &[]).unwrap(), vec![head.clone()]);
907 assert_eq!(restore(&repo, &store, std::slice::from_ref(&head)).unwrap(), vec![head.clone()]);
908 assert!(sh(&repo, &["notes", "--ref=refs/notes/borg", "show", &head]).contains("take it first"));
909 assert!(restore(&repo, &store, std::slice::from_ref(&head)).unwrap().is_empty(), "an existing note is kept");
910 assert_eq!(wanted(&repo, &[]).unwrap(), vec![head.clone()], "noted commits are asked too: links to them come later");
911 let _ = std::fs::remove_dir_all(repo.parent().unwrap());
912 }
913
914 #[test]
915 fn restore_brings_the_fetched_links_into_both_notes() {
916 let (repo, _, store) = repo("relink");
917 let mut shas = Vec::new();
918 for msg in ["decide", "fix", "other"] {
919 sh(&repo, &["commit", "-q", "--allow-empty", "-m", msg]);
920 shas.push(sh(&repo, &["rev-parse", "HEAD"]));
921 }
922 let (dec, fix, other) = (&shas[0], &shas[1], &shas[2]);
923 let e = |c: &str, session: &str| Excerpt {
924 kind: "reasoning".into(),
925 commit: c.into(),
926 session: session.into(),
927 task: "take the lock".into(),
928 collective: "work".into(),
929 ..Excerpt::default()
930 };
931 append_reasoning(&store, &e(dec, "s-1")).unwrap();
933 for (to, collective) in [(fix, "work"), (other, "private")] {
934 let side = links::Side { session: "s-7".into(), task: "fix it".into(), date: "2026-10-12".into(), ..links::Side::default() };
935 let l = Link {
936 kind: links::KIND.into(),
937 commit: dec.clone(),
938 fix: to.clone(),
939 collective: collective.into(),
940 strength: links::FIX.into(),
941 fixed: side,
942 ..Link::default()
943 };
944 learn::append_jsonl(&store.join("reasoning.jsonl"), &l).unwrap();
945 }
946 write_note(&repo, &e(fix, "s-7")).unwrap();
948 write_note(&repo, &e(other, "s-8")).unwrap();
949 assert_eq!(restore(&repo, &store, std::slice::from_ref(dec)).unwrap(), [dec.clone(), fix.clone()]);
950 let decision = note_of(&repo, dec).unwrap();
951 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"));
952 assert_eq!(note_of(&repo, fix).unwrap().fixes[0].commit, *dec);
953 assert!(note_of(&repo, other).unwrap().fixes.is_empty(), "a record of another collective changes no note here");
954 assert!(restore(&repo, &store, std::slice::from_ref(dec)).unwrap().is_empty(), "once");
955 let _ = std::fs::remove_dir_all(repo.parent().unwrap());
956 }
957}