1use 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;
52const MAX_LESSONS: usize = 3;
54const TASK_CAP: usize = 240;
56const PROPOSAL_CAP: usize = 400;
58const LESSON_CAP: usize = 240;
60
61const USAGE: &str = "usage: borg why <path>[:<line>[-<line>]] | <commit> [--full] [--json] [--dir DIR]";
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Target {
66 Span {
68 path: String,
69 lines: Option<(u32, u32)>,
70 },
71 Commit(String),
72}
73
74pub fn parse_span(arg: &str) -> Result<(String, Option<(u32, u32)>), String> {
76 let Some((path, range)) = arg.rsplit_once(':') else { return Ok((arg.to_string(), None)) };
77 if path.is_empty() || range.is_empty() || !range.chars().all(|c| c.is_ascii_digit() || c == '-') {
78 return Ok((arg.to_string(), None));
80 }
81 let num = |s: &str| s.parse::<u32>().map_err(|_| format!("bad line range {range:?} in {arg:?}"));
82 let (a, b) = match range.split_once('-') {
83 Some((a, b)) => (num(a)?, num(b)?),
84 None => (num(range)?, num(range)?),
85 };
86 if a == 0 || b < a {
87 return Err(format!("bad line range {range:?} in {arg:?}: lines start at 1 and the end is not before the start"));
88 }
89 Ok((path.to_string(), Some((a, b))))
90}
91
92fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
93 git_in(dir, args, None).map(|o| String::from_utf8_lossy(&o).into_owned())
94}
95
96fn resolve(p: &Path) -> PathBuf {
99 std::fs::canonicalize(p).or_else(|_| std::path::absolute(p)).unwrap_or_else(|_| p.to_path_buf())
100}
101
102fn is_sha(s: &str) -> bool {
103 (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
104}
105
106pub fn blamed(porcelain: &str) -> (Vec<(String, i64)>, usize) {
109 let (mut out, mut uncommitted) = (Vec::<(String, i64)>::new(), 0);
110 let mut seen = HashSet::<String>::new();
111 let mut current: Option<usize> = None;
113 for l in porcelain.lines().filter(|l| !l.starts_with('\t')) {
114 if let Some(t) = l.strip_prefix("author-time ") {
115 if let Some(i) = current {
116 out[i].1 = t.trim().parse().unwrap_or(0);
117 }
118 continue;
119 }
120 let mut f = l.split(' ');
121 let (Some(sha), Some(_), Some(_)) = (f.next(), f.next(), f.next()) else { continue };
122 if !is_sha(sha) {
123 continue;
124 }
125 if sha.bytes().all(|b| b == b'0') {
126 current = None;
127 uncommitted += f.next().and_then(|n| n.parse::<usize>().ok()).unwrap_or(0);
129 } else if !seen.insert(sha.to_string()) {
130 current = None;
131 } else {
132 out.push((sha.to_string(), 0));
133 current = Some(out.len() - 1);
134 }
135 }
136 (out, uncommitted)
137}
138
139#[derive(Debug, Clone, Serialize, PartialEq)]
140pub struct LessonLine {
141 pub id: String,
142 pub question: String,
143 pub consequence: String,
144}
145
146#[derive(Debug, Clone, Serialize, PartialEq)]
147pub struct Reasoning {
148 pub source: String,
150 pub session: String,
151 pub task: String,
152 pub proposal: String,
153 pub episodes: Vec<String>,
154}
155
156#[derive(Debug, Clone, Serialize, PartialEq)]
157pub struct Why {
158 pub sha: String,
159 pub short: String,
160 pub author: String,
161 pub date: String,
162 pub subject: String,
163 pub reasoning: Option<Reasoning>,
164 pub lessons: Vec<LessonLine>,
165 #[serde(skip_serializing_if = "Vec::is_empty")]
167 pub fixed_in: Vec<LinkLine>,
168 #[serde(skip_serializing_if = "Vec::is_empty")]
170 pub fixes: Vec<LinkLine>,
171}
172
173#[derive(Debug, Clone, Serialize, PartialEq)]
174pub struct Report {
175 pub target: String,
176 pub collective: String,
177 pub commits: Vec<Why>,
178 pub more: usize,
180 pub uncommitted: usize,
182}
183
184fn first_chars(s: &str, n: usize) -> String {
185 if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n).collect::<String>()) }
186}
187
188fn last_chars(s: &str, n: usize) -> String {
189 let len = s.chars().count();
190 if len <= n { s.to_string() } else { format!("…{}", s.chars().skip(len - n).collect::<String>()) }
191}
192
193fn one_line(s: &str) -> String {
194 s.split_whitespace().collect::<Vec<_>>().join(" ")
195}
196
197fn shown(e: Excerpt, source: &str) -> Reasoning {
199 Reasoning {
200 source: source.into(),
201 session: e.session,
202 task: redact(&e.task),
203 proposal: redact(&e.proposal),
204 episodes: e.episodes.iter().map(|x| redact(x)).collect(),
205 }
206}
207
208fn lessons_of(lessons: &[learn::Lesson], episodes: &[String]) -> Vec<LessonLine> {
210 lessons
211 .iter()
212 .filter(|l| l.episodes.iter().any(|e| episodes.contains(e)))
213 .map(|l| LessonLine { id: l.id.clone(), question: redact(&l.question), consequence: redact(&l.consequence) })
214 .collect()
215}
216
217struct Place {
219 name: String,
220 home: PathBuf,
221 repo: PathBuf,
222 dir: PathBuf,
224 file: PathBuf,
225 path: String,
226 target: Target,
227}
228
229fn target_of(cfg: Option<&Config>, base: &Path, cwd: &Path, route: &Route, target: &str) -> Result<Option<(Target, PathBuf, String)>, String> {
231 let (path, lines) = parse_span(target)?;
232 let file = resolve(&cwd.join(&path));
233 if file.is_file() {
234 return match collective::route(cfg, &file.to_string_lossy(), base) {
236 Route::Off => Ok(None),
237 r if r != *route => Err(format!("{path} belongs to another collective than {}; run borg why from its repo", cwd.display())),
238 _ => Ok(Some((Target::Span { path: path.clone(), lines }, file, path))),
239 };
240 }
241 if lines.is_some() {
242 return Err(format!("{path}: no such file"));
243 }
244 Ok(Some((Target::Commit(target.to_string()), file, path)))
245}
246
247fn place(cfg: Option<&Config>, base: &Path, cwd: &str, target: &str) -> Result<Option<Place>, String> {
250 if cwd.trim().is_empty() {
251 return Ok(None);
252 }
253 let cwd = resolve(Path::new(cwd));
254 let route = collective::route(cfg, &cwd.to_string_lossy(), base);
255 let Route::Collective { name, home, .. } = &route else { return Ok(None) };
256 let Some((target, file, path)) = target_of(cfg, base, &cwd, &route, target)? else { return Ok(None) };
257 let dir = match &target {
258 Target::Span { .. } => file.parent().map_or_else(|| cwd.clone(), Path::to_path_buf),
259 Target::Commit(_) => cwd.clone(),
260 };
261 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()))?;
262 let repo = resolve(&repo);
265 if collective::route(cfg, &repo.to_string_lossy(), base) != route {
266 return Ok(None);
267 }
268 Ok(Some(Place { name: name.clone(), home: home.clone(), repo, dir, file, path, target }))
269}
270
271fn commits_of(p: &Place) -> Result<(Vec<(String, i64)>, usize), String> {
273 match &p.target {
274 Target::Span { lines, .. } => {
275 let name = p.file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
276 let mut args = vec!["blame".to_string(), "--porcelain".into()];
277 if let Some((a, b)) = lines {
278 args.push(format!("-L{a},{b}"));
279 }
280 args.extend(["--".into(), name]);
281 let out = git(&p.dir, &args.iter().map(String::as_str).collect::<Vec<_>>()).map_err(|e| format!("git blame {}: {e}", p.path))?;
282 Ok(blamed(&out))
283 }
284 Target::Commit(c) => {
285 let sha = git(&p.repo, &["rev-parse", "--verify", "--quiet", &format!("{c}^{{commit}}")])
286 .map_err(|_| format!("{c} is neither a file nor a commit in {}", p.repo.display()))?;
287 Ok((vec![(sha.trim().to_string(), 0)], 0))
288 }
289 }
290}
291
292fn headers(repo: &Path, shas: &[String]) -> Result<HashMap<String, [String; 4]>, String> {
294 let input: String = shas.iter().map(|s| format!("{s}\n")).collect();
295 let log = git_in(repo, &["log", "--no-walk=unsorted", "--stdin", "--date=short", "--format=%H%x1f%h%x1f%an%x1f%ad%x1f%s"], Some(input))
296 .map_err(|e| format!("git log: {e}"))?;
297 let mut info = HashMap::new();
298 for l in String::from_utf8_lossy(&log).lines() {
299 let f: Vec<&str> = l.splitn(5, '\x1f').collect();
300 if let [sha, short, author, date, subject] = f[..] {
301 info.insert(sha.to_string(), [short.into(), author.into(), date.into(), subject.into()]);
302 }
303 }
304 Ok(info)
305}
306
307fn describe(p: &Place, shas: &[String]) -> Result<Vec<Why>, String> {
310 let mut info = headers(&p.repo, shas)?;
311 let mut from_notes = notes_of(&p.repo, &p.name, shas);
312 let missing: HashSet<&str> = shas.iter().map(String::as_str).filter(|s| !from_notes.contains_key(*s)).collect();
313 let mut from_store = stored_of(&p.home, &p.name, &missing);
314 let absorbed = crate::absorb::state(&crate::absorb::path(&p.home), None);
316 let mut lessons = learn::lessons(&p.home.join("lessons.jsonl"));
317 lessons.retain(|l| !absorbed.contains_key(&l.id));
318 let stored_links = regress::links_of(&p.home, &p.name, &shas.iter().map(String::as_str).collect::<HashSet<_>>());
319 let mut commits = Vec::new();
320 for sha in shas {
321 let Some([short, author, date, subject]) = info.remove(sha) else { return Err(format!("git log {sha}: unexpected output")) };
322 let (excerpt, source) = match from_notes.remove(sha) {
323 Some(e) => (Some(e), "note"),
324 None => (from_store.remove(sha), "store"),
325 };
326 let (fixed_in, fixes) = linked::of(sha, excerpt.as_ref(), &stored_links);
327 let reasoning = excerpt.map(|e| shown(e, source));
328 let lessons = reasoning.as_ref().map(|r| lessons_of(&lessons, &r.episodes)).unwrap_or_default();
329 commits.push(Why { sha: sha.clone(), short, author, date, subject: redact(&subject), reasoning, lessons, fixed_in, fixes });
330 }
331 Ok(commits)
332}
333
334fn compact(w: &mut Why) {
336 w.lessons.truncate(MAX_LESSONS);
337 for l in &mut w.lessons {
338 l.question = first_chars(&one_line(&l.question), LESSON_CAP);
339 l.consequence = first_chars(&one_line(&l.consequence), LESSON_CAP);
340 }
341 if let Some(r) = &mut w.reasoning {
342 r.task = first_chars(r.task.lines().find(|l| !l.trim().is_empty()).unwrap_or_default().trim(), TASK_CAP);
343 r.proposal = last_chars(&one_line(&r.proposal), PROPOSAL_CAP);
344 }
345 linked::compact(&mut w.fixed_in);
346 linked::compact(&mut w.fixes);
347}
348
349pub fn explain(cfg: Option<&Config>, base: &Path, cwd: &str, target: &str, full: bool) -> Result<Option<Report>, String> {
356 let Some(p) = place(cfg, base, cwd, target)? else { return Ok(None) };
357 let (mut shas, uncommitted) = commits_of(&p)?;
358 shas.sort_by_key(|(_, ts)| std::cmp::Reverse(*ts));
360 let more = if full { 0 } else { shas.len().saturating_sub(MAX_COMMITS) };
361 shas.truncate(if full { usize::MAX } else { MAX_COMMITS });
362 let shas: Vec<String> = shas.into_iter().map(|(s, _)| s).collect();
363 let mut commits = if shas.is_empty() { Vec::new() } else { describe(&p, &shas)? };
364 if !full {
365 commits.iter_mut().for_each(compact);
366 }
367 Ok(Some(Report { target: target.into(), collective: p.name, commits, more, uncommitted }))
368}
369
370fn render_reasoning(s: &mut String, r: Option<&Reasoning>) {
372 let Some(x) = r else {
373 s.push_str(" (no borg reasoning recorded)\n");
374 return;
375 };
376 let from = if x.source == "note" { String::new() } else { " (from the collective's store; `borg notes fetch` writes it as a note)".into() };
377 let _ = writeln!(s, " task: {}{from}", x.task.trim());
378 if !x.proposal.trim().is_empty() {
379 let _ = writeln!(s, " proposal: {}", x.proposal.trim());
380 }
381 if !x.episodes.is_empty() {
382 let _ = writeln!(s, " episodes: {}", x.episodes.join(", "));
383 }
384}
385
386#[must_use]
388pub fn render(r: &Report) -> String {
389 let mut s = String::new();
390 for w in &r.commits {
391 let _ = writeln!(s, "{} {} {} {}", w.short, w.date, w.author, w.subject);
392 render_reasoning(&mut s, w.reasoning.as_ref());
393 s.push_str(&linked::render(&w.fixed_in, &w.fixes));
394 for l in &w.lessons {
395 let _ = writeln!(s, " lesson {}: {} -> {}", l.id, l.question.trim(), l.consequence.trim());
396 }
397 }
398 if r.more > 0 {
399 let _ = writeln!(s, "... {} older commit(s) not shown (--full)", r.more);
400 }
401 if r.uncommitted > 0 {
402 let _ = writeln!(s, "{} line(s) not committed yet", r.uncommitted);
403 }
404 if r.commits.is_empty() {
405 let _ = writeln!(s, "no commits behind {}", r.target);
406 } else if r.commits.iter().all(|w| w.reasoning.is_none()) {
407 let _ = writeln!(s, "no borg reasoning found for {} (in collective {}; `borg notes fetch` asks the mesh)", r.target, r.collective);
408 }
409 s
410}
411
412pub fn main(args: &[&str]) -> i32 {
414 let (mut full, mut json, mut dir, mut target) = (false, false, None, None);
415 let mut it = args.iter();
416 while let Some(a) = it.next() {
417 match *a {
418 "--full" => full = true,
419 "--json" => json = true,
420 "--dir" => match it.next() {
421 Some(d) => dir = Some(d.to_string()),
422 None => return usage(),
423 },
424 t if target.is_none() && !t.starts_with("--") => target = Some(t.to_string()),
425 _ => return usage(),
426 }
427 }
428 let Some(target) = target else { return usage() };
429 let cwd = dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string());
430 let Ok(cfg) = collective::load() else { return 0 };
432 match explain(cfg.as_ref(), &learn::home(), &cwd, &target, full) {
433 Ok(None) => 0,
434 Ok(Some(r)) => {
435 if json {
436 println!("{}", serde_json::to_string(&r).unwrap());
437 } else {
438 print!("{}", render(&r));
439 }
440 0
441 }
442 Err(e) => {
443 eprintln!("borg why: {e}");
444 1
445 }
446 }
447}
448
449fn usage() -> i32 {
450 eprintln!("{USAGE}");
451 2
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::notes;
458 use std::process::Command;
459
460 fn sh(dir: &Path, args: &[&str]) -> String {
461 let o = Command::new("git").arg("-C").arg(dir).args(args).env("GIT_CONFIG_GLOBAL", "/dev/null").env("GIT_CONFIG_NOSYSTEM", "1").output().unwrap();
462 assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
463 String::from_utf8_lossy(&o.stdout).trim().to_string()
464 }
465
466 fn commit_as(dir: &Path, who: &str, secs: i64, msg: &str) -> String {
468 let date = format!("@{secs} +0000");
469 let o = Command::new("git")
470 .arg("-C")
471 .arg(dir)
472 .args(["-c", &format!("user.name={who}"), "-c", &format!("user.email={who}@example.com"), "commit", "-q", "-am", msg])
473 .env("GIT_CONFIG_GLOBAL", "/dev/null")
474 .env("GIT_CONFIG_NOSYSTEM", "1")
475 .env("GIT_COMMITTER_DATE", &date)
476 .env("GIT_AUTHOR_DATE", &date)
477 .output()
478 .unwrap();
479 assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
480 sh(dir, &["rev-parse", "HEAD"])
481 }
482
483 struct Fx {
484 base: PathBuf,
485 work: PathBuf,
486 repo: PathBuf,
487 store: PathBuf,
488 cfg: Config,
489 one: String,
490 two: String,
491 }
492
493 fn fixture(tag: &str) -> Fx {
497 let base = std::env::temp_dir().join(format!("borg-why-{tag}-{}", std::process::id()));
498 let _ = std::fs::remove_dir_all(&base);
499 std::fs::create_dir_all(&base).unwrap();
500 let base = std::fs::canonicalize(&base).unwrap();
502 let (work, store_base) = (base.join("work"), base.join("store"));
503 let repo = work.join("app");
504 std::fs::create_dir_all(repo.join("src")).unwrap();
505 sh(&repo, &["init", "-q"]);
506 std::fs::write(repo.join("src/lib.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
507 sh(&repo, &["add", "."]);
508 let one = commit_as(&repo, "Ann", 1_700_000_000, "add a b c");
509 std::fs::write(repo.join("src/lib.rs"), "fn a() {}\nfn b() { lock(); }\nfn c() {}\n").unwrap();
510 let two = commit_as(&repo, "Bob", 1_700_100_000, "take the lock in b");
511 let cfg: Config = serde_json::from_str(&format!(
512 r#"{{"default": "off", "off": ["{0}/work/secret"], "collectives": [
513 {{"name": "corp", "paths": ["{0}/work"], "share": "team"}},
514 {{"name": "private", "paths": ["{0}/private"], "share": "local"}} ]}}"#,
515 base.display()
516 ))
517 .unwrap();
518 let store = collective::store(&store_base, "corp");
519 Fx { base, work, repo, store, cfg, one, two }
520 }
521
522 impl Fx {
523 fn why(&self, cwd: &Path, target: &str, full: bool) -> Result<Option<Report>, String> {
524 explain(Some(&self.cfg), &self.base.join("store"), &cwd.to_string_lossy(), target, full)
525 }
526 fn note(&self, repo: &Path, commit: &str, collective: &str, episodes: &[&str]) {
527 let e = Excerpt {
528 kind: "reasoning".into(),
529 commit: commit.into(),
530 repo: "app".into(),
531 session: "s-1".into(),
532 task: "make b safe under concurrency\nsecond line".into(),
533 proposal: "Take the lock first; the token is ghp_abcdefghijklmnopqrstuvwxyz0123456789.".into(),
534 episodes: episodes.iter().map(|s| s.to_string()).collect(),
535 ms: 1,
536 collective: collective.into(),
537 ..Excerpt::default()
538 };
539 notes::write_note(repo, &e).unwrap();
540 }
541 }
542
543 impl Drop for Fx {
544 fn drop(&mut self) {
545 let _ = std::fs::remove_dir_all(&self.base);
546 }
547 }
548
549 #[test]
550 fn parses_line_ranges() {
551 assert_eq!(parse_span("src/a.rs").unwrap(), ("src/a.rs".into(), None));
552 assert_eq!(parse_span("src/a.rs:12").unwrap(), ("src/a.rs".into(), Some((12, 12))));
553 assert_eq!(parse_span("src/a.rs:12-20").unwrap(), ("src/a.rs".into(), Some((12, 20))));
554 assert_eq!(parse_span("HEAD~1").unwrap(), ("HEAD~1".into(), None));
555 assert_eq!(parse_span("HEAD:src/a.rs").unwrap(), ("HEAD:src/a.rs".into(), None), "not a range");
556 assert!(parse_span("a.rs:0").is_err());
557 assert!(parse_span("a.rs:20-10").is_err());
558 assert!(parse_span("a.rs:1-").is_err());
559 assert!(parse_span("a.rs:1-2-3").is_err());
560 assert!(parse_span("a.rs:99999999999").is_err());
561 }
562
563 #[test]
564 fn blame_of_a_span_spanning_two_commits() {
565 let fx = fixture("span");
566 fx.note(&fx.repo, &fx.two, "corp", &[]);
567 let r = fx.why(&fx.repo, "src/lib.rs:1-2", false).unwrap().unwrap();
568 let shas: Vec<&str> = r.commits.iter().map(|w| w.sha.as_str()).collect();
569 assert_eq!(shas, [fx.two.as_str(), fx.one.as_str()], "both commits, newest first");
570 assert_eq!((r.commits[0].author.as_str(), r.commits[0].subject.as_str()), ("Bob", "take the lock in b"));
571 let x = r.commits[0].reasoning.as_ref().unwrap();
572 assert_eq!((x.source.as_str(), x.task.as_str()), ("note", "make b safe under concurrency"), "compact: the task's first line");
573 assert!(!x.proposal.contains("ghp_abc") && x.proposal.contains("[REDACTED]"), "redacted again at display: {}", x.proposal);
574 assert!(r.commits[1].reasoning.is_none());
575 let text = render(&r);
576 assert!(text.contains("take the lock in b") && text.contains("add a b c") && text.contains("(no borg reasoning recorded)"), "{text}");
577 let r = fx.why(&fx.repo, "src/lib.rs:3", false).unwrap().unwrap();
579 assert_eq!(r.commits.len(), 1);
580 assert_eq!(r.commits[0].sha, fx.one);
581 std::fs::write(fx.repo.join("src/lib.rs"), "fn a() {}\nfn b() { lock(); }\nfn c() {}\nfn d() {}\n").unwrap();
583 let r = fx.why(&fx.repo, "src/lib.rs:3-4", false).unwrap().unwrap();
584 assert_eq!((r.commits.len(), r.uncommitted), (1, 1));
585 assert!(fx.why(&fx.repo, "src/lib.rs:9-12", false).is_err(), "past the end of the file");
586 }
587
588 #[test]
589 fn commit_form_and_nothing_found() {
590 let fx = fixture("commit");
591 let r = fx.why(&fx.repo, &fx.one[..10], false).unwrap().unwrap();
592 assert_eq!(r.commits.len(), 1);
593 assert_eq!(r.commits[0].sha, fx.one);
594 assert!(render(&r).contains("no borg reasoning found for"), "{}", render(&r));
595 let r = fx.why(&fx.repo, "HEAD", true).unwrap().unwrap();
596 assert_eq!(r.commits[0].sha, fx.two);
597 let err = fx.why(&fx.repo, "no-such-thing", false).unwrap_err();
598 assert!(err.contains("neither a file nor a commit"), "{err}");
599 assert!(fx.why(&fx.repo, "missing.rs:3", false).unwrap_err().contains("no such file"));
600 }
601
602 #[test]
603 fn falls_back_to_replicated_lines_and_links_lessons_by_episode() {
604 let fx = fixture("replica");
605 std::fs::create_dir_all(&fx.store).unwrap();
606 let line = |commit: &str, collective: &str, task: &str| {
607 serde_json::json!({"kind": "reasoning", "commit": commit, "repo": "app", "session": "s-9", "task": task,
608 "proposal": "lock before the flag; password=hunter22", "episodes": ["s-9-100"], "ms": 5, "collective": collective})
609 .to_string()
610 };
611 let lesson = |id: &str, ep: &str, q: &str| {
612 serde_json::json!({"id": id, "origin_ms": 1, "repo": "app", "question": q, "situations": [], "consequence": "a race corrupts sessions",
613 "support": 1, "episodes": [ep], "history": [], "version": 1})
614 .to_string()
615 };
616 std::fs::write(
617 fx.store.join("reasoning.jsonl"),
618 [line(&fx.two, "corp", "replicated why"), line(&fx.one, "other", "another collective's")].join("\n") + "\n",
619 )
620 .unwrap();
621 std::fs::write(
622 fx.store.join("lessons.jsonl"),
623 [lesson("L1", "s-9-100", "Is the lock taken before the flag is read?"), lesson("L2", "s-0-1", "unrelated")].join("\n") + "\n",
624 )
625 .unwrap();
626 let r = fx.why(&fx.repo, "src/lib.rs", false).unwrap().unwrap();
627 let x = r.commits[0].reasoning.as_ref().unwrap();
628 assert_eq!((x.source.as_str(), x.task.as_str()), ("store", "replicated why"));
629 assert!(!x.proposal.contains("hunter22"));
630 assert_eq!(r.commits[0].lessons.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(), ["L1"], "linked by episode id");
631 assert!(r.commits[1].reasoning.is_none(), "an excerpt tagged with another collective is not shown");
632 let text = render(&r);
633 assert!(text.contains("lesson L1: Is the lock taken before the flag is read? -> a race corrupts sessions"), "{text}");
634 assert!(text.contains("borg notes fetch"), "{text}");
635 fx.note(&fx.repo, &fx.two, "corp", &["s-9-100"]);
637 let r = fx.why(&fx.repo, &fx.two, false).unwrap().unwrap();
638 assert_eq!(r.commits[0].reasoning.as_ref().unwrap().source, "note");
639 assert_eq!(r.commits[0].lessons.len(), 1);
640 let json = serde_json::to_value(&r).unwrap();
641 assert_eq!(json["commits"][0]["lessons"][0]["id"], "L1");
642 assert!(json["commits"][0].get("ts").is_none());
643 crate::absorb::write(&fx.store, &["L1"], crate::absorb::ABSORB, None, "lead@x", 5, &|_| Some(1)).unwrap();
645 let r = fx.why(&fx.repo, &fx.two, false).unwrap().unwrap();
646 assert!(r.commits[0].lessons.is_empty(), "{:?}", r.commits[0].lessons.iter().map(|l| &l.id).collect::<Vec<_>>());
647 }
648
649 #[test]
650 fn private_notes_stay_in_their_collective_and_off_prints_nothing() {
651 let fx = fixture("private");
652 let private = fx.base.join("private/app");
654 std::fs::create_dir_all(private.parent().unwrap()).unwrap();
655 sh(&fx.base, &["clone", "-q", &fx.repo.to_string_lossy(), &private.to_string_lossy()]);
656 fx.note(&private, &fx.two, "private", &[]);
657 let r = fx.why(&private, "src/lib.rs:2", false).unwrap().unwrap();
658 assert_eq!(r.collective, "private");
659 assert!(r.commits[0].reasoning.is_some(), "shown where the cwd routes to the same private collective");
660 fx.note(&fx.repo, &fx.two, "private", &[]);
662 let r = fx.why(&fx.repo, "src/lib.rs:2", false).unwrap().unwrap();
663 assert!(r.commits[0].reasoning.is_none(), "a private note never shows in another collective");
664 let secret = fx.work.join("secret");
666 std::fs::create_dir_all(&secret).unwrap();
667 std::fs::write(secret.join("x.rs"), "x").unwrap();
668 assert_eq!(fx.why(&secret, "x.rs", false).unwrap(), None);
669 assert_eq!(fx.why(&fx.repo, "../secret/x.rs", false).unwrap(), None, "a target under an off path");
670 assert_eq!(fx.why(&fx.base.join("elsewhere"), "HEAD", false).unwrap(), None, "default off");
671 assert!(fx.why(&fx.repo, "../../private/app/src/lib.rs", false).unwrap_err().contains("another collective"));
673 }
674
675 #[test]
676 fn routes_resolved_paths_and_the_repo_itself() {
677 let fx = fixture("resolve");
678 fx.note(&fx.repo, &fx.two, "corp", &[]);
679 let secret = fx.work.join("secret");
681 std::fs::create_dir_all(&secret).unwrap();
682 sh(&fx.base, &["clone", "-q", &fx.repo.to_string_lossy(), &secret.to_string_lossy()]);
683 sh(&fx.repo, &["push", "-q", &secret.to_string_lossy(), "refs/notes/borg:refs/notes/borg"]);
684 std::os::unix::fs::symlink(&secret, fx.repo.join("link")).unwrap();
685 std::os::unix::fs::symlink(&secret, fx.work.join("alias")).unwrap();
686 assert_eq!(fx.why(&fx.repo, "link/src/lib.rs", false).unwrap(), None, "a symlinked file in an off repo");
687 assert_eq!(fx.why(&fx.repo.join("link"), "src/lib.rs:2", false).unwrap(), None, "a symlinked cwd");
688 assert_eq!(fx.why(&fx.work.join("alias"), "HEAD", false).unwrap(), None, "a symlinked cwd, commit form");
689 assert_eq!(fx.why(&fx.repo.join("src/../link"), "HEAD", false).unwrap(), None);
690 let mut cfg = fx.cfg.clone();
692 cfg.collectives[1].paths.push(fx.repo.join("src").to_string_lossy().into_owned());
693 let r = explain(Some(&cfg), &fx.base.join("store"), &fx.repo.join("src").to_string_lossy(), "lib.rs:2", false).unwrap();
694 assert_eq!(r, None, "a private subdirectory of a team repo");
695 let r = explain(Some(&cfg), &fx.base.join("store"), &fx.repo.join("src").to_string_lossy(), "HEAD", false).unwrap();
696 assert_eq!(r, None);
697 assert!(fx.why(&fx.repo, "src/lib.rs:2", false).unwrap().unwrap().commits[0].reasoning.is_some());
699 }
700
701 #[test]
702 fn blame_headers_give_the_order_and_the_cap_comes_first() {
703 let porcelain = "\
704aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1
705author A
706author-time 100
707summary old
708\tx
709bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2 2 1
710author B
711author-time 300
712summary new
713\ty
714aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 3 3 1
715\tz
7160000000000000000000000000000000000000000 4 4 2
717author Not Committed Yet
718author-time 999
719\tw
720";
721 let (c, u) = blamed(porcelain);
722 assert_eq!(c, [("a".repeat(40), 100), ("b".repeat(40), 300)]);
723 assert_eq!(u, 2);
724
725 let fx = fixture("cap");
728 let file = fx.repo.join("src/lib.rs");
729 let mut text = std::fs::read_to_string(&file).unwrap();
730 let mut made = Vec::new();
731 for i in 0..12 {
732 text.push_str(&format!("fn f{i}() {{}}\n"));
733 std::fs::write(&file, &text).unwrap();
734 made.push(commit_as(&fx.repo, "Cy", 1_800_000_000 + i, &format!("f{i}")));
735 }
736 std::fs::create_dir_all(&fx.store).unwrap();
737 let line = |c: &str, ms: i64, task: &str| {
738 serde_json::json!({"kind": "reasoning", "commit": c, "repo": "app", "session": "s", "task": task, "proposal": "", "episodes": [], "ms": ms})
739 .to_string()
740 };
741 let lines = [line(&made[11], 1, "older"), line(&made[11], 2, "newest"), line(&made[0], 1, "cut")];
742 std::fs::write(fx.store.join("reasoning.jsonl"), lines.join("\n") + "\n").unwrap();
743 fx.note(&fx.repo, &made[10], "corp", &[]);
744 let r = fx.why(&fx.repo, "src/lib.rs", false).unwrap().unwrap();
745 let subjects: Vec<&str> = r.commits.iter().map(|w| w.subject.as_str()).collect();
746 assert_eq!(subjects, ["f11", "f10", "f9", "f8", "f7", "f6", "f5", "f4"]);
747 assert_eq!(r.more, 6, "f3..f0, two and one");
748 assert_eq!(r.commits[0].reasoning.as_ref().map(|x| (x.source.as_str(), x.task.as_str())), Some(("store", "newest")));
749 assert_eq!(r.commits[1].reasoning.as_ref().map(|x| x.source.as_str()), Some("note"));
750 assert_eq!((r.commits[0].author.as_str(), r.commits[0].short.len() >= 7), ("Cy", true));
751 let full = fx.why(&fx.repo, "src/lib.rs", true).unwrap().unwrap();
752 assert_eq!((full.commits.len(), full.more), (14, 0));
753 assert_eq!(full.commits[11].reasoning.as_ref().map(|x| x.task.as_str()), Some("cut"));
754 assert_eq!(full.commits[12].sha, fx.two);
755 }
756
757 #[test]
758 fn a_dir_without_git_is_a_clear_error() {
759 let fx = fixture("nogit");
760 let plain = fx.work.join("plain");
761 std::fs::create_dir_all(&plain).unwrap();
762 std::fs::write(plain.join("a.txt"), "x\n").unwrap();
763 let err = fx.why(&plain, "a.txt:1", false).unwrap_err();
765 assert!(err.contains("is not in a git repo"), "{err}");
766 let err = fx.why(&plain, "HEAD", false).unwrap_err();
767 assert!(err.contains("is not in a git repo"), "{err}");
768 }
769
770 #[test]
771 fn compact_caps_and_full() {
772 let fx = fixture("caps");
773 let long = "x".repeat(2000);
774 let e = Excerpt {
775 kind: "reasoning".into(),
776 commit: fx.two.clone(),
777 repo: "app".into(),
778 session: "s".into(),
779 task: "t".into(),
780 proposal: format!("start {long} end"),
781 episodes: vec![],
782 ms: 1,
783 ..Excerpt::default()
784 };
785 notes::write_note(&fx.repo, &e).unwrap();
786 let compact = fx.why(&fx.repo, "HEAD", false).unwrap().unwrap();
787 let p = &compact.commits[0].reasoning.as_ref().unwrap().proposal;
788 assert!(p.chars().count() <= PROPOSAL_CAP + 1 && p.ends_with("end"), "the proposal's conclusion is kept");
789 let full = fx.why(&fx.repo, "HEAD", true).unwrap().unwrap();
790 assert!(full.commits[0].reasoning.as_ref().unwrap().proposal.starts_with("start"), "an untagged (older) note shows in the repo's collective");
791 }
792}