Skip to main content

borg/
why.rs

1//! `borg why`: the reasoning behind code, on demand (PLAN.md "Recall mechanics").
2//!
3//! `borg why <path>[:<line>[-<line>]]` blames the span (the whole file without a range) and
4//! `borg why <commit>` takes that commit. For each distinct commit, newest first: short sha, date,
5//! author and subject, then the drone's reasoning (task, last proposal, episode ids) and the lessons
6//! learned from those episodes (question and consequence, one line each).
7//!
8//! **Where the reasoning comes from**: the commit's borg note (`refs/notes/borg`, notes.rs); if the
9//! repo lacks it, the excerpts the collective's store holds (`reasoning.jsonl`: this machine's own
10//! and those the mesh replicated to it from peers). No network and no model call: `borg notes fetch`
11//! is the step that asks peers. Lessons come from the routed collective's `lessons.jsonl` only.
12//!
13//! **Links** (notes/regress.rs): a decision shows the later commits that fixed its lines (`→ fixed
14//! in Y (task: …)`), a fix the decisions it fixed (`← decided in X (task: …) → fixed here`), from
15//! the notes and the store's link records, at most 3 each unless `--full` (why/linked.rs).
16//!
17//! **Privacy**: decided before git is asked anything. A cwd (or target) that routes off, or an
18//! invalid config, prints nothing and exits 0. A note or excerpt that names another collective is
19//! never shown (a private collective's note only where the cwd routes to that same collective);
20//! an excerpt from before notes named their collective is treated as this repo's. Everything shown
21//! is redacted again at display time (`notes::redact`), whatever the note says. Paths are routed
22//! resolved (absolute, symlinks followed, as git sees them), and the repo git reads must route to
23//! the cwd's collective too: a symlink or submodule into an off or other repo prints nothing.
24//!
25//! **Cost**: the blame headers give each commit's time, so the cap is applied before any
26//! per-commit work; then one `git log`, one `git notes list` plus one `cat-file --batch`, and one
27//! read of `reasoning.jsonl` (only if a shown commit lacks a note).
28//!
29//! **Compact by default**: at most `MAX_COMMITS` commits, the task's first line and the proposal's
30//! last `PROPOSAL_CAP` characters, `MAX_LESSONS` lessons per commit. `--full` shows everything,
31//! `--json` prints the same (capped unless `--full`) for scripts. It costs tokens only when a drone
32//! runs it: nothing injects it.
33
34use std::collections::{HashMap, HashSet};
35use std::fmt::Write as _;
36use std::path::{Path, PathBuf};
37
38use serde::Serialize;
39
40use crate::collective::{self, Config, Route};
41use crate::learn;
42use crate::notes::reasoning::{git_in, notes_of, stored_of};
43use crate::notes::{Excerpt, redact, regress};
44
45mod linked;
46
47pub use linked::LinkLine;
48
49const MAX_COMMITS: usize = 8;
50const MAX_LESSONS: usize = 3;
51const TASK_CAP: usize = 240;
52const PROPOSAL_CAP: usize = 400;
53const LESSON_CAP: usize = 240;
54
55const USAGE: &str = "usage: borg why <path>[:<line>[-<line>]] | <commit> [--full] [--json] [--dir DIR]";
56
57/// What to explain.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Target {
60    /// A file, optionally a 1-based inclusive line range.
61    Span {
62        path: String,
63        lines: Option<(u32, u32)>,
64    },
65    Commit(String),
66}
67
68/// `path`, `path:12` or `path:12-20` (a commit is told apart later, by what exists).
69pub fn parse_span(arg: &str) -> Result<(String, Option<(u32, u32)>), String> {
70    let Some((path, range)) = arg.rsplit_once(':') else { return Ok((arg.to_string(), None)) };
71    if path.is_empty() || range.is_empty() || !range.chars().all(|c| c.is_ascii_digit() || c == '-') {
72        // Not a line range (`a:b` as a file name, `HEAD:path`): the whole argument.
73        return Ok((arg.to_string(), None));
74    }
75    let num = |s: &str| s.parse::<u32>().map_err(|_| format!("bad line range {range:?} in {arg:?}"));
76    let (a, b) = match range.split_once('-') {
77        Some((a, b)) => (num(a)?, num(b)?),
78        None => (num(range)?, num(range)?),
79    };
80    if a == 0 || b < a {
81        return Err(format!("bad line range {range:?} in {arg:?}: lines start at 1 and the end is not before the start"));
82    }
83    Ok((path.to_string(), Some((a, b))))
84}
85
86fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
87    git_in(dir, args, None).map(|o| String::from_utf8_lossy(&o).into_owned())
88}
89
90/// `p` absolute with symlinks resolved (routing compares path text, git follows the real path);
91/// lexically absolute when it does not exist.
92fn resolve(p: &Path) -> PathBuf {
93    std::fs::canonicalize(p).or_else(|_| std::path::absolute(p)).unwrap_or_else(|_| p.to_path_buf())
94}
95
96fn is_sha(s: &str) -> bool {
97    (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
98}
99
100/// The distinct commits of `git blame --porcelain` output with their author time, in order of
101/// first appearance, and the number of lines not committed yet.
102pub fn blamed(porcelain: &str) -> (Vec<(String, i64)>, usize) {
103    let (mut out, mut uncommitted) = (Vec::<(String, i64)>::new(), 0);
104    let mut seen = HashSet::<String>::new();
105    // The commit whose header lines follow (only its first appearance carries them).
106    let mut current: Option<usize> = None;
107    for l in porcelain.lines().filter(|l| !l.starts_with('\t')) {
108        if let Some(t) = l.strip_prefix("author-time ") {
109            if let Some(i) = current {
110                out[i].1 = t.trim().parse().unwrap_or(0);
111            }
112            continue;
113        }
114        let mut f = l.split(' ');
115        let (Some(sha), Some(_), Some(_)) = (f.next(), f.next(), f.next()) else { continue };
116        if !is_sha(sha) {
117            continue;
118        }
119        if sha.bytes().all(|b| b == b'0') {
120            current = None;
121            // One header per line of an uncommitted group would overcount; a group header has 4 fields.
122            uncommitted += f.next().and_then(|n| n.parse::<usize>().ok()).unwrap_or(0);
123        } else if !seen.insert(sha.to_string()) {
124            current = None;
125        } else {
126            out.push((sha.to_string(), 0));
127            current = Some(out.len() - 1);
128        }
129    }
130    (out, uncommitted)
131}
132
133#[derive(Debug, Clone, Serialize, PartialEq)]
134pub struct LessonLine {
135    pub id: String,
136    pub question: String,
137    pub consequence: String,
138}
139
140#[derive(Debug, Clone, Serialize, PartialEq)]
141pub struct Reasoning {
142    /// "note" (the repo's borg note) or "store" (the collective's reasoning.jsonl, replicated).
143    pub source: String,
144    pub session: String,
145    pub task: String,
146    pub proposal: String,
147    pub episodes: Vec<String>,
148}
149
150#[derive(Debug, Clone, Serialize, PartialEq)]
151pub struct Why {
152    pub sha: String,
153    pub short: String,
154    pub author: String,
155    pub date: String,
156    pub subject: String,
157    pub reasoning: Option<Reasoning>,
158    pub lessons: Vec<LessonLine>,
159    /// Later commits that fixed (or revisited) this one's lines (why/linked.rs).
160    #[serde(skip_serializing_if = "Vec::is_empty")]
161    pub fixed_in: Vec<LinkLine>,
162    /// The earlier decisions this commit fixed.
163    #[serde(skip_serializing_if = "Vec::is_empty")]
164    pub fixes: Vec<LinkLine>,
165}
166
167#[derive(Debug, Clone, Serialize, PartialEq)]
168pub struct Report {
169    pub target: String,
170    pub collective: String,
171    pub commits: Vec<Why>,
172    /// Commits left out by the compact cap.
173    pub more: usize,
174    /// Lines of the span not committed yet.
175    pub uncommitted: usize,
176}
177
178fn first_chars(s: &str, n: usize) -> String {
179    if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n).collect::<String>()) }
180}
181
182fn last_chars(s: &str, n: usize) -> String {
183    let len = s.chars().count();
184    if len <= n { s.to_string() } else { format!("…{}", s.chars().skip(len - n).collect::<String>()) }
185}
186
187fn one_line(s: &str) -> String {
188    s.split_whitespace().collect::<Vec<_>>().join(" ")
189}
190
191/// Defence in depth: whatever wrote the note, it is redacted again before anyone sees it.
192fn shown(e: Excerpt, source: &str) -> Reasoning {
193    Reasoning {
194        source: source.into(),
195        session: e.session,
196        task: redact(&e.task),
197        proposal: redact(&e.proposal),
198        episodes: e.episodes.iter().map(|x| redact(x)).collect(),
199    }
200}
201
202/// The lessons in `lessons` learned from any of `episodes`.
203fn lessons_of(lessons: &[learn::Lesson], episodes: &[String]) -> Vec<LessonLine> {
204    lessons
205        .iter()
206        .filter(|l| l.episodes.iter().any(|e| episodes.contains(e)))
207        .map(|l| LessonLine { id: l.id.clone(), question: redact(&l.question), consequence: redact(&l.consequence) })
208        .collect()
209}
210
211/// Where `borg why` reads: the cwd's collective and store, the repo and the target in it.
212struct Place {
213    name: String,
214    home: PathBuf,
215    repo: PathBuf,
216    /// Where git runs for a span: the file's directory.
217    dir: PathBuf,
218    file: PathBuf,
219    path: String,
220    target: Target,
221}
222
223/// The target as a file span in the cwd's collective, or a commit; `None` when the file routes off.
224fn target_of(cfg: Option<&Config>, base: &Path, cwd: &Path, route: &Route, target: &str) -> Result<Option<(Target, PathBuf, String)>, String> {
225    let (path, lines) = parse_span(target)?;
226    let file = resolve(&cwd.join(&path));
227    if file.is_file() {
228        // The file itself may lie under an off path or in another collective than the cwd.
229        return match collective::route(cfg, &file.to_string_lossy(), base) {
230            Route::Off => Ok(None),
231            r if r != *route => Err(format!("{path} belongs to another collective than {}; run borg why from its repo", cwd.display())),
232            _ => Ok(Some((Target::Span { path: path.clone(), lines }, file, path))),
233        };
234    }
235    if lines.is_some() {
236        return Err(format!("{path}: no such file"));
237    }
238    Ok(Some((Target::Commit(target.to_string()), file, path)))
239}
240
241/// The place `target` is read from `cwd`, or `None` when the cwd, the target or the repo they lie
242/// in routes off or elsewhere (module doc).
243fn place(cfg: Option<&Config>, base: &Path, cwd: &str, target: &str) -> Result<Option<Place>, String> {
244    if cwd.trim().is_empty() {
245        return Ok(None);
246    }
247    let cwd = resolve(Path::new(cwd));
248    let route = collective::route(cfg, &cwd.to_string_lossy(), base);
249    let Route::Collective { name, home, .. } = &route else { return Ok(None) };
250    let Some((target, file, path)) = target_of(cfg, base, &cwd, &route, target)? else { return Ok(None) };
251    let dir = match &target {
252        Target::Span { .. } => file.parent().map_or_else(|| cwd.clone(), Path::to_path_buf),
253        Target::Commit(_) => cwd.clone(),
254    };
255    let repo = git(&dir, &["rev-parse", "--show-toplevel"]).map(|s| PathBuf::from(s.trim())).map_err(|_| format!("{} is not in a git repo", dir.display()))?;
256    // The repo itself must route where the cwd does: a symlinked directory or a submodule may put
257    // the history git reads in an off or another collective's repo.
258    let repo = resolve(&repo);
259    if collective::route(cfg, &repo.to_string_lossy(), base) != route {
260        return Ok(None);
261    }
262    Ok(Some(Place { name: name.clone(), home: home.clone(), repo, dir, file, path, target }))
263}
264
265/// The commits behind the target with their author time, and the span's lines not committed yet.
266fn commits_of(p: &Place) -> Result<(Vec<(String, i64)>, usize), String> {
267    match &p.target {
268        Target::Span { lines, .. } => {
269            let name = p.file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
270            let mut args = vec!["blame".to_string(), "--porcelain".into()];
271            if let Some((a, b)) = lines {
272                args.push(format!("-L{a},{b}"));
273            }
274            args.extend(["--".into(), name]);
275            let out = git(&p.dir, &args.iter().map(String::as_str).collect::<Vec<_>>()).map_err(|e| format!("git blame {}: {e}", p.path))?;
276            Ok(blamed(&out))
277        }
278        Target::Commit(c) => {
279            let sha = git(&p.repo, &["rev-parse", "--verify", "--quiet", &format!("{c}^{{commit}}")])
280                .map_err(|_| format!("{c} is neither a file nor a commit in {}", p.repo.display()))?;
281            Ok((vec![(sha.trim().to_string(), 0)], 0))
282        }
283    }
284}
285
286/// One `git log` for the shown commits' headers: short sha, author, date and subject by commit.
287fn headers(repo: &Path, shas: &[String]) -> Result<HashMap<String, [String; 4]>, String> {
288    let input: String = shas.iter().map(|s| format!("{s}\n")).collect();
289    let log = git_in(repo, &["log", "--no-walk=unsorted", "--stdin", "--date=short", "--format=%H%x1f%h%x1f%an%x1f%ad%x1f%s"], Some(input))
290        .map_err(|e| format!("git log: {e}"))?;
291    let mut info = HashMap::new();
292    for l in String::from_utf8_lossy(&log).lines() {
293        let f: Vec<&str> = l.splitn(5, '\x1f').collect();
294        if let [sha, short, author, date, subject] = f[..] {
295            info.insert(sha.to_string(), [short.into(), author.into(), date.into(), subject.into()]);
296        }
297    }
298    Ok(info)
299}
300
301/// Each shown commit with its reasoning (note, else store), the lessons learned from its episodes
302/// and its decision → fix links.
303fn describe(p: &Place, shas: &[String]) -> Result<Vec<Why>, String> {
304    let mut info = headers(&p.repo, shas)?;
305    let mut from_notes = notes_of(&p.repo, &p.name, shas);
306    let missing: HashSet<&str> = shas.iter().map(String::as_str).filter(|s| !from_notes.contains_key(*s)).collect();
307    let mut from_store = stored_of(&p.home, &p.name, &missing);
308    let lessons = learn::lessons(&p.home.join("lessons.jsonl"));
309    let stored_links = regress::links_of(&p.home, &p.name, &shas.iter().map(String::as_str).collect::<HashSet<_>>());
310    let mut commits = Vec::new();
311    for sha in shas {
312        let Some([short, author, date, subject]) = info.remove(sha) else { return Err(format!("git log {sha}: unexpected output")) };
313        let (excerpt, source) = match from_notes.remove(sha) {
314            Some(e) => (Some(e), "note"),
315            None => (from_store.remove(sha), "store"),
316        };
317        let (fixed_in, fixes) = linked::of(sha, excerpt.as_ref(), &stored_links);
318        let reasoning = excerpt.map(|e| shown(e, source));
319        let lessons = reasoning.as_ref().map(|r| lessons_of(&lessons, &r.episodes)).unwrap_or_default();
320        commits.push(Why { sha: sha.clone(), short, author, date, subject: redact(&subject), reasoning, lessons, fixed_in, fixes });
321    }
322    Ok(commits)
323}
324
325/// The compact form of a commit: capped lessons and links, the task's first line, the proposal's end.
326fn compact(w: &mut Why) {
327    w.lessons.truncate(MAX_LESSONS);
328    for l in &mut w.lessons {
329        l.question = first_chars(&one_line(&l.question), LESSON_CAP);
330        l.consequence = first_chars(&one_line(&l.consequence), LESSON_CAP);
331    }
332    if let Some(r) = &mut w.reasoning {
333        r.task = first_chars(r.task.lines().find(|l| !l.trim().is_empty()).unwrap_or_default().trim(), TASK_CAP);
334        r.proposal = last_chars(&one_line(&r.proposal), PROPOSAL_CAP);
335    }
336    linked::compact(&mut w.fixed_in);
337    linked::compact(&mut w.fixes);
338}
339
340/// The explanation of `target` (an argument as typed) from `cwd`, or `None` when `cwd`, the
341/// target or the repo they lie in routes off or elsewhere. `base` is the root store (`BORG_HOME`).
342/// Paths are routed resolved (absolute, symlinks followed), as git sees them.
343///
344/// # Errors
345/// A bad line range, a missing file or commit, a file of another collective, or git failing.
346pub fn explain(cfg: Option<&Config>, base: &Path, cwd: &str, target: &str, full: bool) -> Result<Option<Report>, String> {
347    let Some(p) = place(cfg, base, cwd, target)? else { return Ok(None) };
348    let (mut shas, uncommitted) = commits_of(&p)?;
349    // Newest first by author time (from the blame headers), and the cap before any per-commit work.
350    shas.sort_by_key(|(_, ts)| std::cmp::Reverse(*ts));
351    let more = if full { 0 } else { shas.len().saturating_sub(MAX_COMMITS) };
352    shas.truncate(if full { usize::MAX } else { MAX_COMMITS });
353    let shas: Vec<String> = shas.into_iter().map(|(s, _)| s).collect();
354    let mut commits = if shas.is_empty() { Vec::new() } else { describe(&p, &shas)? };
355    if !full {
356        commits.iter_mut().for_each(compact);
357    }
358    Ok(Some(Report { target: target.into(), collective: p.name, commits, more, uncommitted }))
359}
360
361/// The reasoning lines of a commit.
362fn render_reasoning(s: &mut String, r: Option<&Reasoning>) {
363    let Some(x) = r else {
364        s.push_str("  (no borg reasoning recorded)\n");
365        return;
366    };
367    let from = if x.source == "note" { String::new() } else { " (from the collective's store; `borg notes fetch` writes it as a note)".into() };
368    let _ = writeln!(s, "  task: {}{from}", x.task.trim());
369    if !x.proposal.trim().is_empty() {
370        let _ = writeln!(s, "  proposal: {}", x.proposal.trim());
371    }
372    if !x.episodes.is_empty() {
373        let _ = writeln!(s, "  episodes: {}", x.episodes.join(", "));
374    }
375}
376
377/// The report as text.
378#[must_use]
379pub fn render(r: &Report) -> String {
380    let mut s = String::new();
381    for w in &r.commits {
382        let _ = writeln!(s, "{} {} {} {}", w.short, w.date, w.author, w.subject);
383        render_reasoning(&mut s, w.reasoning.as_ref());
384        s.push_str(&linked::render(&w.fixed_in, &w.fixes));
385        for l in &w.lessons {
386            let _ = writeln!(s, "  lesson {}: {} -> {}", l.id, l.question.trim(), l.consequence.trim());
387        }
388    }
389    if r.more > 0 {
390        let _ = writeln!(s, "... {} older commit(s) not shown (--full)", r.more);
391    }
392    if r.uncommitted > 0 {
393        let _ = writeln!(s, "{} line(s) not committed yet", r.uncommitted);
394    }
395    if r.commits.is_empty() {
396        let _ = writeln!(s, "no commits behind {}", r.target);
397    } else if r.commits.iter().all(|w| w.reasoning.is_none()) {
398        let _ = writeln!(s, "no borg reasoning found for {} (in collective {}; `borg notes fetch` asks the mesh)", r.target, r.collective);
399    }
400    s
401}
402
403/// `borg why <target> [--full] [--json] [--dir DIR]`.
404pub fn main(args: &[&str]) -> i32 {
405    let (mut full, mut json, mut dir, mut target) = (false, false, None, None);
406    let mut it = args.iter();
407    while let Some(a) = it.next() {
408        match *a {
409            "--full" => full = true,
410            "--json" => json = true,
411            "--dir" => match it.next() {
412                Some(d) => dir = Some(d.to_string()),
413                None => return usage(),
414            },
415            t if target.is_none() && !t.starts_with("--") => target = Some(t.to_string()),
416            _ => return usage(),
417        }
418    }
419    let Some(target) = target else { return usage() };
420    let cwd = dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string());
421    // An invalid config routes everything off: nothing is shown.
422    let Ok(cfg) = collective::load() else { return 0 };
423    match explain(cfg.as_ref(), &learn::home(), &cwd, &target, full) {
424        Ok(None) => 0,
425        Ok(Some(r)) => {
426            if json {
427                println!("{}", serde_json::to_string(&r).unwrap());
428            } else {
429                print!("{}", render(&r));
430            }
431            0
432        }
433        Err(e) => {
434            eprintln!("borg why: {e}");
435            1
436        }
437    }
438}
439
440fn usage() -> i32 {
441    eprintln!("{USAGE}");
442    2
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::notes;
449    use std::process::Command;
450
451    fn sh(dir: &Path, args: &[&str]) -> String {
452        let o = Command::new("git").arg("-C").arg(dir).args(args).env("GIT_CONFIG_GLOBAL", "/dev/null").env("GIT_CONFIG_NOSYSTEM", "1").output().unwrap();
453        assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
454        String::from_utf8_lossy(&o.stdout).trim().to_string()
455    }
456
457    /// git as `sh`, as `who`, at a fixed time.
458    fn commit_as(dir: &Path, who: &str, secs: i64, msg: &str) -> String {
459        let date = format!("@{secs} +0000");
460        let o = Command::new("git")
461            .arg("-C")
462            .arg(dir)
463            .args(["-c", &format!("user.name={who}"), "-c", &format!("user.email={who}@example.com"), "commit", "-q", "-am", msg])
464            .env("GIT_CONFIG_GLOBAL", "/dev/null")
465            .env("GIT_CONFIG_NOSYSTEM", "1")
466            .env("GIT_COMMITTER_DATE", &date)
467            .env("GIT_AUTHOR_DATE", &date)
468            .output()
469            .unwrap();
470        assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
471        sh(dir, &["rev-parse", "HEAD"])
472    }
473
474    struct Fx {
475        base: PathBuf,
476        work: PathBuf,
477        repo: PathBuf,
478        store: PathBuf,
479        cfg: Config,
480        one: String,
481        two: String,
482    }
483
484    /// `<base>/work/app` (collective "corp", team) with `src/lib.rs`: lines 1-3 by Ann (commit
485    /// `one`), line 2 rewritten by Bob (commit `two`). Also `<base>/private` (local) and
486    /// `<base>/work/secret` (off).
487    fn fixture(tag: &str) -> Fx {
488        let base = std::env::temp_dir().join(format!("borg-why-{tag}-{}", std::process::id()));
489        let _ = std::fs::remove_dir_all(&base);
490        std::fs::create_dir_all(&base).unwrap();
491        // Resolved, as `explain` routes it (the temp dir may be a symlink).
492        let base = std::fs::canonicalize(&base).unwrap();
493        let (work, store_base) = (base.join("work"), base.join("store"));
494        let repo = work.join("app");
495        std::fs::create_dir_all(repo.join("src")).unwrap();
496        sh(&repo, &["init", "-q"]);
497        std::fs::write(repo.join("src/lib.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
498        sh(&repo, &["add", "."]);
499        let one = commit_as(&repo, "Ann", 1_700_000_000, "add a b c");
500        std::fs::write(repo.join("src/lib.rs"), "fn a() {}\nfn b() { lock(); }\nfn c() {}\n").unwrap();
501        let two = commit_as(&repo, "Bob", 1_700_100_000, "take the lock in b");
502        let cfg: Config = serde_json::from_str(&format!(
503            r#"{{"default": "off", "off": ["{0}/work/secret"], "collectives": [
504                {{"name": "corp", "paths": ["{0}/work"], "share": "team"}},
505                {{"name": "private", "paths": ["{0}/private"], "share": "local"}} ]}}"#,
506            base.display()
507        ))
508        .unwrap();
509        let store = collective::store(&store_base, "corp");
510        Fx { base, work, repo, store, cfg, one, two }
511    }
512
513    impl Fx {
514        fn why(&self, cwd: &Path, target: &str, full: bool) -> Result<Option<Report>, String> {
515            explain(Some(&self.cfg), &self.base.join("store"), &cwd.to_string_lossy(), target, full)
516        }
517        fn note(&self, repo: &Path, commit: &str, collective: &str, episodes: &[&str]) {
518            let e = Excerpt {
519                kind: "reasoning".into(),
520                commit: commit.into(),
521                repo: "app".into(),
522                session: "s-1".into(),
523                task: "make b safe under concurrency\nsecond line".into(),
524                proposal: "Take the lock first; the token is ghp_abcdefghijklmnopqrstuvwxyz0123456789.".into(),
525                episodes: episodes.iter().map(|s| s.to_string()).collect(),
526                ms: 1,
527                collective: collective.into(),
528                ..Excerpt::default()
529            };
530            notes::write_note(repo, &e).unwrap();
531        }
532    }
533
534    impl Drop for Fx {
535        fn drop(&mut self) {
536            let _ = std::fs::remove_dir_all(&self.base);
537        }
538    }
539
540    #[test]
541    fn parses_line_ranges() {
542        assert_eq!(parse_span("src/a.rs").unwrap(), ("src/a.rs".into(), None));
543        assert_eq!(parse_span("src/a.rs:12").unwrap(), ("src/a.rs".into(), Some((12, 12))));
544        assert_eq!(parse_span("src/a.rs:12-20").unwrap(), ("src/a.rs".into(), Some((12, 20))));
545        assert_eq!(parse_span("HEAD~1").unwrap(), ("HEAD~1".into(), None));
546        assert_eq!(parse_span("HEAD:src/a.rs").unwrap(), ("HEAD:src/a.rs".into(), None), "not a range");
547        assert!(parse_span("a.rs:0").is_err());
548        assert!(parse_span("a.rs:20-10").is_err());
549        assert!(parse_span("a.rs:1-").is_err());
550        assert!(parse_span("a.rs:1-2-3").is_err());
551        assert!(parse_span("a.rs:99999999999").is_err());
552    }
553
554    #[test]
555    fn blame_of_a_span_spanning_two_commits() {
556        let fx = fixture("span");
557        fx.note(&fx.repo, &fx.two, "corp", &[]);
558        let r = fx.why(&fx.repo, "src/lib.rs:1-2", false).unwrap().unwrap();
559        let shas: Vec<&str> = r.commits.iter().map(|w| w.sha.as_str()).collect();
560        assert_eq!(shas, [fx.two.as_str(), fx.one.as_str()], "both commits, newest first");
561        assert_eq!((r.commits[0].author.as_str(), r.commits[0].subject.as_str()), ("Bob", "take the lock in b"));
562        let x = r.commits[0].reasoning.as_ref().unwrap();
563        assert_eq!((x.source.as_str(), x.task.as_str()), ("note", "make b safe under concurrency"), "compact: the task's first line");
564        assert!(!x.proposal.contains("ghp_abc") && x.proposal.contains("[REDACTED]"), "redacted again at display: {}", x.proposal);
565        assert!(r.commits[1].reasoning.is_none());
566        let text = render(&r);
567        assert!(text.contains("take the lock in b") && text.contains("add a b c") && text.contains("(no borg reasoning recorded)"), "{text}");
568        // One line only: one commit.
569        let r = fx.why(&fx.repo, "src/lib.rs:3", false).unwrap().unwrap();
570        assert_eq!(r.commits.len(), 1);
571        assert_eq!(r.commits[0].sha, fx.one);
572        // Uncommitted lines are counted, not blamed on a commit.
573        std::fs::write(fx.repo.join("src/lib.rs"), "fn a() {}\nfn b() { lock(); }\nfn c() {}\nfn d() {}\n").unwrap();
574        let r = fx.why(&fx.repo, "src/lib.rs:3-4", false).unwrap().unwrap();
575        assert_eq!((r.commits.len(), r.uncommitted), (1, 1));
576        assert!(fx.why(&fx.repo, "src/lib.rs:9-12", false).is_err(), "past the end of the file");
577    }
578
579    #[test]
580    fn commit_form_and_nothing_found() {
581        let fx = fixture("commit");
582        let r = fx.why(&fx.repo, &fx.one[..10], false).unwrap().unwrap();
583        assert_eq!(r.commits.len(), 1);
584        assert_eq!(r.commits[0].sha, fx.one);
585        assert!(render(&r).contains("no borg reasoning found for"), "{}", render(&r));
586        let r = fx.why(&fx.repo, "HEAD", true).unwrap().unwrap();
587        assert_eq!(r.commits[0].sha, fx.two);
588        let err = fx.why(&fx.repo, "no-such-thing", false).unwrap_err();
589        assert!(err.contains("neither a file nor a commit"), "{err}");
590        assert!(fx.why(&fx.repo, "missing.rs:3", false).unwrap_err().contains("no such file"));
591    }
592
593    #[test]
594    fn falls_back_to_replicated_lines_and_links_lessons_by_episode() {
595        let fx = fixture("replica");
596        std::fs::create_dir_all(&fx.store).unwrap();
597        let line = |commit: &str, collective: &str, task: &str| {
598            serde_json::json!({"kind": "reasoning", "commit": commit, "repo": "app", "session": "s-9", "task": task,
599                "proposal": "lock before the flag; password=hunter22", "episodes": ["s-9-100"], "ms": 5, "collective": collective})
600            .to_string()
601        };
602        let lesson = |id: &str, ep: &str, q: &str| {
603            serde_json::json!({"id": id, "origin_ms": 1, "repo": "app", "question": q, "situations": [], "consequence": "a race corrupts sessions",
604                "support": 1, "episodes": [ep], "history": [], "version": 1})
605            .to_string()
606        };
607        std::fs::write(
608            fx.store.join("reasoning.jsonl"),
609            [line(&fx.two, "corp", "replicated why"), line(&fx.one, "other", "another collective's")].join("\n") + "\n",
610        )
611        .unwrap();
612        std::fs::write(
613            fx.store.join("lessons.jsonl"),
614            [lesson("L1", "s-9-100", "Is the lock taken before the flag is read?"), lesson("L2", "s-0-1", "unrelated")].join("\n") + "\n",
615        )
616        .unwrap();
617        let r = fx.why(&fx.repo, "src/lib.rs", false).unwrap().unwrap();
618        let x = r.commits[0].reasoning.as_ref().unwrap();
619        assert_eq!((x.source.as_str(), x.task.as_str()), ("store", "replicated why"));
620        assert!(!x.proposal.contains("hunter22"));
621        assert_eq!(r.commits[0].lessons.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(), ["L1"], "linked by episode id");
622        assert!(r.commits[1].reasoning.is_none(), "an excerpt tagged with another collective is not shown");
623        let text = render(&r);
624        assert!(text.contains("lesson L1: Is the lock taken before the flag is read? -> a race corrupts sessions"), "{text}");
625        assert!(text.contains("borg notes fetch"), "{text}");
626        // A local note wins over the store.
627        fx.note(&fx.repo, &fx.two, "corp", &["s-9-100"]);
628        let r = fx.why(&fx.repo, &fx.two, false).unwrap().unwrap();
629        assert_eq!(r.commits[0].reasoning.as_ref().unwrap().source, "note");
630        assert_eq!(r.commits[0].lessons.len(), 1);
631        let json = serde_json::to_value(&r).unwrap();
632        assert_eq!(json["commits"][0]["lessons"][0]["id"], "L1");
633        assert!(json["commits"][0].get("ts").is_none());
634    }
635
636    #[test]
637    fn private_notes_stay_in_their_collective_and_off_prints_nothing() {
638        let fx = fixture("private");
639        // The same repo history moved under the private path: its note is tagged "private".
640        let private = fx.base.join("private/app");
641        std::fs::create_dir_all(private.parent().unwrap()).unwrap();
642        sh(&fx.base, &["clone", "-q", &fx.repo.to_string_lossy(), &private.to_string_lossy()]);
643        fx.note(&private, &fx.two, "private", &[]);
644        let r = fx.why(&private, "src/lib.rs:2", false).unwrap().unwrap();
645        assert_eq!(r.collective, "private");
646        assert!(r.commits[0].reasoning.is_some(), "shown where the cwd routes to the same private collective");
647        // A private note that ends up in a team repo (copied refs) is not shown there.
648        fx.note(&fx.repo, &fx.two, "private", &[]);
649        let r = fx.why(&fx.repo, "src/lib.rs:2", false).unwrap().unwrap();
650        assert!(r.commits[0].reasoning.is_none(), "a private note never shows in another collective");
651        // Off: nothing at all, before git is asked (also for a target inside an off path).
652        let secret = fx.work.join("secret");
653        std::fs::create_dir_all(&secret).unwrap();
654        std::fs::write(secret.join("x.rs"), "x").unwrap();
655        assert_eq!(fx.why(&secret, "x.rs", false).unwrap(), None);
656        assert_eq!(fx.why(&fx.repo, "../secret/x.rs", false).unwrap(), None, "a target under an off path");
657        assert_eq!(fx.why(&fx.base.join("elsewhere"), "HEAD", false).unwrap(), None, "default off");
658        // A file in another collective than the cwd is refused.
659        assert!(fx.why(&fx.repo, "../../private/app/src/lib.rs", false).unwrap_err().contains("another collective"));
660    }
661
662    #[test]
663    fn routes_resolved_paths_and_the_repo_itself() {
664        let fx = fixture("resolve");
665        fx.note(&fx.repo, &fx.two, "corp", &[]);
666        // An off repo with a note, reached through symlinks from inside the team tree.
667        let secret = fx.work.join("secret");
668        std::fs::create_dir_all(&secret).unwrap();
669        sh(&fx.base, &["clone", "-q", &fx.repo.to_string_lossy(), &secret.to_string_lossy()]);
670        sh(&fx.repo, &["push", "-q", &secret.to_string_lossy(), "refs/notes/borg:refs/notes/borg"]);
671        std::os::unix::fs::symlink(&secret, fx.repo.join("link")).unwrap();
672        std::os::unix::fs::symlink(&secret, fx.work.join("alias")).unwrap();
673        assert_eq!(fx.why(&fx.repo, "link/src/lib.rs", false).unwrap(), None, "a symlinked file in an off repo");
674        assert_eq!(fx.why(&fx.repo.join("link"), "src/lib.rs:2", false).unwrap(), None, "a symlinked cwd");
675        assert_eq!(fx.why(&fx.work.join("alias"), "HEAD", false).unwrap(), None, "a symlinked cwd, commit form");
676        assert_eq!(fx.why(&fx.repo.join("src/../link"), "HEAD", false).unwrap(), None);
677        // The cwd routes to a collective, but the repo git reads (its toplevel) routes elsewhere.
678        let mut cfg = fx.cfg.clone();
679        cfg.collectives[1].paths.push(fx.repo.join("src").to_string_lossy().into_owned());
680        let r = explain(Some(&cfg), &fx.base.join("store"), &fx.repo.join("src").to_string_lossy(), "lib.rs:2", false).unwrap();
681        assert_eq!(r, None, "a private subdirectory of a team repo");
682        let r = explain(Some(&cfg), &fx.base.join("store"), &fx.repo.join("src").to_string_lossy(), "HEAD", false).unwrap();
683        assert_eq!(r, None);
684        // The real paths still work.
685        assert!(fx.why(&fx.repo, "src/lib.rs:2", false).unwrap().unwrap().commits[0].reasoning.is_some());
686    }
687
688    #[test]
689    fn blame_headers_give_the_order_and_the_cap_comes_first() {
690        let porcelain = "\
691aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1
692author A
693author-time 100
694summary old
695\tx
696bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2 2 1
697author B
698author-time 300
699summary new
700\ty
701aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 3 3 1
702\tz
7030000000000000000000000000000000000000000 4 4 2
704author Not Committed Yet
705author-time 999
706\tw
707";
708        let (c, u) = blamed(porcelain);
709        assert_eq!(c, [("a".repeat(40), 100), ("b".repeat(40), 300)]);
710        assert_eq!(u, 2);
711
712        // Twelve commits, each owning one line: compact keeps the 8 newest, the store is read once
713        // for those, and an excerpt of a cut commit costs nothing.
714        let fx = fixture("cap");
715        let file = fx.repo.join("src/lib.rs");
716        let mut text = std::fs::read_to_string(&file).unwrap();
717        let mut made = Vec::new();
718        for i in 0..12 {
719            text.push_str(&format!("fn f{i}() {{}}\n"));
720            std::fs::write(&file, &text).unwrap();
721            made.push(commit_as(&fx.repo, "Cy", 1_800_000_000 + i, &format!("f{i}")));
722        }
723        std::fs::create_dir_all(&fx.store).unwrap();
724        let line = |c: &str, ms: i64, task: &str| {
725            serde_json::json!({"kind": "reasoning", "commit": c, "repo": "app", "session": "s", "task": task, "proposal": "", "episodes": [], "ms": ms})
726                .to_string()
727        };
728        let lines = [line(&made[11], 1, "older"), line(&made[11], 2, "newest"), line(&made[0], 1, "cut")];
729        std::fs::write(fx.store.join("reasoning.jsonl"), lines.join("\n") + "\n").unwrap();
730        fx.note(&fx.repo, &made[10], "corp", &[]);
731        let r = fx.why(&fx.repo, "src/lib.rs", false).unwrap().unwrap();
732        let subjects: Vec<&str> = r.commits.iter().map(|w| w.subject.as_str()).collect();
733        assert_eq!(subjects, ["f11", "f10", "f9", "f8", "f7", "f6", "f5", "f4"]);
734        assert_eq!(r.more, 6, "f3..f0, two and one");
735        assert_eq!(r.commits[0].reasoning.as_ref().map(|x| (x.source.as_str(), x.task.as_str())), Some(("store", "newest")));
736        assert_eq!(r.commits[1].reasoning.as_ref().map(|x| x.source.as_str()), Some("note"));
737        assert_eq!((r.commits[0].author.as_str(), r.commits[0].short.len() >= 7), ("Cy", true));
738        let full = fx.why(&fx.repo, "src/lib.rs", true).unwrap().unwrap();
739        assert_eq!((full.commits.len(), full.more), (14, 0));
740        assert_eq!(full.commits[11].reasoning.as_ref().map(|x| x.task.as_str()), Some("cut"));
741        assert_eq!(full.commits[12].sha, fx.two);
742    }
743
744    #[test]
745    fn a_dir_without_git_is_a_clear_error() {
746        let fx = fixture("nogit");
747        let plain = fx.work.join("plain");
748        std::fs::create_dir_all(&plain).unwrap();
749        std::fs::write(plain.join("a.txt"), "x\n").unwrap();
750        // GIT_CEILING_DIRECTORIES is not needed: /tmp/... is not inside a repo.
751        let err = fx.why(&plain, "a.txt:1", false).unwrap_err();
752        assert!(err.contains("is not in a git repo"), "{err}");
753        let err = fx.why(&plain, "HEAD", false).unwrap_err();
754        assert!(err.contains("is not in a git repo"), "{err}");
755    }
756
757    #[test]
758    fn compact_caps_and_full() {
759        let fx = fixture("caps");
760        let long = "x".repeat(2000);
761        let e = Excerpt {
762            kind: "reasoning".into(),
763            commit: fx.two.clone(),
764            repo: "app".into(),
765            session: "s".into(),
766            task: "t".into(),
767            proposal: format!("start {long} end"),
768            episodes: vec![],
769            ms: 1,
770            ..Excerpt::default()
771        };
772        notes::write_note(&fx.repo, &e).unwrap();
773        let compact = fx.why(&fx.repo, "HEAD", false).unwrap().unwrap();
774        let p = &compact.commits[0].reasoning.as_ref().unwrap().proposal;
775        assert!(p.chars().count() <= PROPOSAL_CAP + 1 && p.ends_with("end"), "the proposal's conclusion is kept");
776        let full = fx.why(&fx.repo, "HEAD", true).unwrap().unwrap();
777        assert!(full.commits[0].reasoning.as_ref().unwrap().proposal.starts_with("start"), "an untagged (older) note shows in the repo's collective");
778    }
779}