1use crate::CliError;
19use core_api::{Direction, IngestOptions, Predicate, ResultSet, RuleDef, SharedDb, Value};
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24pub const DEFAULT_MAX_COMMITS_PER_FILE: usize = 200;
27
28const CO_CHANGE_MIN: f64 = 0.25;
30
31const SYNC_KEY: &str = "__mushroomdb_git_sync__";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct IngestGitOpts {
42 pub repo: PathBuf,
43 pub exclude: Vec<String>,
46 pub max_commits_per_file: usize,
47}
48
49#[derive(Debug, Default, Clone, PartialEq, Eq)]
50pub struct IngestGitReport {
51 pub commits: usize,
52 pub files: usize,
53 pub authors: usize,
54 pub renamed: usize,
55 pub deleted: usize,
56 pub incremental: bool,
57 pub rules_created: Vec<String>,
58}
59
60#[derive(Debug)]
61enum Change {
62 Added(String),
63 Modified(String),
64 Deleted(String),
65 Renamed { from: String, to: String },
66}
67
68#[derive(Debug)]
69struct GitCommit {
70 sha: String,
71 author_name: String,
72 author_email: String,
73 ts: i64,
74 subject: String,
75 changes: Vec<Change>,
76}
77
78fn excluded(path: &str, patterns: &[String]) -> bool {
80 patterns.iter().any(|p| {
81 if let Some(prefix) = p.strip_suffix('/') {
82 path.starts_with(&format!("{prefix}/"))
83 } else if let Some(ext) = p.strip_prefix("*.") {
84 path.contains('.') && path.rsplit('.').next() == Some(ext)
85 } else {
86 path.contains(p.as_str())
87 }
88 })
89}
90
91fn git_output(repo: &Path, args: &[&str]) -> Result<std::process::Output, CliError> {
92 Command::new("git")
93 .arg("-C")
94 .arg(repo)
95 .args(args)
96 .output()
97 .map_err(|e| CliError(format!("cannot run git in {}: {e}", repo.display())))
98}
99
100fn read_log(repo: &Path, since: Option<&str>, head: &str) -> Result<Vec<GitCommit>, CliError> {
106 if let Some(s) = since {
107 let spec = format!("{s}^{{commit}}");
108 if !git_output(repo, &["cat-file", "-e", &spec])?
109 .status
110 .success()
111 {
112 return Err(CliError(format!(
113 "recorded sync head {s} is not in {} (history rewritten?); \
114 ingest into a fresh database directory",
115 repo.display()
116 )));
117 }
118 }
119 let mut cmd = Command::new("git");
120 cmd.arg("-C").arg(repo).args([
121 "-c",
126 "core.quotePath=false",
127 "log",
128 "--reverse",
129 "--name-status",
130 "-M",
131 "--no-color",
132 "--format=%x1e%H%x1f%an%x1f%ae%x1f%at%x1f%s",
138 ]);
139 match since {
140 Some(s) => cmd.arg(format!("{s}..{head}")),
141 None => cmd.arg(head),
142 };
143 let out = cmd
144 .output()
145 .map_err(|e| CliError(format!("cannot run git: {e}")))?;
146 if !out.status.success() {
147 return Err(CliError(format!(
148 "git log failed: {}",
149 String::from_utf8_lossy(&out.stderr).trim()
150 )));
151 }
152 let text = String::from_utf8_lossy(&out.stdout);
153 let mut commits = Vec::new();
154 for block in text.split('\x1e').filter(|b| !b.trim().is_empty()) {
155 let mut lines = block.lines();
156 let header = lines.next().unwrap_or("");
157 let f: Vec<&str> = header.split('\x1f').collect();
158 if f.len() < 5 {
159 continue;
160 }
161 let mut changes = Vec::new();
162 for l in lines {
163 let cols: Vec<&str> = l.split('\t').collect();
164 match cols.as_slice() {
165 [s, p] if s.starts_with('A') => changes.push(Change::Added((*p).to_string())),
166 [s, p] if s.starts_with('M') || s.starts_with('T') => {
167 changes.push(Change::Modified((*p).to_string()))
168 }
169 [s, p] if s.starts_with('D') => changes.push(Change::Deleted((*p).to_string())),
170 [s, from, to] if s.starts_with('R') => changes.push(Change::Renamed {
171 from: (*from).to_string(),
172 to: (*to).to_string(),
173 }),
174 [s, _from, to] if s.starts_with('C') => {
176 changes.push(Change::Added((*to).to_string()))
177 }
178 _ => {}
179 }
180 }
181 commits.push(GitCommit {
182 sha: f[0].into(),
183 author_name: f[1].into(),
184 author_email: f[2].into(),
185 ts: f[3].parse().unwrap_or(0),
186 subject: f[4].into(),
187 changes,
188 });
189 }
190 Ok(commits)
191}
192
193fn head_sha(repo: &Path) -> Result<Option<String>, CliError> {
208 let out = git_output(repo, &["rev-parse", "--verify", "-q", "HEAD^{commit}"])?;
209 if !out.status.success() {
210 if !git_output(repo, &["rev-parse", "--git-dir"])?
212 .status
213 .success()
214 {
215 return Err(CliError(format!(
216 "not a git repository: {}",
217 repo.display()
218 )));
219 }
220 return Ok(None);
221 }
222 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
223 if sha.is_empty() {
224 return Err(CliError(format!(
225 "git could not resolve HEAD in {}",
226 repo.display()
227 )));
228 }
229 Ok(Some(sha))
230}
231
232const AUTHOR_COUNT_SEP: char = '\t';
235
236fn file_props(st: &FileState, path: &str) -> Vec<(String, Value)> {
237 let commits = &st.commits;
238 let dir = path
239 .rsplit_once('/')
240 .map(|(d, _)| d)
241 .unwrap_or("")
242 .to_string();
243 let ext = path
244 .rsplit_once('.')
245 .map(|(_, e)| e)
246 .unwrap_or("")
247 .to_string();
248 vec![
249 ("id".into(), Value::Str(path.into())),
250 ("path".into(), Value::Str(path.into())),
251 ("dir".into(), Value::Str(dir)),
252 ("ext".into(), Value::Str(ext)),
253 (
254 "commits".into(),
255 Value::List(commits.iter().map(|s| Value::Str(s.clone())).collect()),
256 ),
257 ("n_commits".into(), Value::Int(st.n_commits as i64)),
262 ("top_author_id".into(), Value::Str(st.top_author())),
263 ("author_counts".into(), st.author_counts_value()),
266 ]
267}
268
269#[derive(Default, Clone)]
271struct FileState {
272 commits: Vec<String>,
273 by_author: BTreeMap<String, usize>,
274 n_commits: usize,
275}
276
277impl FileState {
278 fn touch(&mut self, sha: &str, author: &str, cap: usize) {
279 self.commits.push(sha.to_string());
280 if cap > 0 && self.commits.len() > cap {
281 self.commits.remove(0);
282 }
283 self.n_commits += 1;
284 *self.by_author.entry(author.to_string()).or_default() += 1;
285 }
286
287 fn top_author(&self) -> String {
290 self.by_author
291 .iter()
292 .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
293 .map(|(a, _)| a.clone())
294 .unwrap_or_default()
295 }
296
297 fn author_counts_value(&self) -> Value {
305 Value::List(
306 self.by_author
307 .iter()
308 .map(|(email, n)| Value::Str(format!("{email}{AUTHOR_COUNT_SEP}{n}")))
309 .collect(),
310 )
311 }
312
313 fn set_author_counts(&mut self, list: &[Value]) {
316 for v in list {
317 let Value::Str(s) = v else { continue };
318 let Some((email, n)) = s.rsplit_once(AUTHOR_COUNT_SEP) else {
319 continue;
320 };
321 let Ok(n) = n.parse::<usize>() else { continue };
322 if !email.is_empty() {
323 *self.by_author.entry(email.to_string()).or_default() += n;
324 }
325 }
326 }
327}
328
329const FILE_STATE_QUERY: &str = "MATCH (f:File) RETURN f.id AS id, f.commits AS commits, \
331 f.n_commits AS n, f.top_author_id AS top, f.author_counts AS author_counts";
332
333fn file_state_from(rs: &ResultSet) -> BTreeMap<String, FileState> {
336 let mut files = BTreeMap::new();
337 for i in 0..rs.len() {
338 let id = match rs.get(i, "id") {
339 Some(Value::Str(s)) => s.clone(),
340 _ => continue,
341 };
342 let mut st = FileState::default();
343 if let Some(Value::List(l)) = rs.get(i, "commits") {
344 st.commits = l
345 .iter()
346 .filter_map(|v| match v {
347 Value::Str(s) => Some(s.clone()),
348 _ => None,
349 })
350 .collect();
351 }
352 if let Some(Value::Int(n)) = rs.get(i, "n") {
353 st.n_commits = *n as usize;
354 }
355 match rs.get(i, "author_counts") {
356 Some(Value::List(l)) => st.set_author_counts(l),
357 _ => {
363 if let Some(Value::Str(t)) = rs.get(i, "top") {
364 st.by_author.insert(t.clone(), st.n_commits.max(1));
365 }
366 }
367 }
368 files.insert(id, st);
369 }
370 files
371}
372
373#[derive(Default)]
375struct Walk {
376 files: BTreeMap<String, FileState>,
377 authors: BTreeMap<String, String>,
378 commit_rows: Vec<BTreeMap<String, Value>>,
379 touched_edges: Vec<(String, String, String)>,
380 dirty: BTreeSet<String>,
382 deleted: BTreeSet<String>,
383 renamed: Vec<(String, String)>,
385 alias: BTreeMap<String, String>,
387}
388
389impl Walk {
390 fn rename(&mut self, from: &str, to: &str, node_exists: bool) {
391 if let Some(e) = self.renamed.iter_mut().find(|(_, t)| t == from) {
392 e.1 = to.to_string();
393 } else if node_exists {
394 self.deleted.remove(from);
396 self.renamed.push((from.to_string(), to.to_string()));
397 } else {
398 self.deleted.insert(from.to_string());
399 }
400 for v in self.alias.values_mut() {
401 if v == from {
402 *v = to.to_string();
403 }
404 }
405 self.alias.insert(from.to_string(), to.to_string());
406 }
407}
408
409pub fn run_ingest_git(db_dir: &Path, opts: &IngestGitOpts) -> Result<IngestGitReport, CliError> {
410 let db = SharedDb::open(db_dir)?;
411 let since: Option<String> = {
412 let r = db.read();
413 r.node_ref(SYNC_KEY)
414 .and_then(|n| n.prop("sha"))
415 .and_then(|v| match v {
416 Value::Str(s) if !s.is_empty() => Some(s),
417 _ => None,
418 })
419 };
420 let incremental = since.is_some();
421 let mut report = IngestGitReport {
422 incremental,
423 ..Default::default()
424 };
425 let Some(head) = head_sha(&opts.repo)? else {
429 return Ok(report); };
431 let log = read_log(&opts.repo, since.as_deref(), &head)?;
432 if log.is_empty() {
433 return Ok(report);
435 }
436
437 let mut w = db.write();
438 let ingest = IngestOptions::default(); let mut walk = Walk {
441 files: if incremental {
442 file_state_from(&w.query(FILE_STATE_QUERY, &BTreeMap::new())?)
443 } else {
444 BTreeMap::new()
445 },
446 ..Default::default()
447 };
448
449 for c in &log {
450 walk.authors
451 .entry(c.author_email.clone())
452 .or_insert_with(|| c.author_name.clone());
453 walk.commit_rows.push(BTreeMap::from([
454 ("id".to_string(), Value::Str(c.sha.clone())),
455 ("message".to_string(), Value::Str(c.subject.clone())),
456 ("ts".to_string(), Value::Int(c.ts)),
457 ("author_id".to_string(), Value::Str(c.author_email.clone())),
458 ]));
459 for ch in &c.changes {
460 match ch {
461 Change::Added(p) | Change::Modified(p) => {
462 if excluded(p, &opts.exclude) {
463 continue;
464 }
465 walk.deleted.remove(p);
466 walk.files.entry(p.clone()).or_default().touch(
467 &c.sha,
468 &c.author_email,
469 opts.max_commits_per_file,
470 );
471 walk.dirty.insert(p.clone());
472 walk.touched_edges
473 .push(("TOUCHED".into(), c.sha.clone(), p.clone()));
474 }
475 Change::Deleted(p) => {
476 if excluded(p, &opts.exclude) {
477 continue;
478 }
479 walk.files.remove(p);
480 walk.dirty.remove(p);
481 walk.deleted.insert(p.clone());
482 }
483 Change::Renamed { from, to } => {
484 if excluded(to, &opts.exclude) {
485 walk.files.remove(from);
488 walk.dirty.remove(from);
489 walk.deleted.insert(from.clone());
490 continue;
491 }
492 let mut st = walk.files.remove(from).unwrap_or_default();
493 st.touch(&c.sha, &c.author_email, opts.max_commits_per_file);
494 walk.files.insert(to.clone(), st);
495 walk.dirty.remove(from);
496 walk.dirty.insert(to.clone());
497 walk.deleted.remove(to);
498 walk.touched_edges
499 .push(("TOUCHED".into(), c.sha.clone(), to.clone()));
500 let exists = incremental && w.has_node(from);
501 walk.rename(from, to, exists);
502 }
503 }
504 }
505 }
506
507 let author_rows: Vec<BTreeMap<String, Value>> = walk
510 .authors
511 .iter()
512 .filter(|(email, _)| !w.has_node(email))
513 .map(|(email, name)| {
514 BTreeMap::from([
515 ("id".to_string(), Value::Str(email.clone())),
516 ("name".to_string(), Value::Str(name.clone())),
517 ])
518 })
519 .collect();
520 let a = w.ingest_with_edges("Author", author_rows, &ingest, &[])?;
521 report.rules_created.extend(a.rules_created);
522 report.authors = walk.authors.len();
523
524 for p in &walk.deleted {
529 if w.has_node(p) {
530 w.delete_node(p)?;
531 report.deleted += 1;
532 }
533 }
534 for (from, to) in &walk.renamed {
535 if !w.has_node(from) || from == to {
536 continue;
538 }
539 if !walk.files.contains_key(to) {
540 w.delete_node(from)?;
545 report.deleted += 1;
546 continue;
547 }
548 if w.has_node(to) {
549 w.delete_node(to)?;
552 report.deleted += 1;
553 }
554 w.rename_node(from, to)?;
555 w.set_prop(to, "id", Value::Str(to.clone()))?;
559 report.renamed += 1;
560 }
561
562 let mut new_file_rows = Vec::new();
566 for (path, st) in &walk.files {
567 if incremental && !walk.dirty.contains(path) {
568 continue;
569 }
570 let props = file_props(st, path);
571 if w.has_node(path) {
572 for (k, v) in props {
573 w.set_prop(path, &k, v)?;
574 }
575 } else {
576 new_file_rows.push(props.into_iter().collect::<BTreeMap<_, _>>());
577 }
578 }
579 let f = w.ingest_with_edges("File", new_file_rows, &ingest, &[])?;
580 report.rules_created.extend(f.rules_created);
581 report.files = walk.files.len();
582
583 let c = w.ingest_with_edges("Commit", walk.commit_rows, &ingest, &[])?;
590 report.rules_created.extend(c.rules_created);
591 report.commits = log.len();
592
593 let touched: Vec<(String, String, String)> = walk
596 .touched_edges
597 .into_iter()
598 .map(|(t, sha, p)| {
599 let p = walk.alias.get(&p).cloned().unwrap_or(p);
600 (t, sha, p)
601 })
602 .filter(|(_, _, p)| walk.files.contains_key(p))
603 .collect();
604 if !touched.is_empty() {
605 w.ingest_with_edges("Commit", Vec::new(), &ingest, &touched)?;
606 }
607
608 if !incremental {
611 let co = Predicate::Overlap {
612 field: "commits".into(),
613 min: CO_CHANGE_MIN,
614 };
615 w.create_rule(RuleDef {
616 name: "co_changed".into(),
617 src_label: "File".into(),
618 dst_label: "File".into(),
619 predicate: co.clone(),
620 edge_type: "CO_CHANGED".into(),
621 weight_prop: Some("score".into()),
622 max_edges: Some(10),
623 approximate: false,
624 via_label: None,
625 via_edge: None,
626 via_dir: None,
627 })?;
628 w.create_rule(RuleDef {
629 name: "knows".into(),
630 src_label: "Author".into(),
631 dst_label: "File".into(),
632 predicate: co,
633 edge_type: "KNOWS".into(),
634 weight_prop: Some("score".into()),
635 max_edges: Some(20),
636 approximate: false,
637 via_label: Some("File".into()),
638 via_edge: Some("TOP_AUTHOR".into()),
639 via_dir: Some(Direction::In),
640 })?;
641 report
642 .rules_created
643 .extend(["co_changed".to_string(), "knows".to_string()]);
644 for (l, field) in [("File", "path"), ("Commit", "message"), ("Author", "name")] {
645 w.enable_fulltext(l, field)?;
646 }
647 }
648
649 if w.has_node(SYNC_KEY) {
652 w.set_prop(SYNC_KEY, "sha", Value::Str(head))?;
653 } else {
654 w.insert_node(
655 "GitSync",
656 SYNC_KEY,
657 vec![
658 ("id".into(), Value::Str(SYNC_KEY.into())),
659 ("sha".into(), Value::Str(head)),
660 ],
661 )?;
662 }
663 Ok(report)
664}
665
666pub fn format_ingest_git(r: &IngestGitReport) -> String {
667 let mut out = format!(
668 "ingest-git: {} commit(s), {} file(s), {} author(s){}\n",
669 r.commits,
670 r.files,
671 r.authors,
672 if r.incremental { " (incremental)" } else { "" }
673 );
674 if r.renamed + r.deleted > 0 {
675 out.push_str(&format!(" renamed {} deleted {}\n", r.renamed, r.deleted));
676 }
677 if !r.rules_created.is_empty() {
678 out.push_str(&format!(" rules: {}\n", r.rules_created.join(", ")));
679 }
680 out
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 #[test]
688 fn exclude_matches_prefix_extension_and_substring() {
689 let pats = vec![
690 "target/".to_string(),
691 "*.lock".into(),
692 "node_modules".into(),
693 ];
694 assert!(excluded("target/debug/foo.rs", &pats));
695 assert!(
696 !excluded("targeted/foo.rs", &pats),
697 "prefix needs the slash"
698 );
699 assert!(excluded("Cargo.lock", &pats));
700 assert!(!excluded("Cargo.toml", &pats));
701 assert!(excluded("ui/node_modules/x/y.js", &pats));
702 assert!(!excluded("src/lib.rs", &pats));
703 assert!(!excluded("anything", &[]));
704 }
705
706 #[test]
707 fn file_props_split_dir_and_ext() {
708 let mut st = FileState::default();
709 st.touch("sha1", "a@x.test", 200);
710 let m: BTreeMap<_, _> = file_props(&st, "src/a/b.rs").into_iter().collect();
711 assert_eq!(m["dir"], Value::Str("src/a".into()));
712 assert_eq!(m["ext"], Value::Str("rs".into()));
713 assert_eq!(m["n_commits"], Value::Int(1));
714 assert_eq!(m["id"], Value::Str("src/a/b.rs".into()));
715 assert_eq!(m["top_author_id"], Value::Str("a@x.test".into()));
716 assert_eq!(
717 m["author_counts"],
718 Value::List(vec![Value::Str("a@x.test\t1".into())])
719 );
720 let m: BTreeMap<_, _> = file_props(&FileState::default(), "README")
721 .into_iter()
722 .collect();
723 assert_eq!(m["dir"], Value::Str(String::new()));
724 assert_eq!(m["ext"], Value::Str(String::new()));
725 }
726
727 #[test]
731 fn author_counts_round_trip_preserves_the_distribution() {
732 let mut st = FileState::default();
733 for _ in 0..3 {
734 st.touch("s", "alice@x.test", 200);
735 }
736 for _ in 0..4 {
737 st.touch("s", "bob@x.test", 200);
738 }
739 let Value::List(encoded) = st.author_counts_value() else {
740 panic!("author_counts must be a list");
741 };
742 assert_eq!(
743 encoded,
744 vec![
745 Value::Str("alice@x.test\t3".into()),
746 Value::Str("bob@x.test\t4".into()),
747 ],
748 "email order, so the prop is stable across runs"
749 );
750 let mut reloaded = FileState::default();
751 reloaded.set_author_counts(&encoded);
752 assert_eq!(reloaded.by_author, st.by_author);
753 assert_eq!(reloaded.top_author(), "bob@x.test");
754 }
755
756 #[test]
759 fn author_counts_skips_entries_it_cannot_parse() {
760 let mut st = FileState::default();
761 st.set_author_counts(&[
762 Value::Str("alice@x.test\t2".into()),
763 Value::Str("no-tab-here".into()),
764 Value::Str("bob@x.test\tnotanumber".into()),
765 Value::Str("\t5".into()),
766 Value::Int(7),
767 ]);
768 assert_eq!(st.by_author, BTreeMap::from([("alice@x.test".into(), 2)]));
769 }
770
771 #[test]
772 fn commits_list_is_capped_and_top_author_is_deterministic() {
773 let mut st = FileState::default();
774 for i in 0..5 {
775 st.touch(&format!("sha{i}"), "b@x.test", 3);
776 }
777 st.touch("shaX", "a@x.test", 3);
778 assert_eq!(st.commits, vec!["sha3", "sha4", "shaX"]);
779 assert_eq!(st.n_commits, 6);
780 assert_eq!(st.top_author(), "b@x.test");
781
782 let m: BTreeMap<_, _> = file_props(&st, "src/hot.rs").into_iter().collect();
787 assert_eq!(m["n_commits"], Value::Int(6));
788 let Value::List(commits) = &m["commits"] else {
789 panic!("commits must be a list");
790 };
791 assert_eq!(commits.len(), 3, "the list is still capped at 3");
792 assert!(
793 matches!(m["n_commits"], Value::Int(n) if n as usize > commits.len()),
794 "n_commits must exceed the capped list once the cap is passed"
795 );
796 assert_eq!(
797 m["author_counts"],
798 Value::List(vec![
799 Value::Str("a@x.test\t1".into()),
800 Value::Str("b@x.test\t5".into()),
801 ]),
802 "the per-author counts sum to n_commits, not to the capped list"
803 );
804
805 let mut tie = FileState::default();
806 tie.touch("s", "b@x.test", 10);
807 tie.touch("s", "a@x.test", 10);
808 assert_eq!(tie.top_author(), "a@x.test", "ties break on smallest email");
809 }
810}