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` | email | `name` |
11//! | `Commit` | full sha | `message`, `ts`, `author_id` |
12//! | `File` | path | `path`, `dir`, `ext`, `commits`, `n_commits`, `top_author_id`, `author_counts` |
13//! | `GitSync` | `"__mushroomdb_git_sync__"` | `sha` |
14//!
15//! Edges: user `TOUCHED` Commit→File, auto-FK `AUTHOR` Commit→Author and
16//! `TOP_AUTHOR` File→Author, rule-derived `CO_CHANGED` File→File and `KNOWS`
17//! Author→File.
18use crate::CliError;
19use core_api::{Direction, IngestOptions, Predicate, ResultSet, RuleDef, SharedDb, Value};
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24/// Cap on the `commits` list stored per file. Bounds both node size and the
25/// cost of the jaccard overlap the `co_changed` rule runs over that list.
26pub const DEFAULT_MAX_COMMITS_PER_FILE: usize = 200;
27
28/// Minimum jaccard overlap of two files' `commits` lists for `CO_CHANGED`.
29const CO_CHANGE_MIN: f64 = 0.25;
30
31/// Key of the singleton `GitSync` node holding the last ingested sha.
32///
33/// Node keys are a single namespace shared with `File` keys, which are repo
34/// paths — so this cannot be `"HEAD"`. A repository with a file named `HEAD`
35/// (git's own `.git/HEAD` aside, plenty of projects ship one) would otherwise
36/// have the sha written onto its `File` node, leaving no sync marker and
37/// forcing a full re-ingest on every run.
38const SYNC_KEY: &str = "__mushroomdb_git_sync__";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct IngestGitOpts {
42    pub repo: PathBuf,
43    /// Paths to skip. Pattern ending in `/` = path prefix, pattern starting
44    /// with `*.` = extension, otherwise = substring of the path.
45    pub exclude: Vec<String>,
46    pub max_commits_per_file: usize,
47}
48
49#[derive(Debug, Default, Clone, PartialEq, Eq)]
50pub struct IngestGitReport {
51    pub commits: usize,
52    pub files: usize,
53    pub authors: usize,
54    pub renamed: usize,
55    pub deleted: usize,
56    pub incremental: bool,
57    pub rules_created: Vec<String>,
58}
59
60#[derive(Debug)]
61enum Change {
62    Added(String),
63    Modified(String),
64    Deleted(String),
65    Renamed { from: String, to: String },
66}
67
68#[derive(Debug)]
69struct GitCommit {
70    sha: String,
71    author_name: String,
72    author_email: String,
73    ts: i64,
74    subject: String,
75    changes: Vec<Change>,
76}
77
78/// Simple, dependency-free path matcher. Documented in `docs/site/ingest-git.md`.
79fn excluded(path: &str, patterns: &[String]) -> bool {
80    patterns.iter().any(|p| {
81        if let Some(prefix) = p.strip_suffix('/') {
82            path.starts_with(&format!("{prefix}/"))
83        } else if let Some(ext) = p.strip_prefix("*.") {
84            path.contains('.') && path.rsplit('.').next() == Some(ext)
85        } else {
86            path.contains(p.as_str())
87        }
88    })
89}
90
91fn git_output(repo: &Path, args: &[&str]) -> Result<std::process::Output, CliError> {
92    Command::new("git")
93        .arg("-C")
94        .arg(repo)
95        .args(args)
96        .output()
97        .map_err(|e| CliError(format!("cannot run git in {}: {e}", repo.display())))
98}
99
100/// `git log --reverse --name-status -M --format=<RS>%H<US>%an<US>%ae<US>%at<US>%s <range>`
101///
102/// Returns oldest commit first. The walk ends at `head` — never at the symbolic
103/// `HEAD` — so the range is pinned to the same sha the caller will record as the
104/// sync marker. See [`head_sha`] for why that matters.
105fn read_log(repo: &Path, since: Option<&str>, head: &str) -> Result<Vec<GitCommit>, CliError> {
106    if let Some(s) = since {
107        let spec = format!("{s}^{{commit}}");
108        if !git_output(repo, &["cat-file", "-e", &spec])?
109            .status
110            .success()
111        {
112            return Err(CliError(format!(
113                "recorded sync head {s} is not in {} (history rewritten?); \
114                 ingest into a fresh database directory",
115                repo.display()
116            )));
117        }
118    }
119    let mut cmd = Command::new("git");
120    cmd.arg("-C").arg(repo).args([
121        // Without this git renders any non-ASCII byte in a path as an octal
122        // escape, so `src/café.rs` would be stored under a mangled key that no
123        // later run matches. Paths containing a tab or newline stay quoted and
124        // escaped either way — git has to, or they would break the format below.
125        "-c",
126        "core.quotePath=false",
127        "log",
128        "--reverse",
129        "--name-status",
130        "-M",
131        "--no-color",
132        // Record separator \x1e between commits, unit separator \x1f between
133        // header fields. A commit subject containing either byte splits its own
134        // record: the message is truncated at the first \x1f, and a \x1e drops
135        // the remainder of that commit's header. Accepted — the parse degrades
136        // to a skipped or shortened message, never a panic or a wrong sha.
137        "--format=%x1e%H%x1f%an%x1f%ae%x1f%at%x1f%s",
138    ]);
139    match since {
140        Some(s) => cmd.arg(format!("{s}..{head}")),
141        None => cmd.arg(head),
142    };
143    let out = cmd
144        .output()
145        .map_err(|e| CliError(format!("cannot run git: {e}")))?;
146    if !out.status.success() {
147        return Err(CliError(format!(
148            "git log failed: {}",
149            String::from_utf8_lossy(&out.stderr).trim()
150        )));
151    }
152    let text = String::from_utf8_lossy(&out.stdout);
153    let mut commits = Vec::new();
154    for block in text.split('\x1e').filter(|b| !b.trim().is_empty()) {
155        let mut lines = block.lines();
156        let header = lines.next().unwrap_or("");
157        let f: Vec<&str> = header.split('\x1f').collect();
158        if f.len() < 5 {
159            continue;
160        }
161        let mut changes = Vec::new();
162        for l in lines {
163            let cols: Vec<&str> = l.split('\t').collect();
164            match cols.as_slice() {
165                [s, p] if s.starts_with('A') => changes.push(Change::Added((*p).to_string())),
166                [s, p] if s.starts_with('M') || s.starts_with('T') => {
167                    changes.push(Change::Modified((*p).to_string()))
168                }
169                [s, p] if s.starts_with('D') => changes.push(Change::Deleted((*p).to_string())),
170                [s, from, to] if s.starts_with('R') => changes.push(Change::Renamed {
171                    from: (*from).to_string(),
172                    to: (*to).to_string(),
173                }),
174                // A copy is a brand-new path with no prior history here.
175                [s, _from, to] if s.starts_with('C') => {
176                    changes.push(Change::Added((*to).to_string()))
177                }
178                _ => {}
179            }
180        }
181        commits.push(GitCommit {
182            sha: f[0].into(),
183            author_name: f[1].into(),
184            author_email: f[2].into(),
185            ts: f[3].parse().unwrap_or(0),
186            subject: f[4].into(),
187            changes,
188        });
189    }
190    Ok(commits)
191}
192
193/// Resolve `HEAD` to a concrete sha. `Ok(None)` means the repository has no
194/// commits yet; a path that is not a repository at all is an error.
195///
196/// Called **before** [`read_log`], and the resulting sha is both the end of the
197/// walk and the recorded sync marker. Resolving it afterwards instead would open
198/// a window: a commit landing between the walk and the `rev-parse` would push
199/// the marker past a commit that was never ingested, and every later run would
200/// skip it silently. Pinning both to one sha closes that window — a commit that
201/// lands mid-run is simply outside this range and gets picked up next time.
202///
203/// Asking git also beats taking `log.last()`. The two agree in practice, since a
204/// reachability walk emits its tip first and reversing puts it last, but that is
205/// a property of the traversal and of this module's parser rather than something
206/// the resume marker should rest on.
207fn head_sha(repo: &Path) -> Result<Option<String>, CliError> {
208    let out = git_output(repo, &["rev-parse", "--verify", "-q", "HEAD^{commit}"])?;
209    if !out.status.success() {
210        // No commits yet, or not a repository at all — only the latter is an error.
211        if !git_output(repo, &["rev-parse", "--git-dir"])?
212            .status
213            .success()
214        {
215            return Err(CliError(format!(
216                "not a git repository: {}",
217                repo.display()
218            )));
219        }
220        return Ok(None);
221    }
222    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
223    if sha.is_empty() {
224        return Err(CliError(format!(
225            "git could not resolve HEAD in {}",
226            repo.display()
227        )));
228    }
229    Ok(Some(sha))
230}
231
232/// Separator inside an `author_counts` entry. An email address cannot contain a
233/// tab, so `email\tcount` round-trips unambiguously.
234const AUTHOR_COUNT_SEP: char = '\t';
235
236fn file_props(st: &FileState, path: &str) -> Vec<(String, Value)> {
237    let commits = &st.commits;
238    let dir = path
239        .rsplit_once('/')
240        .map(|(d, _)| d)
241        .unwrap_or("")
242        .to_string();
243    let ext = path
244        .rsplit_once('.')
245        .map(|(_, e)| e)
246        .unwrap_or("")
247        .to_string();
248    vec![
249        ("id".into(), Value::Str(path.into())),
250        ("path".into(), Value::Str(path.into())),
251        ("dir".into(), Value::Str(dir)),
252        ("ext".into(), Value::Str(ext)),
253        (
254            "commits".into(),
255            Value::List(commits.iter().map(|s| Value::Str(s.clone())).collect()),
256        ),
257        // The true total, which past `--max-commits-per-file` is larger than
258        // the `commits` list it is stored beside. It is also what
259        // `author_counts` sums to, so the two props agree at any history
260        // length.
261        ("n_commits".into(), Value::Int(st.n_commits as i64)),
262        ("top_author_id".into(), Value::Str(st.top_author())),
263        // Written on every touch so the next incremental run can rebuild the
264        // distribution instead of crediting the whole history to the incumbent.
265        ("author_counts".into(), st.author_counts_value()),
266    ]
267}
268
269/// In-memory per-file state accumulated while walking commits.
270#[derive(Default, Clone)]
271struct FileState {
272    commits: Vec<String>,
273    by_author: BTreeMap<String, usize>,
274    n_commits: usize,
275}
276
277impl FileState {
278    fn touch(&mut self, sha: &str, author: &str, cap: usize) {
279        self.commits.push(sha.to_string());
280        if cap > 0 && self.commits.len() > cap {
281            self.commits.remove(0);
282        }
283        self.n_commits += 1;
284        *self.by_author.entry(author.to_string()).or_default() += 1;
285    }
286
287    /// Most commits wins; ties break on the lexicographically smallest email
288    /// so the result is deterministic across runs.
289    fn top_author(&self) -> String {
290        self.by_author
291            .iter()
292            .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
293            .map(|(a, _)| a.clone())
294            .unwrap_or_default()
295    }
296
297    /// The per-author distribution as a `File.author_counts` prop: a list of
298    /// `"email\tcount"` strings in email order.
299    ///
300    /// This is the state an incremental run needs and cannot recompute — the
301    /// walk only sees the new window, and `top_author_id` alone cannot say how
302    /// far ahead the incumbent is. Without it a challenger's commits reset on
303    /// every sync and ownership can never change.
304    fn author_counts_value(&self) -> Value {
305        Value::List(
306            self.by_author
307                .iter()
308                .map(|(email, n)| Value::Str(format!("{email}{AUTHOR_COUNT_SEP}{n}")))
309                .collect(),
310        )
311    }
312
313    /// Inverse of [`FileState::author_counts_value`]. Entries that are not
314    /// `email<TAB>count` are skipped rather than failing the run.
315    fn set_author_counts(&mut self, list: &[Value]) {
316        for v in list {
317            let Value::Str(s) = v else { continue };
318            let Some((email, n)) = s.rsplit_once(AUTHOR_COUNT_SEP) else {
319                continue;
320            };
321            let Ok(n) = n.parse::<usize>() else { continue };
322            if !email.is_empty() {
323                *self.by_author.entry(email.to_string()).or_default() += n;
324            }
325        }
326    }
327}
328
329/// Cypher behind [`file_state_from`] — every live `File` node's cumulative state.
330const FILE_STATE_QUERY: &str = "MATCH (f:File) RETURN f.id AS id, f.commits AS commits, \
331     f.n_commits AS n, f.top_author_id AS top, f.author_counts AS author_counts";
332
333/// Rebuild the in-memory per-file state from the `File` nodes already in the
334/// graph so incremental runs keep `commits` and ownership counts cumulative.
335fn file_state_from(rs: &ResultSet) -> BTreeMap<String, FileState> {
336    let mut files = BTreeMap::new();
337    for i in 0..rs.len() {
338        let id = match rs.get(i, "id") {
339            Some(Value::Str(s)) => s.clone(),
340            _ => continue,
341        };
342        let mut st = FileState::default();
343        if let Some(Value::List(l)) = rs.get(i, "commits") {
344            st.commits = l
345                .iter()
346                .filter_map(|v| match v {
347                    Value::Str(s) => Some(s.clone()),
348                    _ => None,
349                })
350                .collect();
351        }
352        if let Some(Value::Int(n)) = rs.get(i, "n") {
353            st.n_commits = *n as usize;
354        }
355        match rs.get(i, "author_counts") {
356            Some(Value::List(l)) => st.set_author_counts(l),
357            // A node written before `author_counts` existed (a store built by
358            // 0.4.x). Fall back to the old approximation — the whole prior
359            // history credited to the incumbent — so those stores keep
360            // working. The prop is written on the next touch, from which point
361            // ownership tracks reality; a full re-ingest repairs it at once.
362            _ => {
363                if let Some(Value::Str(t)) = rs.get(i, "top") {
364                    st.by_author.insert(t.clone(), st.n_commits.max(1));
365                }
366            }
367        }
368        files.insert(id, st);
369    }
370    files
371}
372
373/// Accumulated effect of one log window, before anything is written.
374#[derive(Default)]
375struct Walk {
376    files: BTreeMap<String, FileState>,
377    authors: BTreeMap<String, String>,
378    commit_rows: Vec<BTreeMap<String, Value>>,
379    touched_edges: Vec<(String, String, String)>,
380    /// Paths whose `File` props changed in this window.
381    dirty: BTreeSet<String>,
382    deleted: BTreeSet<String>,
383    /// Node renames to apply, collapsed across chained renames.
384    renamed: Vec<(String, String)>,
385    /// Any old path → its final path in this window, for edge retargeting.
386    alias: BTreeMap<String, String>,
387}
388
389impl Walk {
390    fn rename(&mut self, from: &str, to: &str, node_exists: bool) {
391        if let Some(e) = self.renamed.iter_mut().find(|(_, t)| t == from) {
392            e.1 = to.to_string();
393        } else if node_exists {
394            // A node cannot be both deleted and moved; the move wins.
395            self.deleted.remove(from);
396            self.renamed.push((from.to_string(), to.to_string()));
397        } else {
398            self.deleted.insert(from.to_string());
399        }
400        for v in self.alias.values_mut() {
401            if v == from {
402                *v = to.to_string();
403            }
404        }
405        self.alias.insert(from.to_string(), to.to_string());
406    }
407}
408
409pub fn run_ingest_git(db_dir: &Path, opts: &IngestGitOpts) -> Result<IngestGitReport, CliError> {
410    let db = SharedDb::open(db_dir)?;
411    let since: Option<String> = {
412        let r = db.read();
413        r.node_ref(SYNC_KEY)
414            .and_then(|n| n.prop("sha"))
415            .and_then(|v| match v {
416                Value::Str(s) if !s.is_empty() => Some(s),
417                _ => None,
418            })
419    };
420    let incremental = since.is_some();
421    let mut report = IngestGitReport {
422        incremental,
423        ..Default::default()
424    };
425    // Pin the end of the walk and the marker to one sha, resolved first. A
426    // commit landing mid-run then falls outside this range instead of being
427    // skipped by a marker that advanced past it.
428    let Some(head) = head_sha(&opts.repo)? else {
429        return Ok(report); // repository has no commits yet
430    };
431    let log = read_log(&opts.repo, since.as_deref(), &head)?;
432    if log.is_empty() {
433        // Nothing new: leave the store untouched so `commit_seq` does not move.
434        return Ok(report);
435    }
436
437    let mut w = db.write();
438    let ingest = IngestOptions::default(); // key `id`, auto-FK suffix `_id`
439
440    let mut walk = Walk {
441        files: if incremental {
442            file_state_from(&w.query(FILE_STATE_QUERY, &BTreeMap::new())?)
443        } else {
444            BTreeMap::new()
445        },
446        ..Default::default()
447    };
448
449    for c in &log {
450        walk.authors
451            .entry(c.author_email.clone())
452            .or_insert_with(|| c.author_name.clone());
453        walk.commit_rows.push(BTreeMap::from([
454            ("id".to_string(), Value::Str(c.sha.clone())),
455            ("message".to_string(), Value::Str(c.subject.clone())),
456            ("ts".to_string(), Value::Int(c.ts)),
457            ("author_id".to_string(), Value::Str(c.author_email.clone())),
458        ]));
459        for ch in &c.changes {
460            match ch {
461                Change::Added(p) | Change::Modified(p) => {
462                    if excluded(p, &opts.exclude) {
463                        continue;
464                    }
465                    walk.deleted.remove(p);
466                    walk.files.entry(p.clone()).or_default().touch(
467                        &c.sha,
468                        &c.author_email,
469                        opts.max_commits_per_file,
470                    );
471                    walk.dirty.insert(p.clone());
472                    walk.touched_edges
473                        .push(("TOUCHED".into(), c.sha.clone(), p.clone()));
474                }
475                Change::Deleted(p) => {
476                    if excluded(p, &opts.exclude) {
477                        continue;
478                    }
479                    walk.files.remove(p);
480                    walk.dirty.remove(p);
481                    walk.deleted.insert(p.clone());
482                }
483                Change::Renamed { from, to } => {
484                    if excluded(to, &opts.exclude) {
485                        // Moved out of scope: drop the old node, keep no alias
486                        // so its TOUCHED edges are filtered out below.
487                        walk.files.remove(from);
488                        walk.dirty.remove(from);
489                        walk.deleted.insert(from.clone());
490                        continue;
491                    }
492                    let mut st = walk.files.remove(from).unwrap_or_default();
493                    st.touch(&c.sha, &c.author_email, opts.max_commits_per_file);
494                    walk.files.insert(to.clone(), st);
495                    walk.dirty.remove(from);
496                    walk.dirty.insert(to.clone());
497                    walk.deleted.remove(to);
498                    walk.touched_edges
499                        .push(("TOUCHED".into(), c.sha.clone(), to.clone()));
500                    let exists = incremental && w.has_node(from);
501                    walk.rename(from, to, exists);
502                }
503            }
504        }
505    }
506
507    // 1. Authors first: the auto-FK rules for `Commit.author_id` and
508    //    `File.top_author_id` only infer once their targets resolve to Author.
509    let author_rows: Vec<BTreeMap<String, Value>> = walk
510        .authors
511        .iter()
512        .filter(|(email, _)| !w.has_node(email))
513        .map(|(email, name)| {
514            BTreeMap::from([
515                ("id".to_string(), Value::Str(email.clone())),
516                ("name".to_string(), Value::Str(name.clone())),
517            ])
518        })
519        .collect();
520    let a = w.ingest_with_edges("Author", author_rows, &ingest, &[])?;
521    report.rules_created.extend(a.rules_created);
522    report.authors = walk.authors.len();
523
524    // 2. Deletes run first so a rename can claim a path freed in this same
525    //    window, then renames carry each node (and its history) to its new path.
526    //    `walk.files` is the authority on what is still live: a rename whose
527    //    destination is not in it is a delete, not a move.
528    for p in &walk.deleted {
529        if w.has_node(p) {
530            w.delete_node(p)?;
531            report.deleted += 1;
532        }
533    }
534    for (from, to) in &walk.renamed {
535        if !w.has_node(from) || from == to {
536            // Nothing to move, or a rename that swapped back to its own path.
537            continue;
538        }
539        if !walk.files.contains_key(to) {
540            // The destination did not survive the window — it was deleted, or
541            // moved into an excluded path, after this rename. The node goes
542            // with it; renaming into a dead path would strand a phantom node
543            // that no later phase refreshes.
544            w.delete_node(from)?;
545            report.deleted += 1;
546            continue;
547        }
548        if w.has_node(to) {
549            // A pre-existing node already holds the destination path (deleted
550            // earlier in this window, then claimed by this rename).
551            w.delete_node(to)?;
552            report.deleted += 1;
553        }
554        w.rename_node(from, to)?;
555        // The key moved, so the `id` prop must move with it. Phase 3 also sets
556        // it for every dirty path; doing it here keeps the invariant local to
557        // the rename and independent of that filter.
558        w.set_prop(to, "id", Value::Str(to.clone()))?;
559        report.renamed += 1;
560    }
561
562    // 3. File nodes. Existing nodes are updated in place (including `id`, which
563    //    must follow the key after a rename); new paths go through ingest so
564    //    the `top_author_id` auto-FK rule is inferred.
565    let mut new_file_rows = Vec::new();
566    for (path, st) in &walk.files {
567        if incremental && !walk.dirty.contains(path) {
568            continue;
569        }
570        let props = file_props(st, path);
571        if w.has_node(path) {
572            for (k, v) in props {
573                w.set_prop(path, &k, v)?;
574            }
575        } else {
576            new_file_rows.push(props.into_iter().collect::<BTreeMap<_, _>>());
577        }
578    }
579    let f = w.ingest_with_edges("File", new_file_rows, &ingest, &[])?;
580    report.rules_created.extend(f.rules_created);
581    report.files = walk.files.len();
582
583    // 4. Commits, then their TOUCHED edges. The two must be separate batches:
584    //    a batch that both inserts nodes firing a new rule and carries a user
585    //    edge of a not-yet-interned type writes a WAL frame that cannot be
586    //    replayed (`Intern` records are emitted in a pre-pass, but on replay the
587    //    rule fires — and interns its edge type — before the later `Intern`
588    //    record is read). See the report for a reproducer.
589    let c = w.ingest_with_edges("Commit", walk.commit_rows, &ingest, &[])?;
590    report.rules_created.extend(c.rules_created);
591    report.commits = log.len();
592
593    // Edges name File keys, so files must already exist. A path renamed later
594    // in this same window is retargeted to where its node ended up.
595    let touched: Vec<(String, String, String)> = walk
596        .touched_edges
597        .into_iter()
598        .map(|(t, sha, p)| {
599            let p = walk.alias.get(&p).cloned().unwrap_or(p);
600            (t, sha, p)
601        })
602        .filter(|(_, _, p)| walk.files.contains_key(p))
603        .collect();
604    if !touched.is_empty() {
605        w.ingest_with_edges("Commit", Vec::new(), &ingest, &touched)?;
606    }
607
608    // 5. Rules and fulltext, first run only. Created after the data so each
609    //    rule backfills once.
610    if !incremental {
611        let co = Predicate::Overlap {
612            field: "commits".into(),
613            min: CO_CHANGE_MIN,
614        };
615        w.create_rule(RuleDef {
616            name: "co_changed".into(),
617            src_label: "File".into(),
618            dst_label: "File".into(),
619            predicate: co.clone(),
620            edge_type: "CO_CHANGED".into(),
621            weight_prop: Some("score".into()),
622            max_edges: Some(10),
623            approximate: false,
624            via_label: None,
625            via_edge: None,
626            via_dir: None,
627        })?;
628        w.create_rule(RuleDef {
629            name: "knows".into(),
630            src_label: "Author".into(),
631            dst_label: "File".into(),
632            predicate: co,
633            edge_type: "KNOWS".into(),
634            weight_prop: Some("score".into()),
635            max_edges: Some(20),
636            approximate: false,
637            via_label: Some("File".into()),
638            via_edge: Some("TOP_AUTHOR".into()),
639            via_dir: Some(Direction::In),
640        })?;
641        report
642            .rules_created
643            .extend(["co_changed".to_string(), "knows".to_string()]);
644        for (l, field) in [("File", "path"), ("Commit", "message"), ("Author", "name")] {
645            w.enable_fulltext(l, field)?;
646        }
647    }
648
649    // 6. Sync marker for the next incremental run, resolved above. It carries
650    //    `id` like every other label here, so the key is readable from Cypher.
651    if w.has_node(SYNC_KEY) {
652        w.set_prop(SYNC_KEY, "sha", Value::Str(head))?;
653    } else {
654        w.insert_node(
655            "GitSync",
656            SYNC_KEY,
657            vec![
658                ("id".into(), Value::Str(SYNC_KEY.into())),
659                ("sha".into(), Value::Str(head)),
660            ],
661        )?;
662    }
663    Ok(report)
664}
665
666pub fn format_ingest_git(r: &IngestGitReport) -> String {
667    let mut out = format!(
668        "ingest-git: {} commit(s), {} file(s), {} author(s){}\n",
669        r.commits,
670        r.files,
671        r.authors,
672        if r.incremental { " (incremental)" } else { "" }
673    );
674    if r.renamed + r.deleted > 0 {
675        out.push_str(&format!("  renamed {}  deleted {}\n", r.renamed, r.deleted));
676    }
677    if !r.rules_created.is_empty() {
678        out.push_str(&format!("  rules: {}\n", r.rules_created.join(", ")));
679    }
680    out
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn exclude_matches_prefix_extension_and_substring() {
689        let pats = vec![
690            "target/".to_string(),
691            "*.lock".into(),
692            "node_modules".into(),
693        ];
694        assert!(excluded("target/debug/foo.rs", &pats));
695        assert!(
696            !excluded("targeted/foo.rs", &pats),
697            "prefix needs the slash"
698        );
699        assert!(excluded("Cargo.lock", &pats));
700        assert!(!excluded("Cargo.toml", &pats));
701        assert!(excluded("ui/node_modules/x/y.js", &pats));
702        assert!(!excluded("src/lib.rs", &pats));
703        assert!(!excluded("anything", &[]));
704    }
705
706    #[test]
707    fn file_props_split_dir_and_ext() {
708        let mut st = FileState::default();
709        st.touch("sha1", "a@x.test", 200);
710        let m: BTreeMap<_, _> = file_props(&st, "src/a/b.rs").into_iter().collect();
711        assert_eq!(m["dir"], Value::Str("src/a".into()));
712        assert_eq!(m["ext"], Value::Str("rs".into()));
713        assert_eq!(m["n_commits"], Value::Int(1));
714        assert_eq!(m["id"], Value::Str("src/a/b.rs".into()));
715        assert_eq!(m["top_author_id"], Value::Str("a@x.test".into()));
716        assert_eq!(
717            m["author_counts"],
718            Value::List(vec![Value::Str("a@x.test\t1".into())])
719        );
720        let m: BTreeMap<_, _> = file_props(&FileState::default(), "README")
721            .into_iter()
722            .collect();
723        assert_eq!(m["dir"], Value::Str(String::new()));
724        assert_eq!(m["ext"], Value::Str(String::new()));
725    }
726
727    /// The prop is the whole point of the incremental fix: it must survive a
728    /// round trip so the next run resumes the real distribution, not the
729    /// incumbent's total.
730    #[test]
731    fn author_counts_round_trip_preserves_the_distribution() {
732        let mut st = FileState::default();
733        for _ in 0..3 {
734            st.touch("s", "alice@x.test", 200);
735        }
736        for _ in 0..4 {
737            st.touch("s", "bob@x.test", 200);
738        }
739        let Value::List(encoded) = st.author_counts_value() else {
740            panic!("author_counts must be a list");
741        };
742        assert_eq!(
743            encoded,
744            vec![
745                Value::Str("alice@x.test\t3".into()),
746                Value::Str("bob@x.test\t4".into()),
747            ],
748            "email order, so the prop is stable across runs"
749        );
750        let mut reloaded = FileState::default();
751        reloaded.set_author_counts(&encoded);
752        assert_eq!(reloaded.by_author, st.by_author);
753        assert_eq!(reloaded.top_author(), "bob@x.test");
754    }
755
756    /// Malformed entries are skipped, not fatal: the run degrades to the counts
757    /// it can read rather than refusing to sync.
758    #[test]
759    fn author_counts_skips_entries_it_cannot_parse() {
760        let mut st = FileState::default();
761        st.set_author_counts(&[
762            Value::Str("alice@x.test\t2".into()),
763            Value::Str("no-tab-here".into()),
764            Value::Str("bob@x.test\tnotanumber".into()),
765            Value::Str("\t5".into()),
766            Value::Int(7),
767        ]);
768        assert_eq!(st.by_author, BTreeMap::from([("alice@x.test".into(), 2)]));
769    }
770
771    #[test]
772    fn commits_list_is_capped_and_top_author_is_deterministic() {
773        let mut st = FileState::default();
774        for i in 0..5 {
775            st.touch(&format!("sha{i}"), "b@x.test", 3);
776        }
777        st.touch("shaX", "a@x.test", 3);
778        assert_eq!(st.commits, vec!["sha3", "sha4", "shaX"]);
779        assert_eq!(st.n_commits, 6);
780        assert_eq!(st.top_author(), "b@x.test");
781
782        // Past the cap the stored `n_commits` is the true total, not the length
783        // of the truncated `commits` list, and `author_counts` sums to the same
784        // number. A file over `--max-commits-per-file` would otherwise report a
785        // history frozen at the cap.
786        let m: BTreeMap<_, _> = file_props(&st, "src/hot.rs").into_iter().collect();
787        assert_eq!(m["n_commits"], Value::Int(6));
788        let Value::List(commits) = &m["commits"] else {
789            panic!("commits must be a list");
790        };
791        assert_eq!(commits.len(), 3, "the list is still capped at 3");
792        assert!(
793            matches!(m["n_commits"], Value::Int(n) if n as usize > commits.len()),
794            "n_commits must exceed the capped list once the cap is passed"
795        );
796        assert_eq!(
797            m["author_counts"],
798            Value::List(vec![
799                Value::Str("a@x.test\t1".into()),
800                Value::Str("b@x.test\t5".into()),
801            ]),
802            "the per-author counts sum to n_commits, not to the capped list"
803        );
804
805        let mut tie = FileState::default();
806        tie.touch("s", "b@x.test", 10);
807        tie.touch("s", "a@x.test", 10);
808        assert_eq!(tie.top_author(), "a@x.test", "ties break on smallest email");
809    }
810}