Skip to main content

cli/
ingest_git.rs

1//! `mushroomdb ingest-git <db> <repo>`: build and maintain a graph of a git
2//! repository. First run ingests the whole history; later runs apply only the
3//! commits after the recorded `GitSync` head, so deletes and renames retract
4//! or move derived edges instead of leaving them stale.
5//!
6//! Graph shape:
7//!
8//! | Label | key (`id`) | props |
9//! |---|---|---|
10//! | `Author` | mailmap-resolved email | `name` |
11//! | `Commit` | full sha | `message`, `ts`, `author_id`, `pr_id` (with `--prs`) |
12//! | `File` | path | `path`, `dir`, `ext`, `commits`, `n_commits`, `top_author_id`, `author_counts`, plus the working-tree props in [`structure`](crate::structure) |
13//! | `Symbol` | `"<path>#<qualified name>"` | see [`structure`](crate::structure) |
14//! | `PR` | `"pr:<number>"` | `number`, `title`, `url`, `merged_at`, `author_login` |
15//! | `GitSync` | `"__mushroomdb_git_sync__"` | `sha`, `synced_at`, `repo`, `recurse`, `prs`, `structure`, `docs` |
16//!
17//! Edges: user `TOUCHED` Commit→File and `MERGED_AS` PR→Commit, auto-FK
18//! `AUTHOR` Commit→Author, `TOP_AUTHOR` File→Author and `PR` Commit→PR,
19//! rule-derived `CO_CHANGED` File→File and `KNOWS` Author→File — and, from the
20//! working-tree pass, `DEFINES`, `IMPORTS`, `CALLS` and `MENTIONS`.
21//!
22//! With `--recurse-submodules` each initialised submodule is walked as its own
23//! *unit*: its file keys carry the submodule's path in the parent, and it
24//! resumes from its own `GitSync` marker.
25use 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
35/// Cap on the `commits` list stored per file. Bounds both node size and the
36/// cost of the jaccard overlap the `co_changed` rule runs over that list.
37pub const DEFAULT_MAX_COMMITS_PER_FILE: usize = 200;
38
39/// Applied when the user names no `--exclude` pattern of their own.
40///
41/// Defined in core-api because the `impact` MCP tool builds its default file
42/// list straight off a working tree and has to leave out exactly what this
43/// ingest leaves out; two lists would let the tool ask about files no store was
44/// ever going to hold.
45pub use core_api::repograph::DEFAULT_EXCLUDES;
46
47/// Minimum jaccard overlap of two files' `commits` lists for `CO_CHANGED`.
48const CO_CHANGE_MIN: f64 = 0.25;
49
50/// Key of the singleton `GitSync` node holding the last ingested sha.
51///
52/// Node keys are a single namespace shared with `File` keys, which are repo
53/// paths — so this cannot be `"HEAD"`. A repository with a file named `HEAD`
54/// (git's own `.git/HEAD` aside, plenty of projects ship one) would otherwise
55/// have the sha written onto its `File` node, leaving no sync marker and
56/// forcing a full re-ingest on every run.
57pub(crate) const SYNC_KEY: &str = "__mushroomdb_git_sync__";
58
59/// What the CLI prints, and exits 3 on, when another process holds the store's
60/// cross-process write lock. Nothing was written, so retrying is always safe.
61pub const BUSY_MESSAGE: &str = "another mushroomdb process is writing; retry";
62
63/// Name of the `Commit.pr_id` foreign-key rule. Identical to the name the
64/// zero-config FK inference would choose, so the two never both create it.
65const PR_FK_RULE: &str = "auto_fk_commit_pr_id";
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct IngestGitOpts {
69    pub repo: PathBuf,
70    /// Paths to skip. Pattern ending in `/` = path prefix, pattern starting
71    /// with `*.` = extension, otherwise = substring of the path.
72    pub exclude: Vec<String>,
73    pub max_commits_per_file: usize,
74    /// Walk every initialised submodule as its own unit.
75    pub recurse_submodules: bool,
76    /// Ask `gh` for merged pull requests and link them to their commits.
77    pub prs: bool,
78    /// Read the working tree: content hashes, `Symbol` nodes, imports and
79    /// calls. Off with `--no-structure`. Recorded on the `GitSync` node.
80    pub structure: bool,
81    /// Index Markdown bodies, headings and mentions. Off with `--no-docs`, and
82    /// inert without `structure`. Recorded on the `GitSync` node.
83    pub docs: bool,
84    /// Add the database directory to the repository's `.gitignore`.
85    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    /// Submodules walked as their own units.
98    pub submodules: usize,
99    /// Pull requests inserted by this run.
100    pub prs: usize,
101    /// Whether this run appended the database directory to `.gitignore`.
102    pub gitignore_added: bool,
103    /// What the working-tree pass saw. All zeros with `--no-structure`.
104    pub structure: crate::structure::StructureReport,
105}
106
107/// Paths the commit walk left for the working-tree pass to look at.
108#[derive(Default)]
109struct StructureWork {
110    /// Files added, modified, or renamed *into* this window's paths.
111    touched: BTreeSet<String>,
112    /// Keys that no longer name a file: renamed away, or deleted. Any file
113    /// whose `imports` or `mentions` list still holds one of these has to be
114    /// extracted again, or the edge it derived stays behind.
115    stale: BTreeSet<String>,
116}
117
118/// One git working tree walked by a run: the repository itself, or one of its
119/// submodules.
120///
121/// A submodule's paths are keys under `prefix` (its path in the parent), and it
122/// carries its own sync marker, so the two histories advance independently.
123#[derive(Debug, Clone)]
124struct RepoUnit {
125    path: PathBuf,
126    /// `""` for the repository itself, `"<displaypath>/"` for a submodule.
127    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
149/// Simple, dependency-free path matcher. Documented in `docs/site/ingest-git.md`.
150///
151/// The matcher itself is `core_api::repograph::path_excluded`, next to
152/// [`DEFAULT_EXCLUDES`], because the `impact` MCP tool filters a working tree
153/// with the same patterns and must read them the same way.
154fn 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
167/// `git log --reverse --name-status -M --format=<RS>%H<US>%aN<US>%aE<US>%at<US>%s <range>`
168///
169/// Returns oldest commit first. The walk ends at `head` — never at the symbolic
170/// `HEAD` — so the range is pinned to the same sha the caller will record as the
171/// sync marker. See [`head_sha`] for why that matters.
172///
173/// `%aN` and `%aE` are the mailmap-resolved name and address, so a repository
174/// with a `.mailmap` reports one identity for a contributor who has committed
175/// under several addresses. Without one they are exactly `%an`/`%ae`.
176fn 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        // Without this git renders any non-ASCII byte in a path as an octal
193        // escape, so `src/café.rs` would be stored under a mangled key that no
194        // later run matches. Paths containing a tab or newline stay quoted and
195        // escaped either way — git has to, or they would break the format below.
196        "-c",
197        "core.quotePath=false",
198        "log",
199        "--reverse",
200        "--name-status",
201        "-M",
202        "--no-color",
203        // Record separator \x1e between commits, unit separator \x1f between
204        // header fields. A commit subject containing either byte splits its own
205        // record: the message is truncated at the first \x1f, and a \x1e drops
206        // the remainder of that commit's header. Accepted — the parse degrades
207        // to a skipped or shortened message, never a panic or a wrong sha.
208        "--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                // A copy is a brand-new path with no prior history here.
246                [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
264/// Resolve `HEAD` to a concrete sha. `Ok(None)` means the repository has no
265/// commits yet; a path that is not a repository at all is an error.
266///
267/// Called **before** [`read_log`], and the resulting sha is both the end of the
268/// walk and the recorded sync marker. Resolving it afterwards instead would open
269/// a window: a commit landing between the walk and the `rev-parse` would push
270/// the marker past a commit that was never ingested, and every later run would
271/// skip it silently. Pinning both to one sha closes that window — a commit that
272/// lands mid-run is simply outside this range and gets picked up next time.
273///
274/// Asking git also beats taking `log.last()`. The two agree in practice, since a
275/// reachability walk emits its tip first and reversing puts it last, but that is
276/// a property of the traversal and of this module's parser rather than something
277/// the resume marker should rest on.
278fn 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        // No commits yet, or not a repository at all — only the latter is an error.
282        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
303/// Absolute, symlink-free form of `p`, falling back to `p` when it cannot be
304/// resolved (a path that does not exist yet, or a permission error). The result
305/// is what `GitSync.repo` records, so a later run can find the repository again
306/// from any working directory.
307fn canonical(p: &Path) -> PathBuf {
308    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
309}
310
311/// The submodule paths recorded in a unit's `.gitmodules`, relative to that
312/// unit.
313///
314/// These are the paths git reports as ordinary changes in `--name-status`
315/// output while being gitlinks — a commit pointer, not a file. They get no
316/// `File` node whether or not the submodule is walked, and the list is read
317/// from configuration so it is the same for an uninitialised submodule.
318fn 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(); // no .gitmodules, so no submodules
328    }
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
337/// The repository itself, then one unit per initialised submodule when
338/// `recurse` is set.
339///
340/// `git submodule foreach` visits only initialised submodules and says nothing
341/// about the rest, which is the behaviour wanted here: a submodule that was
342/// never checked out has no working tree to walk. `$displaypath` is relative to
343/// the top-level repository, so it is exactly the key prefix its files need.
344fn 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        // `foreach` already skips the uninitialised, but a stale entry or a
376        // removed checkout would otherwise fail the whole run.
377        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
392/// Rewrite one unit's changes into repository-wide keys: drop gitlinks, then
393/// prepend the unit's prefix so a submodule's `src/lib.rs` is stored under
394/// `vendor/lib/src/lib.rs`.
395///
396/// Done before anything else looks at the log, so exclusion patterns, the
397/// stored keys and the `File` state query all speak the same path.
398fn 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
422/// Append `<db dir>/` to the repository's `.gitignore` unless it is already
423/// listed, creating the file if it does not exist. Returns whether it was
424/// written.
425///
426/// A database kept outside the repository is left alone: the repository has no
427/// business ignoring a path it does not contain.
428fn 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
464/// Separator inside an `author_counts` entry. An email address cannot contain a
465/// tab, so `email\tcount` round-trips unambiguously.
466const 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        // The true total, which past `--max-commits-per-file` is larger than
490        // the `commits` list it is stored beside. It is also what
491        // `author_counts` sums to, so the two props agree at any history
492        // length.
493        ("n_commits".into(), Value::Int(st.n_commits as i64)),
494        ("top_author_id".into(), Value::Str(st.top_author())),
495        // Written on every touch so the next incremental run can rebuild the
496        // distribution instead of crediting the whole history to the incumbent.
497        ("author_counts".into(), st.author_counts_value()),
498    ]
499}
500
501/// In-memory per-file state accumulated while walking commits.
502#[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    /// Most commits wins; ties break on the lexicographically smallest email
520    /// so the result is deterministic across runs.
521    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    /// The per-author distribution as a `File.author_counts` prop: a list of
530    /// `"email\tcount"` strings in email order.
531    ///
532    /// This is the state an incremental run needs and cannot recompute — the
533    /// walk only sees the new window, and `top_author_id` alone cannot say how
534    /// far ahead the incumbent is. Without it a challenger's commits reset on
535    /// every sync and ownership can never change.
536    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    /// Inverse of [`FileState::author_counts_value`]. Entries that are not
546    /// `email<TAB>count` are skipped rather than failing the run.
547    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/// One merged pull request as reported by `gh`.
562#[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    /// `mergeCommit.oid`, absent for a pull request merged some other way (or
570    /// whose merge commit has since been rewritten).
571    merge_sha: Option<String>,
572}
573
574fn pr_key(number: i64) -> String {
575    format!("pr:{number}")
576}
577
578/// The pull request number in a squash-merge subject: `\(#(\d+)\)$`.
579///
580/// Hand-rolled rather than pulled in as a dependency — the pattern is anchored
581/// at the end of the subject and made of two literals around a run of digits.
582fn 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
592/// `gh pr list --state merged` in `repo`, or an empty list with one warning.
593///
594/// Every failure is a skip: `gh` may not be installed, the repository may have
595/// no GitHub remote, and the user may not be authenticated. None of that is a
596/// reason to fail an ingest that is otherwise complete.
597fn 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    // Ascending by number: the insert order, and so the node order, is the
669    // same on every run whatever order gh listed them in.
670    prs.sort_by_key(|p| p.number);
671    prs.dedup_by_key(|p| p.number);
672    prs
673}
674
675/// Insert the `PR` nodes that are new, declare the `Commit.pr_id` foreign key,
676/// and index titles for search. Runs before the commits so the FK resolves.
677fn 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    // Declared here rather than left to FK inference, which only fires on a
706    // batch of `Commit` rows that already carry `pr_id` — a run that links a
707    // pull request to a commit ingested earlier would otherwise leave the
708    // property with no edge behind it.
709    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
738/// Point every commit that carries a pull request at it: the merge commit by
739/// sha, a squash merge by its `(#N)` subject. Runs after the commits are in the
740/// graph, so a pull request merged before the last sync is linked too.
741fn 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(); // by sha: the same store state writes the same records
780    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
804/// Cypher behind [`file_state_from`] — the cumulative state of every live
805/// `File` node belonging to one unit.
806///
807/// The prefix filter is what keeps a submodule's files out of the parent's
808/// walk and vice versa. `startsWith` is the documented Cypher form (see
809/// `docs/site/query.md`); the parent unit's empty prefix matches everything, so
810/// its own submodules' keys are dropped in [`file_state_from`].
811const 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
815/// Rebuild the in-memory per-file state from the `File` nodes already in the
816/// graph so incremental runs keep `commits` and ownership counts cumulative.
817///
818/// `nested` holds the key prefixes of any submodules inside this unit; their
819/// files belong to their own unit's walk and are dropped here.
820fn 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            // A node written before `author_counts` existed (a store built by
846            // 0.4.x). Fall back to the old approximation — the whole prior
847            // history credited to the incumbent — so those stores keep
848            // working. The prop is written on the next touch, from which point
849            // ownership tracks reality; a full re-ingest repairs it at once.
850            _ => {
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/// Accumulated effect of one log window, before anything is written.
862#[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    /// Paths whose `File` props changed in this window.
869    dirty: BTreeSet<String>,
870    deleted: BTreeSet<String>,
871    /// Node renames to apply, collapsed across chained renames.
872    renamed: Vec<(String, String)>,
873    /// Any old path → its final path in this window, for edge retargeting.
874    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            // A node cannot be both deleted and moved; the move wins.
883            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
897/// The `GitSync` props that say *how* a unit was ingested, as opposed to how
898/// far. Compared against the stored node so that changing a flag refreshes the
899/// marker even when there is nothing new to walk.
900fn 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
913/// One unit and everything read about it before the write pass opens.
914struct Pending {
915    unit: RepoUnit,
916    /// The sha its marker resumes from, absent on a first run.
917    since: Option<String>,
918    /// The stored marker disagrees with this run's flags.
919    stale: bool,
920    /// `None` when the unit has no commits at all.
921    head: Option<String>,
922    log: Vec<GitCommit>,
923    /// Key prefixes of the submodules nested inside this unit, whose files
924    /// belong to their own walk.
925    nested: Vec<String>,
926}
927
928pub fn run_ingest_git(db_dir: &Path, opts: &IngestGitOpts) -> Result<IngestGitReport, CliError> {
929    // Absolute and symlink-free: it is recorded on the marker, and a later run
930    // has no reason to share this one's working directory.
931    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    // The repository itself is always the first unit, and it is what "this
980    // database has seen this repo before" means.
981    report.incremental = pending[0].since.is_some();
982
983    for p in &mut pending {
984        // Pin the end of the walk and the marker to one sha, resolved first. A
985        // commit landing mid-run then falls outside this range instead of being
986        // skipped by a marker that advanced past it.
987        let Some(head) = head_sha(&p.unit.path)? else {
988            continue; // this unit has no commits yet
989        };
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        // Nothing new: leave the store untouched so `commit_seq` does not move.
1003        return Ok(report);
1004    }
1005
1006    // Held for the rest of the run. Another process holding the store's
1007    // cross-process write lock is a retry, not a failure: nothing was written.
1008    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(); // key `id`, auto-FK suffix `_id`
1013
1014    // Pull requests first: their nodes are what `Commit.pr_id` resolves to.
1015    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    // Rules and fulltext, created after the data so each backfills once. They
1037    // span every unit, so they are declared once for the database rather than
1038    // once per repository walked.
1039    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    // The working tree, on top of the history. It runs after the commit walk
1084    // so every `File` node it reads exists, and its rules are declared after
1085    // its props so each one backfills exactly once.
1086    if opts.structure {
1087        // A first run has nothing to be incremental against, and a run whose
1088        // flags changed (structure or docs just turned on) has to revisit
1089        // files its predecessor deliberately skipped.
1090        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    // Every commit this run could link is in the graph by now.
1105    if !prs.is_empty() {
1106        link_prs(&mut w, &prs, &ingest)?;
1107    }
1108
1109    // The markers go last, once every phase of the run has succeeded. They say
1110    // how far a *complete* run got, so a failure anywhere above — a working-tree
1111    // batch that will not commit, a `gh` link pass that errors — leaves them
1112    // where they were and the next run re-walks the same window rather than
1113    // stepping over it. Re-walking a window that was partly applied is safe:
1114    // commits already in the graph are skipped as duplicate keys, file props
1115    // are rewritten from the recomputed state, and a rename whose node already
1116    // moved finds nothing to move.
1117    for p in &pending {
1118        write_marker(&mut w, p, opts)?;
1119    }
1120    Ok(report)
1121}
1122
1123/// Wall-clock seconds since the Unix epoch, for [`SYNCED_AT`].
1124///
1125/// This is the one place the ingest reads a clock. A clock before the epoch
1126/// reads as `0` rather than going negative.
1127fn 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
1133/// Marker prop holding when this store last took data from the repository.
1134///
1135/// `Commit.ts` says when the work was *written*, which on a store synced to
1136/// its repository's head is indistinguishable from now — so it cannot answer
1137/// "how stale is my graph". This can. Absent on a store built before it
1138/// existed, which readers must tolerate.
1139pub const SYNCED_AT: &str = "synced_at";
1140
1141/// Record how far this unit got, and under what flags.
1142///
1143/// Props are written only where they differ, so a run that touches one unit
1144/// does not churn the markers of the others — and a run that changes nothing
1145/// writes nothing at all, [`SYNCED_AT`] included.
1146///
1147/// The stamp therefore means "when this run last changed this marker": a new
1148/// head, or a flag ingested differently from last time. A no-op re-run leaving
1149/// it alone is the point rather than a gap — a graph that had nothing to take
1150/// is not staler for having checked.
1151fn write_marker(w: &mut WriteGuard<'_>, p: &Pending, opts: &IngestGitOpts) -> Result<(), CliError> {
1152    let Some(head) = p.head.as_deref() else {
1153        return Ok(()); // no commits, so nothing to resume from
1154    };
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    // It carries `id` like every other label here, so the key is readable from
1178    // Cypher.
1179    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
1186/// Walk one unit's new commits into the graph.
1187///
1188/// Every path in `p.log` is already a repository-wide key, so this is the
1189/// single-repository algorithm unchanged: only the `File` state it starts from
1190/// and the counts it adds to are scoped to the unit.
1191fn 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, &params)?, &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                        // Moved out of scope: drop the old node, keep no alias
1251                        // so its TOUCHED edges are filtered out below.
1252                        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    // What the working-tree pass has to look at afterwards: the paths this
1273    // window changed, and the keys it left pointing at nothing. A key that
1274    // ended the window live again — a file moved away and back — is neither.
1275    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    // 1. Authors first: the auto-FK rules for `Commit.author_id` and
1283    //    `File.top_author_id` only infer once their targets resolve to Author.
1284    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    // 2. Deletes run first so a rename can claim a path freed in this same
1300    //    window, then renames carry each node (and its history) to its new path.
1301    //    `walk.files` is the authority on what is still live: a rename whose
1302    //    destination is not in it is a delete, not a move.
1303    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            // Nothing to move, or a rename that swapped back to its own path.
1312            continue;
1313        }
1314        if !walk.files.contains_key(to) {
1315            // The destination did not survive the window — it was deleted, or
1316            // moved into an excluded path, after this rename. The node goes
1317            // with it; renaming into a dead path would strand a phantom node
1318            // that no later phase refreshes.
1319            w.delete_node(from)?;
1320            report.deleted += 1;
1321            continue;
1322        }
1323        if w.has_node(to) {
1324            // A pre-existing node already holds the destination path (deleted
1325            // earlier in this window, then claimed by this rename).
1326            w.delete_node(to)?;
1327            report.deleted += 1;
1328        }
1329        w.rename_node(from, to)?;
1330        // The key moved, so the `id` prop must move with it. Phase 3 also sets
1331        // it for every dirty path; doing it here keeps the invariant local to
1332        // the rename and independent of that filter.
1333        w.set_prop(to, "id", Value::Str(to.clone()))?;
1334        report.renamed += 1;
1335    }
1336
1337    // 3. File nodes. Existing nodes are updated in place (including `id`, which
1338    //    must follow the key after a rename); new paths go through ingest so
1339    //    the `top_author_id` auto-FK rule is inferred.
1340    //    Updates to existing nodes go in one batch rather than one WAL commit
1341    //    per property. Every frame in the WAL is replayed on every later open,
1342    //    and each one re-fires the rules watching the property it carries, so a
1343    //    run that appends a frame per property makes every subsequent open
1344    //    slower for as long as that frame lives. The op order inside the batch
1345    //    is the order the individual writes had, so the resulting state is the
1346    //    same one either form produces.
1347    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    // What this run wrote, not every file it has ever seen: an incremental run
1372    // loads the whole known file set to fold the new commits into it, and
1373    // reporting that set made a one-file run print the size of the repository.
1374    report.files += written;
1375
1376    // 4. Commits, then their TOUCHED edges. The two must be separate batches:
1377    //    a batch that both inserts nodes firing a new rule and carries a user
1378    //    edge of a not-yet-interned type writes a WAL frame that cannot be
1379    //    replayed (`Intern` records are emitted in a pre-pass, but on replay the
1380    //    rule fires — and interns its edge type — before the later `Intern`
1381    //    record is read). See the report for a reproducer.
1382    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    // Edges name File keys, so files must already exist. A path renamed later
1387    // in this same window is retargeted to where its node ended up.
1388    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
1403// ── sync and touch ──────────────────────────────────────────────────────────
1404//
1405// `ingest-git` is the command a person runs. These two are what a *hook* runs:
1406// `sync` after a commit lands, `touch` after a single file is edited. Both read
1407// the repository out of the `GitSync` marker rather than taking it as an
1408// argument, so a hook line carries only the database path and keeps working
1409// when the checkout moves.
1410
1411/// What a store with no `GitSync` node is told. Naming the fix matters: this is
1412/// the error a hook installed against the wrong database prints.
1413const NO_MARKER: &str = "store has no git sync marker; run ingest-git first";
1414
1415/// The `GitSync` props that say how this store was built, read back so a later
1416/// `sync` repeats the same run without being told any of it again.
1417///
1418/// `exclude` and `max_commits_per_file` are not on the marker, so a `sync`
1419/// applies [`DEFAULT_EXCLUDES`] and [`DEFAULT_MAX_COMMITS_PER_FILE`]. A store
1420/// first built with custom `--exclude` patterns should keep being maintained
1421/// with `ingest-git`, which takes them.
1422#[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
1446/// Read the marker off an already-open handle.
1447///
1448/// Deliberately not "open the store and read the marker": opening this store is
1449/// by far the most expensive thing either command does — it replays the whole
1450/// WAL — so both of them open once and read the marker through that same
1451/// handle. A separate read-only open just to learn the repository path would
1452/// double the cost of every hook invocation.
1453fn 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            // A marker written before these two flags existed carries neither,
1464            // and a working-tree pass is what such a run did.
1465            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
1472/// Open a store that must already exist.
1473///
1474/// `SharedDb::open` runs `create_dir_all`, so without this guard a hook line
1475/// carrying a typo'd path would keep creating empty databases and reporting
1476/// that they hold no marker — the same trap [`run_recall`] guards against.
1477///
1478/// [`run_recall`]: crate::recall::run_recall
1479fn 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/// What one [`run_sync`] did.
1490#[derive(Debug, Default, Clone, PartialEq, Eq)]
1491pub struct SyncReport {
1492    /// The incremental history walk.
1493    pub git: IngestGitReport,
1494    /// The working-tree pass over the dirty paths, which the history walk does
1495    /// not see: an edit that has not been committed is in no commit.
1496    pub structure: crate::structure::StructureReport,
1497    /// Dirty paths handed to that pass. Higher than `structure.files_scanned`
1498    /// when some of them are new to the graph, or no longer on disk.
1499    pub dirty_refreshed: usize,
1500}
1501
1502/// Paths that differ from `HEAD` or are not tracked at all, repository-relative
1503/// and sorted.
1504///
1505/// `-z` rather than the default listing: git escapes and quotes a path holding
1506/// a tab, a newline or a non-ASCII byte, and a quoted path matches no key.
1507fn 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            // `diff HEAD` fails in a repository with no commits yet. Nothing is
1517            // dirty relative to a head that does not exist.
1518            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
1530/// Bring the store up to date with the repository it was built from: the
1531/// commits since the marker, then the working tree where it differs from
1532/// `HEAD`.
1533///
1534/// The second half is what a plain `ingest-git` cannot do. Its working-tree
1535/// pass only visits the paths the *commits* touched, so a file edited and not
1536/// yet committed keeps whatever the graph last recorded about it. A hook that
1537/// runs on every commit wants the uncommitted remainder refreshed too.
1538pub fn run_sync(db_dir: &Path) -> Result<SyncReport, CliError> {
1539    // One handle for the whole run. It is opened before `run_ingest_git`, which
1540    // opens its own and commits through it, but a `SharedDb` refreshes off the
1541    // WAL when a write scope is entered — so the dirty pass below sees every
1542    // commit the ingest just made without this handle being reopened.
1543    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    // Only the root repository's working tree. A submodule's dirty files are
1555    // its own checkout's business, and `--recurse-submodules` resumes each unit
1556    // from its own marker on the next commit there.
1557    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        // Nothing to refresh: never enter a write scope, so the run takes no
1562        // lock and `commit_seq` cannot move.
1563        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/// One [`SyncReport`] as a single JSON object, for `sync --json`.
1582///
1583/// `text` is exactly what [`format_sync`] prints, so a caller that has the
1584/// object never has to render the digest a second way and never has to parse
1585/// the counts back out of the prose. The MCP `sync` tool is that caller: it
1586/// runs this binary and hands both halves straight to the assistant.
1587#[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
1632/// Re-extract exactly the files named, and nothing else.
1633///
1634/// `files` comes from argv when a caller has the paths; otherwise they are read
1635/// out of a `PostToolUse` hook payload on stdin, the same way [`run_recall`]
1636/// reads a prompt. Anything that is not a working-tree file this store already
1637/// knows — a path outside the repository, an excluded one, one the graph has
1638/// never seen — is dropped without comment, because a hook fires on every edit
1639/// the assistant makes and most of them are none of this store's business.
1640///
1641/// [`run_recall`]: crate::recall::run_recall
1642pub 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        // The store was built with `--no-structure`, so it holds no working-tree
1660        // props at all and re-extracting one file would be the only exception.
1661        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        // Nothing of ours changed: never enter a write scope, so no lock is
1677        // taken and a running writer is never made to wait.
1678        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
1689/// The file paths in a `PostToolUse` payload.
1690///
1691/// `tool_input.file_path` covers Edit and Write; `tool_input.edits[].file_path`
1692/// covers the multi-edit shape. Both are read, so a payload carrying either (or
1693/// both) is handled without knowing which tool produced it. A payload that is
1694/// not JSON, or that names no file, yields nothing — never an error.
1695fn 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
1715/// `path` as a key under `repo`, or `None` when it is not inside it.
1716///
1717/// A hook payload carries absolute paths and a person typing the command uses
1718/// relative ones, so a relative path is taken against `cwd`. Both sides are
1719/// then resolved through symlinks before comparing: the marker records the
1720/// canonical repository path, and on macOS a checkout under `/tmp` is reached
1721/// through a symlink that never compares equal as written.
1722fn 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
1738/// [`canonical`] that still works for a path that no longer exists: a file
1739/// deleted between the edit and the hook resolves through its parent directory.
1740fn 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    /// A `*.` pattern is a file-name suffix, so a compound one works. Reading
1815    /// only the last dot segment would make `*.min.js` — a default — inert,
1816    /// and generated bundles are both the largest files in a tree and the ones
1817    /// least worth parsing.
1818    #[test]
1819    fn a_compound_suffix_pattern_matches() {
1820        let defaults: Vec<String> = DEFAULT_EXCLUDES.iter().map(|p| (*p).to_string()).collect();
1821        // Not under `dist/`, so only the suffix rule can match it.
1822        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        // The single-extension form is unchanged, and a bare suffix is not a
1830        // match: `*.lock` means something *dot* lock.
1831        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    /// The prop is the whole point of the incremental fix: it must survive a
1858    /// round trip so the next run resumes the real distribution, not the
1859    /// incumbent's total.
1860    #[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    /// Malformed entries are skipped, not fatal: the run degrades to the counts
1887    /// it can read rather than refusing to sync.
1888    #[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        // Past the cap the stored `n_commits` is the true total, not the length
1913        // of the truncated `commits` list, and `author_counts` sums to the same
1914        // number. A file over `--max-commits-per-file` would otherwise report a
1915        // history frozen at the cap.
1916        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}