Skip to main content

borg/notes/
reasoning.rs

1//! The reasoning recorded for commits, as `borg why` and regress.rs look it up: a commit's borg
2//! note (notes.rs) or else the excerpt the collective's `reasoning.jsonl` holds, each only where it
3//! may be shown (`visible`). One `notes list` plus one `cat-file --batch`, and one read of the store.
4
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7use std::process::{Command, Stdio};
8
9use crate::notes::{self, Excerpt};
10
11fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
12    git_in(dir, args, None).map(|o| String::from_utf8_lossy(&o).into_owned())
13}
14
15/// git in `dir`, with `input` on stdin (written from a thread so a large output cannot block it).
16///
17/// # Errors
18/// When git cannot run or fails (its stderr).
19pub fn git_in(dir: &Path, args: &[&str], input: Option<String>) -> Result<Vec<u8>, String> {
20    let mut child = Command::new("git")
21        .arg("-C")
22        .arg(dir)
23        .args(args)
24        .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() })
25        .stdout(Stdio::piped())
26        .stderr(Stdio::piped())
27        .spawn()
28        .map_err(|e| format!("cannot run git: {e}"))?;
29    let writer = match (input, child.stdin.take()) {
30        (Some(text), Some(mut stdin)) => Some(std::thread::spawn(move || {
31            use std::io::Write;
32            let _ = stdin.write_all(text.as_bytes());
33        })),
34        _ => None,
35    };
36    let o = child.wait_with_output().map_err(|e| format!("cannot run git: {e}"))?;
37    if let Some(w) = writer {
38        let _ = w.join();
39    }
40    if !o.status.success() {
41        return Err(String::from_utf8_lossy(&o.stderr).trim().to_string());
42    }
43    Ok(o.stdout)
44}
45
46/// Whether an excerpt tagged `tag` may be shown in collective `here` (module doc).
47#[must_use]
48pub fn visible(tag: &str, here: &str) -> bool {
49    tag.is_empty() || tag == here
50}
51
52/// The borg notes of `shas` that are visible in `here`, by commit: one `notes list` for the note
53/// objects and one `cat-file --batch` for the notes of `shas` that have one.
54pub fn notes_of(repo: &Path, here: &str, shas: &[String]) -> HashMap<String, Excerpt> {
55    let list = git(repo, &["notes", &format!("--ref={}", notes::NOTES_REF), "list"]).unwrap_or_default();
56    let wanted: HashSet<&str> = shas.iter().map(String::as_str).collect();
57    let blobs: Vec<(&str, &str)> = list.lines().filter_map(|l| l.split_once(' ')).filter(|(_, commit)| wanted.contains(commit)).collect();
58    if blobs.is_empty() {
59        return HashMap::new();
60    }
61    let input: String = blobs.iter().flat_map(|(blob, _)| [*blob, "\n"]).collect();
62    let Ok(out) = git_in(repo, &["cat-file", "--batch"], Some(input)) else { return HashMap::new() };
63    let mut found = HashMap::new();
64    let mut rest = &out[..];
65    for (_, commit) in blobs {
66        // "<oid> blob <size>\n<content>\n", or "<oid> missing\n".
67        let Some(nl) = rest.iter().position(|&b| b == b'\n') else { break };
68        let head = String::from_utf8_lossy(&rest[..nl]).into_owned();
69        rest = &rest[nl + 1..];
70        let Some(size) = head.split(' ').nth(2).and_then(|n| n.parse::<usize>().ok()) else { continue };
71        if rest.len() < size {
72            break;
73        }
74        let text = String::from_utf8_lossy(&rest[..size]).into_owned();
75        rest = rest.get(size + 1..).unwrap_or_default();
76        if let Some(e) = Excerpt::from_note(commit, &text).filter(|e| visible(&e.collective, here)) {
77            found.insert(commit.to_string(), e);
78        }
79    }
80    found
81}
82
83/// The newest excerpt visible in `here` for each of `shas`, from the store's reasoning.jsonl,
84/// read once.
85pub fn stored_of<S: std::hash::BuildHasher>(store: &Path, here: &str, shas: &HashSet<&str, S>) -> HashMap<String, Excerpt> {
86    let mut found: HashMap<String, Excerpt> = HashMap::new();
87    if shas.is_empty() {
88        return found;
89    }
90    let text = std::fs::read_to_string(store.join("reasoning.jsonl")).unwrap_or_default();
91    for e in text.lines().filter_map(|l| serde_json::from_str::<Excerpt>(l).ok()) {
92        if e.kind != "reasoning" || !shas.contains(e.commit.as_str()) || !visible(&e.collective, here) {
93            continue;
94        }
95        if found.get(&e.commit).is_none_or(|old| e.ms > old.ms) {
96            found.insert(e.commit.clone(), e);
97        }
98    }
99    found
100}
101
102/// The reasoning visible in `here` for each of `shas`: its note, else the newest store excerpt.
103pub fn reasoning_for(repo: &Path, store: &Path, here: &str, shas: &[String]) -> HashMap<String, Excerpt> {
104    let mut found = notes_of(repo, here, shas);
105    let missing: HashSet<&str> = shas.iter().map(String::as_str).filter(|s| !found.contains_key(*s)).collect();
106    let stored = stored_of(store, here, &missing);
107    found.extend(stored);
108    found
109}