1use crate::structure;
26use crate::CliError;
27use core_api::{
28 default_max_edges, Direction, GraphError, IngestOptions, Predicate, ResultSet, RuleDef,
29 SharedDb, Value, WriteGuard, WRITE_LOCK_WAIT,
30};
31use std::collections::{BTreeMap, BTreeSet};
32use std::path::{Path, PathBuf};
33use std::process::Command;
34
35pub const DEFAULT_MAX_COMMITS_PER_FILE: usize = 200;
38
39pub use core_api::repograph::DEFAULT_EXCLUDES;
46
47const CO_CHANGE_MIN: f64 = 0.25;
49
50pub(crate) const SYNC_KEY: &str = "__mushroomdb_git_sync__";
58
59pub const BUSY_MESSAGE: &str = "another mushroomdb process is writing; retry";
62
63const PR_FK_RULE: &str = "auto_fk_commit_pr_id";
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct IngestGitOpts {
69 pub repo: PathBuf,
70 pub exclude: Vec<String>,
73 pub max_commits_per_file: usize,
74 pub recurse_submodules: bool,
76 pub prs: bool,
78 pub structure: bool,
81 pub docs: bool,
84 pub ensure_gitignore: bool,
86}
87
88#[derive(Debug, Default, Clone, PartialEq, Eq)]
89pub struct IngestGitReport {
90 pub commits: usize,
91 pub files: usize,
92 pub authors: usize,
93 pub renamed: usize,
94 pub deleted: usize,
95 pub incremental: bool,
96 pub rules_created: Vec<String>,
97 pub submodules: usize,
99 pub prs: usize,
101 pub gitignore_added: bool,
103 pub structure: crate::structure::StructureReport,
105}
106
107#[derive(Default)]
109struct StructureWork {
110 touched: BTreeSet<String>,
112 stale: BTreeSet<String>,
116}
117
118#[derive(Debug, Clone)]
124struct RepoUnit {
125 path: PathBuf,
126 prefix: String,
128 sync_key: String,
129}
130
131#[derive(Debug)]
132enum Change {
133 Added(String),
134 Modified(String),
135 Deleted(String),
136 Renamed { from: String, to: String },
137}
138
139#[derive(Debug)]
140struct GitCommit {
141 sha: String,
142 author_name: String,
143 author_email: String,
144 ts: i64,
145 subject: String,
146 changes: Vec<Change>,
147}
148
149fn excluded(path: &str, patterns: &[String]) -> bool {
155 core_api::repograph::path_excluded(path, patterns)
156}
157
158fn git_output(repo: &Path, args: &[&str]) -> Result<std::process::Output, CliError> {
159 Command::new("git")
160 .arg("-C")
161 .arg(repo)
162 .args(args)
163 .output()
164 .map_err(|e| CliError(format!("cannot run git in {}: {e}", repo.display())))
165}
166
167fn read_log(repo: &Path, since: Option<&str>, head: &str) -> Result<Vec<GitCommit>, CliError> {
177 if let Some(s) = since {
178 let spec = format!("{s}^{{commit}}");
179 if !git_output(repo, &["cat-file", "-e", &spec])?
180 .status
181 .success()
182 {
183 return Err(CliError(format!(
184 "recorded sync head {s} is not in {} (history rewritten?); \
185 ingest into a fresh database directory",
186 repo.display()
187 )));
188 }
189 }
190 let mut cmd = Command::new("git");
191 cmd.arg("-C").arg(repo).args([
192 "-c",
197 "core.quotePath=false",
198 "log",
199 "--reverse",
200 "--name-status",
201 "-M",
202 "--no-color",
203 "--format=%x1e%H%x1f%aN%x1f%aE%x1f%at%x1f%s",
209 ]);
210 match since {
211 Some(s) => cmd.arg(format!("{s}..{head}")),
212 None => cmd.arg(head),
213 };
214 let out = cmd
215 .output()
216 .map_err(|e| CliError(format!("cannot run git: {e}")))?;
217 if !out.status.success() {
218 return Err(CliError(format!(
219 "git log failed: {}",
220 String::from_utf8_lossy(&out.stderr).trim()
221 )));
222 }
223 let text = String::from_utf8_lossy(&out.stdout);
224 let mut commits = Vec::new();
225 for block in text.split('\x1e').filter(|b| !b.trim().is_empty()) {
226 let mut lines = block.lines();
227 let header = lines.next().unwrap_or("");
228 let f: Vec<&str> = header.split('\x1f').collect();
229 if f.len() < 5 {
230 continue;
231 }
232 let mut changes = Vec::new();
233 for l in lines {
234 let cols: Vec<&str> = l.split('\t').collect();
235 match cols.as_slice() {
236 [s, p] if s.starts_with('A') => changes.push(Change::Added((*p).to_string())),
237 [s, p] if s.starts_with('M') || s.starts_with('T') => {
238 changes.push(Change::Modified((*p).to_string()))
239 }
240 [s, p] if s.starts_with('D') => changes.push(Change::Deleted((*p).to_string())),
241 [s, from, to] if s.starts_with('R') => changes.push(Change::Renamed {
242 from: (*from).to_string(),
243 to: (*to).to_string(),
244 }),
245 [s, _from, to] if s.starts_with('C') => {
247 changes.push(Change::Added((*to).to_string()))
248 }
249 _ => {}
250 }
251 }
252 commits.push(GitCommit {
253 sha: f[0].into(),
254 author_name: f[1].into(),
255 author_email: f[2].into(),
256 ts: f[3].parse().unwrap_or(0),
257 subject: f[4].into(),
258 changes,
259 });
260 }
261 Ok(commits)
262}
263
264fn head_sha(repo: &Path) -> Result<Option<String>, CliError> {
279 let out = git_output(repo, &["rev-parse", "--verify", "-q", "HEAD^{commit}"])?;
280 if !out.status.success() {
281 if !git_output(repo, &["rev-parse", "--git-dir"])?
283 .status
284 .success()
285 {
286 return Err(CliError(format!(
287 "not a git repository: {}",
288 repo.display()
289 )));
290 }
291 return Ok(None);
292 }
293 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
294 if sha.is_empty() {
295 return Err(CliError(format!(
296 "git could not resolve HEAD in {}",
297 repo.display()
298 )));
299 }
300 Ok(Some(sha))
301}
302
303fn canonical(p: &Path) -> PathBuf {
308 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
309}
310
311fn gitlink_paths(unit: &Path) -> BTreeSet<String> {
319 let out = Command::new("git")
320 .arg("config")
321 .arg("--file")
322 .arg(unit.join(".gitmodules"))
323 .args(["--get-regexp", r"^submodule\..*\.path$"])
324 .output();
325 let Ok(out) = out else { return BTreeSet::new() };
326 if !out.status.success() {
327 return BTreeSet::new(); }
329 String::from_utf8_lossy(&out.stdout)
330 .lines()
331 .filter_map(|l| l.split_once(' '))
332 .map(|(_, path)| path.trim().to_string())
333 .filter(|p| !p.is_empty())
334 .collect()
335}
336
337fn repo_units(repo: &Path, recurse: bool) -> Result<Vec<RepoUnit>, CliError> {
345 let mut units = vec![RepoUnit {
346 path: repo.to_path_buf(),
347 prefix: String::new(),
348 sync_key: SYNC_KEY.to_string(),
349 }];
350 if !recurse {
351 return Ok(units);
352 }
353 let out = git_output(
354 repo,
355 &[
356 "submodule",
357 "foreach",
358 "--quiet",
359 "--recursive",
360 "echo \"$displaypath\"",
361 ],
362 )?;
363 if !out.status.success() {
364 return Ok(units);
365 }
366 let mut paths: Vec<String> = String::from_utf8_lossy(&out.stdout)
367 .lines()
368 .map(|l| l.trim_end_matches('/').trim().to_string())
369 .filter(|l| !l.is_empty())
370 .collect();
371 paths.sort();
372 paths.dedup();
373 for dp in paths {
374 let path = repo.join(&dp);
375 if !git_output(&path, &["rev-parse", "--git-dir"])?
378 .status
379 .success()
380 {
381 continue;
382 }
383 units.push(RepoUnit {
384 prefix: format!("{dp}/"),
385 sync_key: format!("{SYNC_KEY}:{dp}"),
386 path,
387 });
388 }
389 Ok(units)
390}
391
392fn localise(log: &mut [GitCommit], prefix: &str, gitlinks: &BTreeSet<String>) {
399 let keep = |p: &String| !gitlinks.contains(p.as_str());
400 for c in log.iter_mut() {
401 c.changes.retain(|ch| match ch {
402 Change::Added(p) | Change::Modified(p) | Change::Deleted(p) => keep(p),
403 Change::Renamed { from, to } => keep(from) && keep(to),
404 });
405 if prefix.is_empty() {
406 continue;
407 }
408 for ch in c.changes.iter_mut() {
409 match ch {
410 Change::Added(p) | Change::Modified(p) | Change::Deleted(p) => {
411 *p = format!("{prefix}{p}")
412 }
413 Change::Renamed { from, to } => {
414 *from = format!("{prefix}{from}");
415 *to = format!("{prefix}{to}");
416 }
417 }
418 }
419 }
420}
421
422fn ensure_gitignore(repo: &Path, db_dir: &Path) -> Result<bool, CliError> {
429 let Ok(rel) = canonical(db_dir)
430 .strip_prefix(repo)
431 .map(|p| p.to_path_buf())
432 else {
433 return Ok(false);
434 };
435 if rel.as_os_str().is_empty() {
436 return Ok(false);
437 }
438 let line = format!("{}/", rel.to_string_lossy().replace('\\', "/"));
439 let path = repo.join(".gitignore");
440 let current = match std::fs::read_to_string(&path) {
441 Ok(s) => s,
442 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
443 Err(e) => return Err(CliError(format!("cannot read {}: {e}", path.display()))),
444 };
445 let bare = line.trim_end_matches('/');
446 if current
447 .lines()
448 .map(|l| l.trim())
449 .any(|l| l == line || l == bare || l == format!("/{line}") || l == format!("/{bare}"))
450 {
451 return Ok(false);
452 }
453 let mut next = current;
454 if !next.is_empty() && !next.ends_with('\n') {
455 next.push('\n');
456 }
457 next.push_str(&line);
458 next.push('\n');
459 std::fs::write(&path, next)
460 .map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
461 Ok(true)
462}
463
464const AUTHOR_COUNT_SEP: char = '\t';
467
468fn file_props(st: &FileState, path: &str) -> Vec<(String, Value)> {
469 let commits = &st.commits;
470 let dir = path
471 .rsplit_once('/')
472 .map(|(d, _)| d)
473 .unwrap_or("")
474 .to_string();
475 let ext = path
476 .rsplit_once('.')
477 .map(|(_, e)| e)
478 .unwrap_or("")
479 .to_string();
480 vec![
481 ("id".into(), Value::Str(path.into())),
482 ("path".into(), Value::Str(path.into())),
483 ("dir".into(), Value::Str(dir)),
484 ("ext".into(), Value::Str(ext)),
485 (
486 "commits".into(),
487 Value::List(commits.iter().map(|s| Value::Str(s.clone())).collect()),
488 ),
489 ("n_commits".into(), Value::Int(st.n_commits as i64)),
494 ("top_author_id".into(), Value::Str(st.top_author())),
495 ("author_counts".into(), st.author_counts_value()),
498 ]
499}
500
501#[derive(Default, Clone)]
503struct FileState {
504 commits: Vec<String>,
505 by_author: BTreeMap<String, usize>,
506 n_commits: usize,
507}
508
509impl FileState {
510 fn touch(&mut self, sha: &str, author: &str, cap: usize) {
511 self.commits.push(sha.to_string());
512 if cap > 0 && self.commits.len() > cap {
513 self.commits.remove(0);
514 }
515 self.n_commits += 1;
516 *self.by_author.entry(author.to_string()).or_default() += 1;
517 }
518
519 fn top_author(&self) -> String {
522 self.by_author
523 .iter()
524 .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
525 .map(|(a, _)| a.clone())
526 .unwrap_or_default()
527 }
528
529 fn author_counts_value(&self) -> Value {
537 Value::List(
538 self.by_author
539 .iter()
540 .map(|(email, n)| Value::Str(format!("{email}{AUTHOR_COUNT_SEP}{n}")))
541 .collect(),
542 )
543 }
544
545 fn set_author_counts(&mut self, list: &[Value]) {
548 for v in list {
549 let Value::Str(s) = v else { continue };
550 let Some((email, n)) = s.rsplit_once(AUTHOR_COUNT_SEP) else {
551 continue;
552 };
553 let Ok(n) = n.parse::<usize>() else { continue };
554 if !email.is_empty() {
555 *self.by_author.entry(email.to_string()).or_default() += n;
556 }
557 }
558 }
559}
560
561#[derive(Debug, Clone, PartialEq, Eq)]
563struct PullRequest {
564 number: i64,
565 title: String,
566 url: String,
567 merged_at: String,
568 author_login: String,
569 merge_sha: Option<String>,
572}
573
574fn pr_key(number: i64) -> String {
575 format!("pr:{number}")
576}
577
578fn subject_pr(subject: &str) -> Option<i64> {
583 let rest = subject.strip_suffix(')')?;
584 let at = rest.rfind("(#")?;
585 let digits = &rest[at + 2..];
586 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
587 return None;
588 }
589 digits.parse().ok()
590}
591
592fn fetch_prs(repo: &Path) -> Vec<PullRequest> {
598 let out = Command::new("gh")
599 .current_dir(repo)
600 .args([
601 "pr",
602 "list",
603 "--state",
604 "merged",
605 "--limit",
606 "1000",
607 "--json",
608 "number,title,url,mergedAt,mergeCommit,author",
609 ])
610 .output();
611 let out = match out {
612 Ok(o) => o,
613 Err(_) => {
614 eprintln!("ingest-git: --prs skipped: gh is not on PATH");
615 return Vec::new();
616 }
617 };
618 if !out.status.success() {
619 let detail = String::from_utf8_lossy(&out.stderr)
620 .lines()
621 .find(|l| !l.trim().is_empty())
622 .unwrap_or("no detail")
623 .to_string();
624 eprintln!("ingest-git: --prs skipped: gh pr list failed: {detail}");
625 return Vec::new();
626 }
627 let parsed: serde_json::Value = match serde_json::from_slice(&out.stdout) {
628 Ok(v) => v,
629 Err(e) => {
630 eprintln!("ingest-git: --prs skipped: gh pr list output is not JSON: {e}");
631 return Vec::new();
632 }
633 };
634 let Some(items) = parsed.as_array() else {
635 eprintln!("ingest-git: --prs skipped: gh pr list did not return a list");
636 return Vec::new();
637 };
638 let mut prs: Vec<PullRequest> = items
639 .iter()
640 .filter_map(|v| {
641 let number = v.get("number")?.as_i64()?;
642 let str_at = |k: &str| {
643 v.get(k)
644 .and_then(|x| x.as_str())
645 .unwrap_or_default()
646 .to_string()
647 };
648 Some(PullRequest {
649 number,
650 title: str_at("title"),
651 url: str_at("url"),
652 merged_at: str_at("mergedAt"),
653 author_login: v
654 .get("author")
655 .and_then(|a| a.get("login"))
656 .and_then(|l| l.as_str())
657 .unwrap_or_default()
658 .to_string(),
659 merge_sha: v
660 .get("mergeCommit")
661 .and_then(|m| m.get("oid"))
662 .and_then(|o| o.as_str())
663 .filter(|s| !s.is_empty())
664 .map(str::to_string),
665 })
666 })
667 .collect();
668 prs.sort_by_key(|p| p.number);
671 prs.dedup_by_key(|p| p.number);
672 prs
673}
674
675fn ingest_prs(
678 w: &mut WriteGuard<'_>,
679 prs: &[PullRequest],
680 ingest: &IngestOptions,
681 report: &mut IngestGitReport,
682) -> Result<(), CliError> {
683 let rows: Vec<BTreeMap<String, Value>> = prs
684 .iter()
685 .filter(|p| !w.has_node(&pr_key(p.number)))
686 .map(|p| {
687 BTreeMap::from([
688 ("id".to_string(), Value::Str(pr_key(p.number))),
689 ("number".to_string(), Value::Int(p.number)),
690 ("title".to_string(), Value::Str(p.title.clone())),
691 ("url".to_string(), Value::Str(p.url.clone())),
692 ("merged_at".to_string(), Value::Str(p.merged_at.clone())),
693 (
694 "author_login".to_string(),
695 Value::Str(p.author_login.clone()),
696 ),
697 ])
698 })
699 .collect();
700 if !rows.is_empty() {
701 let r = w.ingest_with_edges("PR", rows, ingest, &[])?;
702 report.prs = r.inserted;
703 report.rules_created.extend(r.rules_created);
704 }
705 if !w.rules().iter().any(|r| r.name == PR_FK_RULE) {
710 let predicate = Predicate::KeyMatch {
711 field: "pr_id".into(),
712 };
713 let max_edges = Some(default_max_edges(&predicate));
714 w.create_rule(RuleDef {
715 name: PR_FK_RULE.into(),
716 src_label: "Commit".into(),
717 dst_label: "PR".into(),
718 predicate,
719 edge_type: "PR".into(),
720 weight_prop: None,
721 max_edges,
722 approximate: false,
723 via_label: None,
724 via_edge: None,
725 via_dir: None,
726 })?;
727 report.rules_created.push(PR_FK_RULE.to_string());
728 }
729 if !w
730 .fulltext_pairs()
731 .contains(&("PR".to_string(), "title".to_string()))
732 {
733 w.enable_fulltext("PR", "title")?;
734 }
735 Ok(())
736}
737
738fn link_prs(
742 w: &mut WriteGuard<'_>,
743 prs: &[PullRequest],
744 ingest: &IngestOptions,
745) -> Result<(), CliError> {
746 let by_sha: BTreeMap<&str, i64> = prs
747 .iter()
748 .filter_map(|p| p.merge_sha.as_deref().map(|s| (s, p.number)))
749 .collect();
750 let known: BTreeSet<i64> = prs.iter().map(|p| p.number).collect();
751
752 let rs = w.query(
753 "MATCH (c:Commit) RETURN c.id AS id, c.message AS message, c.pr_id AS pr_id",
754 &BTreeMap::new(),
755 )?;
756 let mut updates: Vec<(String, String)> = Vec::new();
757 let mut links: BTreeMap<i64, BTreeSet<String>> = BTreeMap::new();
758 for i in 0..rs.len() {
759 let Some(Value::Str(sha)) = rs.get(i, "id") else {
760 continue;
761 };
762 let subject = match rs.get(i, "message") {
763 Some(Value::Str(s)) => s.as_str(),
764 _ => "",
765 };
766 let Some(number) = by_sha
767 .get(sha.as_str())
768 .copied()
769 .or_else(|| subject_pr(subject).filter(|n| known.contains(n)))
770 else {
771 continue;
772 };
773 let key = pr_key(number);
774 links.entry(number).or_default().insert(sha.clone());
775 if rs.get(i, "pr_id") != Some(&Value::Str(key.clone())) {
776 updates.push((sha.clone(), key));
777 }
778 }
779 updates.sort(); for (sha, key) in updates {
781 w.set_prop(&sha, "pr_id", Value::Str(key))?;
782 }
783
784 let mut edges: Vec<(String, String, String)> = Vec::new();
785 for (number, shas) in links {
786 let src = pr_key(number);
787 let existing: BTreeSet<String> = w
788 .neighbors(&src, "MERGED_AS", Direction::Out)
789 .unwrap_or_default()
790 .into_iter()
791 .collect();
792 for sha in shas {
793 if !existing.contains(&sha) {
794 edges.push(("MERGED_AS".to_string(), src.clone(), sha));
795 }
796 }
797 }
798 if !edges.is_empty() {
799 w.ingest_with_edges("PR", Vec::new(), ingest, &edges)?;
800 }
801 Ok(())
802}
803
804const FILE_STATE_QUERY: &str =
812 "MATCH (f:File) WHERE startsWith(f.id, $prefix) RETURN f.id AS id, f.commits AS commits, \
813 f.n_commits AS n, f.top_author_id AS top, f.author_counts AS author_counts";
814
815fn file_state_from(rs: &ResultSet, nested: &[String]) -> BTreeMap<String, FileState> {
821 let mut files = BTreeMap::new();
822 for i in 0..rs.len() {
823 let id = match rs.get(i, "id") {
824 Some(Value::Str(s)) => s.clone(),
825 _ => continue,
826 };
827 if nested.iter().any(|p| id.starts_with(p.as_str())) {
828 continue;
829 }
830 let mut st = FileState::default();
831 if let Some(Value::List(l)) = rs.get(i, "commits") {
832 st.commits = l
833 .iter()
834 .filter_map(|v| match v {
835 Value::Str(s) => Some(s.clone()),
836 _ => None,
837 })
838 .collect();
839 }
840 if let Some(Value::Int(n)) = rs.get(i, "n") {
841 st.n_commits = *n as usize;
842 }
843 match rs.get(i, "author_counts") {
844 Some(Value::List(l)) => st.set_author_counts(l),
845 _ => {
851 if let Some(Value::Str(t)) = rs.get(i, "top") {
852 st.by_author.insert(t.clone(), st.n_commits.max(1));
853 }
854 }
855 }
856 files.insert(id, st);
857 }
858 files
859}
860
861#[derive(Default)]
863struct Walk {
864 files: BTreeMap<String, FileState>,
865 authors: BTreeMap<String, String>,
866 commit_rows: Vec<BTreeMap<String, Value>>,
867 touched_edges: Vec<(String, String, String)>,
868 dirty: BTreeSet<String>,
870 deleted: BTreeSet<String>,
871 renamed: Vec<(String, String)>,
873 alias: BTreeMap<String, String>,
875}
876
877impl Walk {
878 fn rename(&mut self, from: &str, to: &str, node_exists: bool) {
879 if let Some(e) = self.renamed.iter_mut().find(|(_, t)| t == from) {
880 e.1 = to.to_string();
881 } else if node_exists {
882 self.deleted.remove(from);
884 self.renamed.push((from.to_string(), to.to_string()));
885 } else {
886 self.deleted.insert(from.to_string());
887 }
888 for v in self.alias.values_mut() {
889 if v == from {
890 *v = to.to_string();
891 }
892 }
893 self.alias.insert(from.to_string(), to.to_string());
894 }
895}
896
897fn marker_flag_props(unit: &RepoUnit, opts: &IngestGitOpts) -> Vec<(String, Value)> {
901 vec![
902 (
903 "repo".into(),
904 Value::Str(unit.path.to_string_lossy().into_owned()),
905 ),
906 ("recurse".into(), Value::Bool(opts.recurse_submodules)),
907 ("prs".into(), Value::Bool(opts.prs)),
908 ("structure".into(), Value::Bool(opts.structure)),
909 ("docs".into(), Value::Bool(opts.docs)),
910 ]
911}
912
913struct Pending {
915 unit: RepoUnit,
916 since: Option<String>,
918 stale: bool,
920 head: Option<String>,
922 log: Vec<GitCommit>,
923 nested: Vec<String>,
926}
927
928pub fn run_ingest_git(db_dir: &Path, opts: &IngestGitOpts) -> Result<IngestGitReport, CliError> {
929 let repo = canonical(&opts.repo);
932 let units = repo_units(&repo, opts.recurse_submodules)?;
933 let prefixes: Vec<String> = units
934 .iter()
935 .map(|u| u.prefix.clone())
936 .filter(|p| !p.is_empty())
937 .collect();
938
939 let db = SharedDb::open(db_dir)?;
940 let mut report = IngestGitReport {
941 submodules: units.len() - 1,
942 ..Default::default()
943 };
944 if opts.ensure_gitignore {
945 report.gitignore_added = ensure_gitignore(&repo, db_dir)?;
946 }
947
948 let mut pending: Vec<Pending> = Vec::new();
949 {
950 let r = db.read();
951 for unit in units {
952 let nested = prefixes
953 .iter()
954 .filter(|p| **p != unit.prefix && p.starts_with(&unit.prefix))
955 .cloned()
956 .collect();
957 let (since, stale) = match r.node_ref(&unit.sync_key) {
958 Some(n) => (
959 match n.prop("sha") {
960 Some(Value::Str(s)) if !s.is_empty() => Some(s),
961 _ => None,
962 },
963 marker_flag_props(&unit, opts)
964 .into_iter()
965 .any(|(k, v)| n.prop(&k) != Some(v)),
966 ),
967 None => (None, false),
968 };
969 pending.push(Pending {
970 unit,
971 since,
972 stale,
973 head: None,
974 log: Vec::new(),
975 nested,
976 });
977 }
978 }
979 report.incremental = pending[0].since.is_some();
982
983 for p in &mut pending {
984 let Some(head) = head_sha(&p.unit.path)? else {
988 continue; };
990 let mut log = read_log(&p.unit.path, p.since.as_deref(), &head)?;
991 localise(&mut log, &p.unit.prefix, &gitlink_paths(&p.unit.path));
992 p.head = Some(head);
993 p.log = log;
994 }
995
996 let prs = if opts.prs {
997 fetch_prs(&repo)
998 } else {
999 Vec::new()
1000 };
1001 if prs.is_empty() && pending.iter().all(|p| p.log.is_empty() && !p.stale) {
1002 return Ok(report);
1004 }
1005
1006 let mut w = db.write_with_wait(WRITE_LOCK_WAIT).map_err(|e| match e {
1009 GraphError::Busy { .. } => CliError(BUSY_MESSAGE.to_string()),
1010 other => CliError(other.to_string()),
1011 })?;
1012 let ingest = IngestOptions::default(); if !prs.is_empty() {
1016 ingest_prs(&mut w, &prs, &ingest, &mut report)?;
1017 }
1018
1019 let mut authors: BTreeSet<String> = BTreeSet::new();
1020 let mut work = StructureWork::default();
1021 for p in &pending {
1022 if !p.log.is_empty() {
1023 ingest_unit(
1024 &mut w,
1025 p,
1026 opts,
1027 &ingest,
1028 &mut report,
1029 &mut authors,
1030 &mut work,
1031 )?;
1032 }
1033 }
1034 report.authors = authors.len();
1035
1036 if report.commits > 0 && !w.rules().iter().any(|r| r.name == "co_changed") {
1040 let co = Predicate::Overlap {
1041 field: "commits".into(),
1042 min: CO_CHANGE_MIN,
1043 };
1044 w.create_rule(RuleDef {
1045 name: "co_changed".into(),
1046 src_label: "File".into(),
1047 dst_label: "File".into(),
1048 predicate: co.clone(),
1049 edge_type: "CO_CHANGED".into(),
1050 weight_prop: Some("score".into()),
1051 max_edges: Some(10),
1052 approximate: false,
1053 via_label: None,
1054 via_edge: None,
1055 via_dir: None,
1056 })?;
1057 w.create_rule(RuleDef {
1058 name: "knows".into(),
1059 src_label: "Author".into(),
1060 dst_label: "File".into(),
1061 predicate: co,
1062 edge_type: "KNOWS".into(),
1063 weight_prop: Some("score".into()),
1064 max_edges: Some(20),
1065 approximate: false,
1066 via_label: Some("File".into()),
1067 via_edge: Some("TOP_AUTHOR".into()),
1068 via_dir: Some(Direction::In),
1069 })?;
1070 report
1071 .rules_created
1072 .extend(["co_changed".to_string(), "knows".to_string()]);
1073 for (l, field) in [("File", "path"), ("Commit", "message"), ("Author", "name")] {
1074 if !w
1075 .fulltext_pairs()
1076 .contains(&(l.to_string(), field.to_string()))
1077 {
1078 w.enable_fulltext(l, field)?;
1079 }
1080 }
1081 }
1082
1083 if opts.structure {
1087 let full = !report.incremental || pending.iter().any(|p| p.stale);
1091 report.structure = if full {
1092 structure::refresh_all(&mut w, &repo, "", opts.docs)?
1093 } else {
1094 let mut paths = work.touched;
1095 paths.extend(structure::importers_of(&w, &work.stale)?);
1096 let paths: Vec<String> = paths.into_iter().collect();
1097 structure::refresh_files(&mut w, &repo, "", &paths, opts.docs)?
1098 };
1099 report
1100 .rules_created
1101 .extend(structure::ensure_rules_and_fulltext(&mut w)?);
1102 }
1103
1104 if !prs.is_empty() {
1106 link_prs(&mut w, &prs, &ingest)?;
1107 }
1108
1109 for p in &pending {
1118 write_marker(&mut w, p, opts)?;
1119 }
1120 Ok(report)
1121}
1122
1123fn now_unix() -> i64 {
1128 std::time::SystemTime::now()
1129 .duration_since(std::time::UNIX_EPOCH)
1130 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
1131}
1132
1133pub const SYNCED_AT: &str = "synced_at";
1140
1141fn write_marker(w: &mut WriteGuard<'_>, p: &Pending, opts: &IngestGitOpts) -> Result<(), CliError> {
1152 let Some(head) = p.head.as_deref() else {
1153 return Ok(()); };
1155 let mut props = marker_flag_props(&p.unit, opts);
1156 if !p.log.is_empty() {
1157 props.push(("sha".into(), Value::Str(head.to_string())));
1158 }
1159 let key = p.unit.sync_key.clone();
1160 if w.has_node(&key) {
1161 let mut changed = false;
1162 for (k, v) in props {
1163 let current = w.node_ref(&key).and_then(|n| n.prop(&k));
1164 if current.as_ref() != Some(&v) {
1165 w.set_prop(&key, &k, v)?;
1166 changed = true;
1167 }
1168 }
1169 if changed {
1170 w.set_prop(&key, SYNCED_AT, Value::Int(now_unix()))?;
1171 }
1172 return Ok(());
1173 }
1174 if p.log.is_empty() {
1175 return Ok(());
1176 }
1177 props.push(("id".into(), Value::Str(key.clone())));
1180 props.push((SYNCED_AT.into(), Value::Int(now_unix())));
1181 props.sort_by(|a, b| a.0.cmp(&b.0));
1182 w.insert_node("GitSync", &key, props)?;
1183 Ok(())
1184}
1185
1186fn ingest_unit(
1192 w: &mut WriteGuard<'_>,
1193 p: &Pending,
1194 opts: &IngestGitOpts,
1195 ingest: &IngestOptions,
1196 report: &mut IngestGitReport,
1197 authors: &mut BTreeSet<String>,
1198 work: &mut StructureWork,
1199) -> Result<(), CliError> {
1200 let log = &p.log;
1201 let incremental = p.since.is_some();
1202
1203 let mut walk = Walk {
1204 files: if incremental {
1205 let params =
1206 BTreeMap::from([("prefix".to_string(), Value::Str(p.unit.prefix.clone()))]);
1207 file_state_from(&w.query(FILE_STATE_QUERY, ¶ms)?, &p.nested)
1208 } else {
1209 BTreeMap::new()
1210 },
1211 ..Default::default()
1212 };
1213
1214 for c in log {
1215 walk.authors
1216 .entry(c.author_email.clone())
1217 .or_insert_with(|| c.author_name.clone());
1218 walk.commit_rows.push(BTreeMap::from([
1219 ("id".to_string(), Value::Str(c.sha.clone())),
1220 ("message".to_string(), Value::Str(c.subject.clone())),
1221 ("ts".to_string(), Value::Int(c.ts)),
1222 ("author_id".to_string(), Value::Str(c.author_email.clone())),
1223 ]));
1224 for ch in &c.changes {
1225 match ch {
1226 Change::Added(p) | Change::Modified(p) => {
1227 if excluded(p, &opts.exclude) {
1228 continue;
1229 }
1230 walk.deleted.remove(p);
1231 walk.files.entry(p.clone()).or_default().touch(
1232 &c.sha,
1233 &c.author_email,
1234 opts.max_commits_per_file,
1235 );
1236 walk.dirty.insert(p.clone());
1237 walk.touched_edges
1238 .push(("TOUCHED".into(), c.sha.clone(), p.clone()));
1239 }
1240 Change::Deleted(p) => {
1241 if excluded(p, &opts.exclude) {
1242 continue;
1243 }
1244 walk.files.remove(p);
1245 walk.dirty.remove(p);
1246 walk.deleted.insert(p.clone());
1247 }
1248 Change::Renamed { from, to } => {
1249 if excluded(to, &opts.exclude) {
1250 walk.files.remove(from);
1253 walk.dirty.remove(from);
1254 walk.deleted.insert(from.clone());
1255 continue;
1256 }
1257 let mut st = walk.files.remove(from).unwrap_or_default();
1258 st.touch(&c.sha, &c.author_email, opts.max_commits_per_file);
1259 walk.files.insert(to.clone(), st);
1260 walk.dirty.remove(from);
1261 walk.dirty.insert(to.clone());
1262 walk.deleted.remove(to);
1263 walk.touched_edges
1264 .push(("TOUCHED".into(), c.sha.clone(), to.clone()));
1265 let exists = incremental && w.has_node(from);
1266 walk.rename(from, to, exists);
1267 }
1268 }
1269 }
1270 }
1271
1272 work.touched.extend(walk.dirty.iter().cloned());
1276 for key in walk.deleted.iter().chain(walk.alias.keys()) {
1277 if !walk.files.contains_key(key) {
1278 work.stale.insert(key.clone());
1279 }
1280 }
1281
1282 let author_rows: Vec<BTreeMap<String, Value>> = walk
1285 .authors
1286 .iter()
1287 .filter(|(email, _)| !w.has_node(email))
1288 .map(|(email, name)| {
1289 BTreeMap::from([
1290 ("id".to_string(), Value::Str(email.clone())),
1291 ("name".to_string(), Value::Str(name.clone())),
1292 ])
1293 })
1294 .collect();
1295 let a = w.ingest_with_edges("Author", author_rows, ingest, &[])?;
1296 report.rules_created.extend(a.rules_created);
1297 authors.extend(walk.authors.keys().cloned());
1298
1299 for p in &walk.deleted {
1304 if w.has_node(p) {
1305 w.delete_node(p)?;
1306 report.deleted += 1;
1307 }
1308 }
1309 for (from, to) in &walk.renamed {
1310 if !w.has_node(from) || from == to {
1311 continue;
1313 }
1314 if !walk.files.contains_key(to) {
1315 w.delete_node(from)?;
1320 report.deleted += 1;
1321 continue;
1322 }
1323 if w.has_node(to) {
1324 w.delete_node(to)?;
1327 report.deleted += 1;
1328 }
1329 w.rename_node(from, to)?;
1330 w.set_prop(to, "id", Value::Str(to.clone()))?;
1334 report.renamed += 1;
1335 }
1336
1337 let mut new_file_rows = Vec::new();
1348 let mut updates = Vec::new();
1349 let mut written = 0usize;
1350 for (path, st) in &walk.files {
1351 if incremental && !walk.dirty.contains(path) {
1352 continue;
1353 }
1354 written += 1;
1355 let props = file_props(st, path);
1356 if w.has_node(path) {
1357 updates.extend(props.into_iter().map(|(k, v)| (path.clone(), k, v)));
1358 } else {
1359 new_file_rows.push(props.into_iter().collect::<BTreeMap<_, _>>());
1360 }
1361 }
1362 if !updates.is_empty() {
1363 let mut b = w.batch();
1364 for (key, field, value) in updates {
1365 b.set_prop(&key, &field, value);
1366 }
1367 b.commit()?;
1368 }
1369 let f = w.ingest_with_edges("File", new_file_rows, ingest, &[])?;
1370 report.rules_created.extend(f.rules_created);
1371 report.files += written;
1375
1376 let c = w.ingest_with_edges("Commit", walk.commit_rows, ingest, &[])?;
1383 report.rules_created.extend(c.rules_created);
1384 report.commits += log.len();
1385
1386 let touched: Vec<(String, String, String)> = walk
1389 .touched_edges
1390 .into_iter()
1391 .map(|(t, sha, p)| {
1392 let p = walk.alias.get(&p).cloned().unwrap_or(p);
1393 (t, sha, p)
1394 })
1395 .filter(|(_, _, p)| walk.files.contains_key(p))
1396 .collect();
1397 if !touched.is_empty() {
1398 w.ingest_with_edges("Commit", Vec::new(), ingest, &touched)?;
1399 }
1400 Ok(())
1401}
1402
1403const NO_MARKER: &str = "store has no git sync marker; run ingest-git first";
1414
1415#[derive(Debug, Clone, PartialEq, Eq)]
1423struct SyncMarker {
1424 repo: PathBuf,
1425 recurse: bool,
1426 prs: bool,
1427 structure: bool,
1428 docs: bool,
1429}
1430
1431impl SyncMarker {
1432 fn opts(&self) -> IngestGitOpts {
1433 IngestGitOpts {
1434 repo: self.repo.clone(),
1435 exclude: DEFAULT_EXCLUDES.iter().map(|p| (*p).to_string()).collect(),
1436 max_commits_per_file: DEFAULT_MAX_COMMITS_PER_FILE,
1437 recurse_submodules: self.recurse,
1438 prs: self.prs,
1439 structure: self.structure,
1440 docs: self.docs,
1441 ensure_gitignore: false,
1442 }
1443 }
1444}
1445
1446fn marker_of(r: &structure::Db) -> Result<SyncMarker, CliError> {
1454 let node = r
1455 .node_ref(SYNC_KEY)
1456 .ok_or_else(|| CliError(NO_MARKER.into()))?;
1457 let flag = |name: &str| matches!(node.prop(name), Some(Value::Bool(true)));
1458 match node.prop("repo") {
1459 Some(Value::Str(repo)) if !repo.is_empty() => Ok(SyncMarker {
1460 repo: PathBuf::from(repo),
1461 recurse: flag("recurse"),
1462 prs: flag("prs"),
1463 structure: node.prop("structure") != Some(Value::Bool(false)),
1466 docs: node.prop("docs") != Some(Value::Bool(false)),
1467 }),
1468 _ => Err(CliError(NO_MARKER.into())),
1469 }
1470}
1471
1472fn open_existing(db_dir: &Path) -> Result<SharedDb, CliError> {
1480 if !db_dir.exists() {
1481 return Err(CliError(format!(
1482 "no database directory at {}",
1483 db_dir.display()
1484 )));
1485 }
1486 Ok(SharedDb::open(db_dir)?)
1487}
1488
1489#[derive(Debug, Default, Clone, PartialEq, Eq)]
1491pub struct SyncReport {
1492 pub git: IngestGitReport,
1494 pub structure: crate::structure::StructureReport,
1497 pub dirty_refreshed: usize,
1500}
1501
1502fn dirty_paths(repo: &Path, exclude: &[String]) -> Result<Vec<String>, CliError> {
1508 const LISTS: [&[&str]; 2] = [
1509 &["diff", "--name-only", "-z", "HEAD"],
1510 &["ls-files", "--others", "--exclude-standard", "-z"],
1511 ];
1512 let mut out = BTreeSet::new();
1513 for args in LISTS {
1514 let o = git_output(repo, args)?;
1515 if !o.status.success() {
1516 continue;
1519 }
1520 for path in String::from_utf8_lossy(&o.stdout).split('\0') {
1521 if path.is_empty() || excluded(path, exclude) {
1522 continue;
1523 }
1524 out.insert(path.to_string());
1525 }
1526 }
1527 Ok(out.into_iter().collect())
1528}
1529
1530pub fn run_sync(db_dir: &Path) -> Result<SyncReport, CliError> {
1539 let db = open_existing(db_dir)?;
1544 let marker = marker_of(&db.read())?;
1545 let opts = marker.opts();
1546 let mut report = SyncReport {
1547 git: run_ingest_git(db_dir, &opts)?,
1548 ..Default::default()
1549 };
1550 if !opts.structure {
1551 return Ok(report);
1552 }
1553
1554 let repo = canonical(&marker.repo);
1558 let paths = dirty_paths(&repo, &opts.exclude)?;
1559 report.dirty_refreshed = paths.len();
1560 if paths.is_empty() {
1561 return Ok(report);
1564 }
1565
1566 let mut w = db.write_with_wait(WRITE_LOCK_WAIT).map_err(|e| match e {
1567 GraphError::Busy { .. } => CliError(BUSY_MESSAGE.to_string()),
1568 other => CliError(other.to_string()),
1569 })?;
1570 report.structure = structure::refresh_files(&mut w, &repo, "", &paths, opts.docs)?;
1571 Ok(report)
1572}
1573
1574pub fn format_touch(r: &structure::StructureReport) -> String {
1575 format!(
1576 "touch: {} file(s), {} symbol(s), {} import(s), {} call(s), {} mention(s)\n",
1577 r.files_scanned, r.symbols, r.imports, r.calls, r.mentions
1578 )
1579}
1580
1581#[must_use]
1588pub fn format_sync_json(r: &SyncReport) -> String {
1589 let g = &r.git;
1590 let s = &r.structure;
1591 let gs = &g.structure;
1592 let value = serde_json::json!({
1593 "text": format_sync(r),
1594 "commits": g.commits,
1595 "files": g.files,
1596 "authors": g.authors,
1597 "renamed": g.renamed,
1598 "deleted": g.deleted,
1599 "incremental": g.incremental,
1600 "submodules": g.submodules,
1601 "prs": g.prs,
1602 "rules_created": g.rules_created,
1603 "scanned": {
1604 "files": gs.files_scanned,
1605 "symbols": gs.symbols,
1606 "imports": gs.imports,
1607 "calls": gs.calls,
1608 "mentions": gs.mentions,
1609 },
1610 "dirty_refreshed": r.dirty_refreshed,
1611 "dirty": {
1612 "files": s.files_scanned,
1613 "symbols": s.symbols,
1614 "imports": s.imports,
1615 "calls": s.calls,
1616 "mentions": s.mentions,
1617 },
1618 });
1619 format!("{value}\n")
1620}
1621
1622pub fn format_sync(r: &SyncReport) -> String {
1623 let mut out = format_ingest_git(&r.git);
1624 let s = &r.structure;
1625 out.push_str(&format!(
1626 " dirty {} path(s): scanned {}, {} symbol(s), {} import(s), {} call(s)\n",
1627 r.dirty_refreshed, s.files_scanned, s.symbols, s.imports, s.calls
1628 ));
1629 out
1630}
1631
1632pub fn run_touch(
1643 db_dir: &Path,
1644 files: &[PathBuf],
1645 hook_stdin: Option<&str>,
1646) -> Result<structure::StructureReport, CliError> {
1647 let named: Vec<PathBuf> = if files.is_empty() {
1648 hook_stdin.map(paths_from_payload).unwrap_or_default()
1649 } else {
1650 files.to_vec()
1651 };
1652 if named.is_empty() {
1653 return Ok(structure::StructureReport::default());
1654 }
1655
1656 let db = open_existing(db_dir)?;
1657 let marker = marker_of(&db.read())?;
1658 if !marker.structure {
1659 return Ok(structure::StructureReport::default());
1662 }
1663 let repo = canonical(&marker.repo);
1664 let exclude: Vec<String> = DEFAULT_EXCLUDES.iter().map(|p| (*p).to_string()).collect();
1665 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1666 let mut paths: BTreeSet<String> = BTreeSet::new();
1667 for path in &named {
1668 let Some(rel) = repo_relative(&repo, &cwd, path) else {
1669 continue;
1670 };
1671 if !excluded(&rel, &exclude) {
1672 paths.insert(rel);
1673 }
1674 }
1675 if paths.is_empty() {
1676 return Ok(structure::StructureReport::default());
1679 }
1680
1681 let paths: Vec<String> = paths.into_iter().collect();
1682 let mut w = db.write_with_wait(WRITE_LOCK_WAIT).map_err(|e| match e {
1683 GraphError::Busy { .. } => CliError(BUSY_MESSAGE.to_string()),
1684 other => CliError(other.to_string()),
1685 })?;
1686 structure::refresh_files(&mut w, &repo, "", &paths, marker.docs)
1687}
1688
1689fn paths_from_payload(raw: &str) -> Vec<PathBuf> {
1696 let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) else {
1697 return Vec::new();
1698 };
1699 let input = &v["tool_input"];
1700 let mut out = Vec::new();
1701 let mut push = |value: &serde_json::Value| {
1702 if let Some(s) = value.as_str().map(str::trim).filter(|s| !s.is_empty()) {
1703 out.push(PathBuf::from(s));
1704 }
1705 };
1706 push(&input["file_path"]);
1707 if let Some(edits) = input["edits"].as_array() {
1708 for e in edits {
1709 push(&e["file_path"]);
1710 }
1711 }
1712 out
1713}
1714
1715fn repo_relative(repo: &Path, cwd: &Path, path: &Path) -> Option<String> {
1723 let absolute = if path.is_absolute() {
1724 path.to_path_buf()
1725 } else {
1726 cwd.join(path)
1727 };
1728 let resolved = resolve_symlinks(&absolute);
1729 let rel = resolved.strip_prefix(repo).ok()?;
1730 let key: Vec<String> = rel
1731 .components()
1732 .map(|c| c.as_os_str().to_string_lossy().into_owned())
1733 .collect();
1734 let key = key.join("/");
1735 (!key.is_empty()).then_some(key)
1736}
1737
1738fn resolve_symlinks(p: &Path) -> PathBuf {
1741 if let Ok(resolved) = std::fs::canonicalize(p) {
1742 return resolved;
1743 }
1744 match (p.parent(), p.file_name()) {
1745 (Some(dir), Some(name)) => std::fs::canonicalize(dir)
1746 .map(|d| d.join(name))
1747 .unwrap_or_else(|_| p.to_path_buf()),
1748 _ => p.to_path_buf(),
1749 }
1750}
1751
1752pub fn format_ingest_git(r: &IngestGitReport) -> String {
1753 let mut out = format!(
1754 "ingest-git: {} commit(s), {} file(s), {} author(s){}\n",
1755 r.commits,
1756 r.files,
1757 r.authors,
1758 if r.incremental { " (incremental)" } else { "" }
1759 );
1760 if r.renamed + r.deleted > 0 {
1761 out.push_str(&format!(" renamed {} deleted {}\n", r.renamed, r.deleted));
1762 }
1763 if r.submodules + r.prs > 0 {
1764 out.push_str(&format!(
1765 " submodules {} pull requests {}\n",
1766 r.submodules, r.prs
1767 ));
1768 }
1769 let s = &r.structure;
1770 if s.files_scanned > 0 {
1771 out.push_str(&format!(
1772 " scanned {} file(s): {} symbol(s), {} import(s), {} call(s), {} mention(s)\n",
1773 s.files_scanned, s.symbols, s.imports, s.calls, s.mentions
1774 ));
1775 if s.skipped_large + s.symbols_capped > 0 {
1776 out.push_str(&format!(
1777 " hash-only {} symbol cap hit on {}\n",
1778 s.skipped_large, s.symbols_capped
1779 ));
1780 }
1781 }
1782 if r.gitignore_added {
1783 out.push_str(" added the database directory to .gitignore\n");
1784 }
1785 if !r.rules_created.is_empty() {
1786 out.push_str(&format!(" rules: {}\n", r.rules_created.join(", ")));
1787 }
1788 out
1789}
1790
1791#[cfg(test)]
1792mod tests {
1793 use super::*;
1794
1795 #[test]
1796 fn exclude_matches_prefix_extension_and_substring() {
1797 let pats = vec![
1798 "target/".to_string(),
1799 "*.lock".into(),
1800 "node_modules".into(),
1801 ];
1802 assert!(excluded("target/debug/foo.rs", &pats));
1803 assert!(
1804 !excluded("targeted/foo.rs", &pats),
1805 "prefix needs the slash"
1806 );
1807 assert!(excluded("Cargo.lock", &pats));
1808 assert!(!excluded("Cargo.toml", &pats));
1809 assert!(excluded("ui/node_modules/x/y.js", &pats));
1810 assert!(!excluded("src/lib.rs", &pats));
1811 assert!(!excluded("anything", &[]));
1812 }
1813
1814 #[test]
1819 fn a_compound_suffix_pattern_matches() {
1820 let defaults: Vec<String> = DEFAULT_EXCLUDES.iter().map(|p| (*p).to_string()).collect();
1821 assert!(excluded("ui/build/bundle.min.js", &defaults));
1823 assert!(excluded("bundle.min.js", &defaults));
1824 assert!(
1825 !excluded("ui/src/app.js", &defaults),
1826 "an ordinary source file is not a bundle"
1827 );
1828 assert!(!excluded("ui/src/minify.js", &defaults));
1829 assert!(excluded("Cargo.lock", &defaults));
1832 assert!(!excluded(".lock", &defaults));
1833 assert!(!excluded("src/lib.rs", &defaults));
1834 }
1835
1836 #[test]
1837 fn file_props_split_dir_and_ext() {
1838 let mut st = FileState::default();
1839 st.touch("sha1", "a@x.test", 200);
1840 let m: BTreeMap<_, _> = file_props(&st, "src/a/b.rs").into_iter().collect();
1841 assert_eq!(m["dir"], Value::Str("src/a".into()));
1842 assert_eq!(m["ext"], Value::Str("rs".into()));
1843 assert_eq!(m["n_commits"], Value::Int(1));
1844 assert_eq!(m["id"], Value::Str("src/a/b.rs".into()));
1845 assert_eq!(m["top_author_id"], Value::Str("a@x.test".into()));
1846 assert_eq!(
1847 m["author_counts"],
1848 Value::List(vec![Value::Str("a@x.test\t1".into())])
1849 );
1850 let m: BTreeMap<_, _> = file_props(&FileState::default(), "README")
1851 .into_iter()
1852 .collect();
1853 assert_eq!(m["dir"], Value::Str(String::new()));
1854 assert_eq!(m["ext"], Value::Str(String::new()));
1855 }
1856
1857 #[test]
1861 fn author_counts_round_trip_preserves_the_distribution() {
1862 let mut st = FileState::default();
1863 for _ in 0..3 {
1864 st.touch("s", "alice@x.test", 200);
1865 }
1866 for _ in 0..4 {
1867 st.touch("s", "bob@x.test", 200);
1868 }
1869 let Value::List(encoded) = st.author_counts_value() else {
1870 panic!("author_counts must be a list");
1871 };
1872 assert_eq!(
1873 encoded,
1874 vec![
1875 Value::Str("alice@x.test\t3".into()),
1876 Value::Str("bob@x.test\t4".into()),
1877 ],
1878 "email order, so the prop is stable across runs"
1879 );
1880 let mut reloaded = FileState::default();
1881 reloaded.set_author_counts(&encoded);
1882 assert_eq!(reloaded.by_author, st.by_author);
1883 assert_eq!(reloaded.top_author(), "bob@x.test");
1884 }
1885
1886 #[test]
1889 fn author_counts_skips_entries_it_cannot_parse() {
1890 let mut st = FileState::default();
1891 st.set_author_counts(&[
1892 Value::Str("alice@x.test\t2".into()),
1893 Value::Str("no-tab-here".into()),
1894 Value::Str("bob@x.test\tnotanumber".into()),
1895 Value::Str("\t5".into()),
1896 Value::Int(7),
1897 ]);
1898 assert_eq!(st.by_author, BTreeMap::from([("alice@x.test".into(), 2)]));
1899 }
1900
1901 #[test]
1902 fn commits_list_is_capped_and_top_author_is_deterministic() {
1903 let mut st = FileState::default();
1904 for i in 0..5 {
1905 st.touch(&format!("sha{i}"), "b@x.test", 3);
1906 }
1907 st.touch("shaX", "a@x.test", 3);
1908 assert_eq!(st.commits, vec!["sha3", "sha4", "shaX"]);
1909 assert_eq!(st.n_commits, 6);
1910 assert_eq!(st.top_author(), "b@x.test");
1911
1912 let m: BTreeMap<_, _> = file_props(&st, "src/hot.rs").into_iter().collect();
1917 assert_eq!(m["n_commits"], Value::Int(6));
1918 let Value::List(commits) = &m["commits"] else {
1919 panic!("commits must be a list");
1920 };
1921 assert_eq!(commits.len(), 3, "the list is still capped at 3");
1922 assert!(
1923 matches!(m["n_commits"], Value::Int(n) if n as usize > commits.len()),
1924 "n_commits must exceed the capped list once the cap is passed"
1925 );
1926 assert_eq!(
1927 m["author_counts"],
1928 Value::List(vec![
1929 Value::Str("a@x.test\t1".into()),
1930 Value::Str("b@x.test\t5".into()),
1931 ]),
1932 "the per-author counts sum to n_commits, not to the capped list"
1933 );
1934
1935 let mut tie = FileState::default();
1936 tie.touch("s", "b@x.test", 10);
1937 tie.touch("s", "a@x.test", 10);
1938 assert_eq!(tie.top_author(), "a@x.test", "ties break on smallest email");
1939 }
1940}