Skip to main content

rto_graph/
git.rs

1//! A thin `gix` wrapper exposing exactly the git facts the sync engine needs:
2//! the HEAD tree id, the blobs in that tree, and blob contents. Kept small so
3//! all `gix` coupling lives in one place.
4
5use std::path::Path;
6
7/// A blob in a tree: its repository-relative path and hex object id.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct BlobRef {
10    /// Repository-relative path (forward-slash separated).
11    pub path: String,
12    /// Hex-encoded git blob object id.
13    pub oid: String,
14}
15
16/// Which tree the graph — derived layer *and* authored layer — is built from:
17/// the committed `HEAD`, the working tree (uncommitted edits on disk), or the
18/// git index (the staged tree a commit would record).
19///
20/// It selects the sync engine ([`crate::sync`] / [`crate::sync_worktree`] /
21/// [`crate::sync_index`]) and the authored-layer source
22/// ([`Repo::read_source`]) **together**, which is the point of it being one
23/// type: the two layers disagreeing about which tree they describe is issue
24/// #330, and it was a silent wrong answer rather than a loud one.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum GraphSource {
27    /// The committed `HEAD` tree (the CI merge gate).
28    Committed,
29    /// The working tree: `HEAD` plus uncommitted edits to tracked files on disk.
30    Worktree,
31    /// The git index — exactly what a commit would record (the pre-commit gate).
32    Index,
33}
34
35impl GraphSource {
36    /// A short stable token for this source, for reports and tool documents.
37    #[must_use]
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Committed => "committed",
41            Self::Worktree => "worktree",
42            Self::Index => "index",
43        }
44    }
45}
46
47/// Errors raised while reading from a git repository.
48#[derive(Debug, thiserror::Error)]
49pub enum GitError {
50    /// A `gix` operation failed (message preserved).
51    #[error("git error: {0}")]
52    Git(String),
53    /// A tree entry path was not valid UTF-8.
54    #[error("non-utf8 path in tree: {0:?}")]
55    NonUtf8Path(Vec<u8>),
56}
57
58fn ge<E: std::fmt::Display>(e: E) -> GitError {
59    GitError::Git(e.to_string())
60}
61
62/// Who last changed one path, and when — see [`Repo::last_authors`].
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct PathAuthor {
65    /// The commit author's name, exactly as git recorded it. The bare identity:
66    /// any prefix a format requires (OKF's `human:`, §7) is the renderer's.
67    pub name: String,
68    /// That commit's commit time, in seconds since the Unix epoch, UTC.
69    ///
70    /// Carried alongside the name because the two are one claim. Stamping a
71    /// person's confirmation with the *render's* clock would date a review to a
72    /// moment the reviewer had nothing to do with, and would make the bundle
73    /// differ on every render for a document nobody touched.
74    pub at: i64,
75}
76
77/// The blob id at `path` within `tree`, memoised on `(tree, path)`.
78///
79/// `None` means the path does not exist in that tree — which is a state the
80/// caller compares like any other, because a path appearing (or disappearing) is
81/// as much a change as its content differing.
82fn blob_at<'p>(
83    repo: &gix::Repository,
84    memo: &mut std::collections::HashMap<(gix::ObjectId, &'p str), Option<gix::ObjectId>>,
85    tree: gix::ObjectId,
86    path: &'p str,
87) -> Result<Option<gix::ObjectId>, GitError> {
88    if let Some(hit) = memo.get(&(tree, path)) {
89        return Ok(*hit);
90    }
91    let found = repo
92        .find_object(tree)
93        .map_err(ge)?
94        .try_into_tree()
95        .map_err(ge)?
96        .lookup_entry(path.split('/').map(str::as_bytes))
97        .map_err(ge)?
98        .map(|e| e.id().detach());
99    memo.insert((tree, path), found);
100    Ok(found)
101}
102
103/// The file a repository declares its submodule URLs in — the **source** path for
104/// every submodule fact, distinct from the **subject** path each fact is about.
105///
106/// A named constant because two modules must agree on it: `git` asks the policy
107/// about it before reading the blob, and `sync` asks about it before attributing
108/// a node to it. A literal in both places is how the two drift apart.
109pub(crate) const GITMODULES: &str = ".gitmodules";
110
111/// A discovered git repository.
112pub struct Repo {
113    inner: gix::Repository,
114}
115
116impl Repo {
117    /// Discover the repository containing `path` (walking upwards to the `.git`).
118    ///
119    /// # Errors
120    /// Returns [`GitError::Git`] if no repository is found or it cannot be opened.
121    pub fn discover(path: &Path) -> Result<Self, GitError> {
122        Ok(Self {
123            inner: gix::discover(path).map_err(ge)?,
124        })
125    }
126
127    /// The repository's *common* git directory. The cache lives under here so it
128    /// is shared across linked worktrees (which each have their own git dir).
129    #[must_use]
130    pub fn common_dir(&self) -> &Path {
131        self.inner.common_dir()
132    }
133
134    /// This worktree's git directory (per-worktree; the graph DB lives here).
135    #[must_use]
136    pub fn git_dir(&self) -> &Path {
137        self.inner.git_dir()
138    }
139
140    /// The directory git actually looks in for hooks. Honours `core.hooksPath`
141    /// (absolute, or relative to the working-tree root — else the git dir); when
142    /// unset it is `<common git dir>/hooks`, so managed hooks are shared across
143    /// linked worktrees. `roteiro init` installs into this so its hooks run
144    /// wherever git expects them.
145    #[must_use]
146    pub fn hooks_dir(&self) -> std::path::PathBuf {
147        let configured = self.inner.config_snapshot().string("core.hooksPath");
148        // An empty `core.hooksPath` (e.g. `git -c core.hooksPath=`) means "unset".
149        let configured = configured.filter(|c| !AsRef::<[u8]>::as_ref(c).is_empty());
150        if let Some(configured) = configured {
151            let bytes: &[u8] = configured.as_ref();
152            let path = std::path::PathBuf::from(String::from_utf8_lossy(bytes).into_owned());
153            if path.is_absolute() {
154                return path;
155            }
156            let base = self.inner.workdir().unwrap_or_else(|| self.inner.git_dir());
157            return base.join(path);
158        }
159        self.common_dir().join("hooks")
160    }
161
162    /// The working directory, if this is not a bare repository. The dirty
163    /// overlay reads uncommitted file contents from here.
164    #[must_use]
165    pub fn workdir(&self) -> Option<&Path> {
166        self.inner.workdir()
167    }
168
169    /// When this repository is a **linked git worktree**, the main checkout it
170    /// belongs to; `None` for an ordinary clone.
171    ///
172    /// The runtime counterpart of [`crate::is_linked_worktree`], which answers the
173    /// same question about a directory nobody has opened yet. This one answers it
174    /// about the repository actually in hand, through `gix`'s own classification
175    /// (`Kind::LinkedWorkTree`). No `.git` file is parsed and no directory name is
176    /// consulted.
177    ///
178    /// # Why not `git_dir() != common_dir()`
179    ///
180    /// Because that comparison is true but the *path* it hands back is not usable.
181    /// `gix` reports the common dir as the worktree's `commondir` file records it,
182    /// which git writes **relative** and gix leaves unresolved: a real checkout
183    /// yields `<main>/.git/worktrees/<name>/../..`. Taking `.parent()` of that
184    /// returns `…/worktrees/<name>/..`, and `file_name()` is `".."` rather than
185    /// `.git`, so a note built from it names a directory nobody typed and reads as
186    /// a bug in the tool. The first version of this method did exactly that, and
187    /// the assertion in
188    /// `tests/worktree_discovery.rs::a_worktree_and_its_main_checkout_share_a_cache_without_sharing_a_graph`
189    /// is what caught it — a `contains` check on the note had passed, because the
190    /// unresolved path still has the right prefix.
191    ///
192    /// `main_repo()` opens the main repository instead and reports *its* workdir,
193    /// so the path is the one that repository actually has. A worktree of a **bare**
194    /// repository has no workdir, and its git dir is then the most specific true
195    /// thing to name.
196    #[must_use]
197    pub fn linked_worktree_of(&self) -> Option<std::path::PathBuf> {
198        if self.inner.kind() != gix::repository::Kind::LinkedWorkTree {
199            return None;
200        }
201        let main = self.inner.main_repo().ok()?;
202        Some(
203            main.workdir()
204                .unwrap_or_else(|| main.git_dir())
205                .to_path_buf(),
206        )
207    }
208
209    /// The short name of the branch `HEAD` points at (`main`, `feat/x`), or `None`
210    /// at a detached `HEAD`.
211    ///
212    /// Read through `gix`'s `head_name`, which resolves `HEAD` in **this**
213    /// repository's git dir. That is the whole point in a linked worktree: its
214    /// `HEAD` lives in `<main>/.git/worktrees/<name>/HEAD`, not in `<main>/.git/HEAD`,
215    /// so a caller naming the branch a served graph was built from must ask the
216    /// repository it actually opened rather than the common dir.
217    #[must_use]
218    pub fn head_branch(&self) -> Option<String> {
219        let name = self.inner.head_name().ok().flatten()?;
220        Some(name.shorten().to_string())
221    }
222
223    /// The hex git blob object id that `bytes` would have, without writing
224    /// anything. Used to detect whether a working-copy file differs from the
225    /// committed blob (same content ⇒ same id).
226    ///
227    /// # Errors
228    /// Returns [`GitError::Git`] if hashing fails.
229    pub fn blob_oid(&self, bytes: &[u8]) -> Result<String, GitError> {
230        let id = gix::objs::compute_hash(self.inner.object_hash(), gix::objs::Kind::Blob, bytes)
231            .map_err(ge)?;
232        Ok(id.to_hex().to_string())
233    }
234
235    /// Hex object id of the tree at `HEAD`.
236    ///
237    /// # Errors
238    /// Returns [`GitError::Git`] if `HEAD` cannot be resolved to a tree.
239    pub fn head_tree_id(&self) -> Result<String, GitError> {
240        let tree = self.inner.head_tree().map_err(ge)?;
241        Ok(tree.id().to_hex().to_string())
242    }
243
244    /// Hex object id of the commit at `HEAD` — a stable permalink ref for the tree
245    /// the graph was built from (used to build source links).
246    ///
247    /// # Errors
248    /// Returns [`GitError::Git`] if `HEAD` cannot be resolved to a commit.
249    pub fn head_commit_id(&self) -> Result<String, GitError> {
250        Ok(self
251            .inner
252            .head_id()
253            .map_err(ge)?
254            .detach()
255            .to_hex()
256            .to_string())
257    }
258
259    /// Seconds since the Unix epoch of the `HEAD` commit's commit time, in UTC.
260    ///
261    /// Added for analyzer-asset provisioning: an advisory database that is a git
262    /// checkout has no publication date of its own, and `cargo audit` reports
263    /// none at all when it is pointed at a database with `--db` rather than
264    /// resolving one itself. The commit time is the publication date, and it is
265    /// what lets a result be labelled *possibly stale* with a number attached
266    /// (ADR-0012).
267    ///
268    /// # Errors
269    /// Returns [`GitError::Git`] if `HEAD` cannot be resolved to a commit or the
270    /// commit carries no readable time.
271    pub fn head_commit_time(&self) -> Result<i64, GitError> {
272        let commit = self.inner.head_commit().map_err(ge)?;
273        Ok(commit.time().map_err(ge)?.seconds)
274    }
275
276    /// Who last changed each of `paths`, and when — the equivalent of
277    /// `git log -1 --format='%an %ct' -- <path>` for a whole set at once.
278    ///
279    /// Used to attribute the **authored** layer to a person, which is what puts a
280    /// concept in OKF's human-reviewed trust tier (§5.3) rather than the
281    /// machine-confirmed one. The `human:` prefix is applied by the renderer, not
282    /// here — this returns the bare identity.
283    ///
284    /// # Per path, because the claim is per document
285    ///
286    /// The obvious cheap answer is the `HEAD` commit's author, and it is wrong in
287    /// a way the format cannot survive: `verified: [{ by: human:<id> }]` asserts
288    /// that *that person* stands behind *that document*, so attributing the whole
289    /// repository to whoever pushed last records a confirmation nobody made. A
290    /// bot merge at `HEAD` would mark every ADR as human-reviewed by the bot.
291    ///
292    /// # What "last changed" means here
293    ///
294    /// Walking newest-first by commit time, a commit changed a path when the blob
295    /// at that path differs from the blob in **every** parent — the same
296    /// definition `git log -- <path>` uses, so a merge that only carried a change
297    /// across is not credited with making it. A path present in a root commit was
298    /// changed by that commit.
299    ///
300    /// A path absent from the result was never resolved: the history ran out
301    /// first (a shallow clone), a commit's parents could not be read (a partial
302    /// clone), or the walk failed. The caller must read that as *no
303    /// confirmation*, never as the tool's — substituting a machine actor would
304    /// move a concept down a trust tier silently, which is worse than claiming
305    /// nothing.
306    ///
307    /// Every commit this cannot fully compare is skipped rather than guessed at,
308    /// for the same reason: the only wrong answer that costs anything here is a
309    /// confident one.
310    ///
311    /// # Errors
312    /// Returns [`GitError::Git`] if `HEAD` cannot be resolved or the history
313    /// cannot be walked.
314    pub fn last_authors(
315        &self,
316        paths: &std::collections::BTreeSet<String>,
317    ) -> Result<std::collections::BTreeMap<String, PathAuthor>, GitError> {
318        use std::collections::{BTreeMap, BTreeSet, HashMap};
319
320        let mut out: BTreeMap<String, PathAuthor> = BTreeMap::new();
321        if paths.is_empty() {
322            return Ok(out);
323        }
324        let head = self.inner.head_id().map_err(ge)?.detach();
325
326        // A local handle carrying an object cache. The walk reads each commit's
327        // tree and its parents' trees, and every parent is itself visited later
328        // in the walk, so without a cache the same objects are decoded twice.
329        let mut repo = self.inner.clone();
330        repo.object_cache_size_if_unset(8 * 1024 * 1024);
331
332        // The shallow boundary, where the history a comparison needs is absent.
333        // In a `fetch-depth: 1` checkout the paths that would be misattributed
334        // there are *every* path, credited to whoever made the one commit
335        // present — precisely the false claim this method exists to stop — so a
336        // boundary commit resolves nothing.
337        //
338        // Two mechanisms refuse it, and measured by injection **either alone is
339        // enough**: deleting this check leaves
340        // `a_shallow_clone_claims_no_human_verifier_rather_than_the_wrong_one`
341        // green, and so does reverting the all-or-nothing parent read below.
342        // Only removing both makes it fail. The reason is that gix does not
343        // report a boundary commit as parentless: it lists the parent ids, and
344        // the *objects* behind them are what is missing, so the read below
345        // already declines. This check is kept as the one that does not depend
346        // on an object lookup failing — it names the condition git itself
347        // records, and it is the cheaper of the two.
348        let boundary: BTreeSet<gix::ObjectId> = repo
349            .shallow_commits()
350            .map_err(ge)?
351            .map(|c| c.iter().copied().collect())
352            .unwrap_or_default();
353
354        let mut pending: BTreeSet<&str> = paths.iter().map(String::as_str).collect();
355        // (tree id, path) -> blob id there. Keyed on the *tree* rather than the
356        // commit so a parent looked up as a parent and later walked as a commit
357        // is one lookup, not two.
358        let mut memo: HashMap<(gix::ObjectId, &str), Option<gix::ObjectId>> = HashMap::new();
359
360        let walk = repo
361            .rev_walk([head])
362            .sorting(gix::revision::walk::Sorting::ByCommitTime(
363                gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
364            ))
365            .all()
366            .map_err(ge)?;
367
368        for info in walk {
369            if pending.is_empty() {
370                break;
371            }
372            let info = info.map_err(ge)?;
373            if boundary.contains(&info.id) {
374                continue;
375            }
376            let commit = info.object().map_err(ge)?;
377            let tree_id = commit.tree_id().map_err(ge)?.detach();
378            // **Every** parent, or none of this commit's answers. A parent that
379            // cannot be read is not a parent that is absent: dropping it silently
380            // shrinks the set this commit is compared against, and dropping the
381            // last one turns the commit into an apparent root that introduced
382            // everything it contains. Both directions produce an attribution, and
383            // an attribution derived from history we could not read is the false
384            // claim this whole method exists to avoid. A partial clone reaches
385            // here without a `shallow` file to warn us.
386            let parent_trees: Option<Vec<gix::ObjectId>> = info
387                .parent_ids
388                .iter()
389                .map(|id| {
390                    repo.find_object(*id)
391                        .ok()
392                        .and_then(|o| o.try_into_commit().ok())
393                        .and_then(|c| c.tree_id().ok())
394                        .map(gix::Id::detach)
395                })
396                .collect();
397            // `collect` into an `Option<Vec<_>>` is the all-or-nothing above: one
398            // unreadable parent makes the whole set `None`.
399            let Some(parent_trees) = parent_trees else {
400                continue;
401            };
402
403            let mut resolved: Vec<&str> = Vec::new();
404            for path in &pending {
405                let here = blob_at(&repo, &mut memo, tree_id, path)?;
406                // A root commit introduces whatever it contains; otherwise the
407                // commit changed the path only if no parent already had this blob.
408                let changed = if parent_trees.is_empty() {
409                    here.is_some()
410                } else {
411                    let mut differs = true;
412                    for parent in &parent_trees {
413                        if blob_at(&repo, &mut memo, *parent, path)? == here {
414                            differs = false;
415                            break;
416                        }
417                    }
418                    differs
419                };
420                if changed {
421                    resolved.push(path);
422                }
423            }
424            if resolved.is_empty() {
425                continue;
426            }
427            // Read the author only once a path actually resolved: it decodes the
428            // commit's header, and most commits in the walk touch none of `paths`.
429            let author = commit
430                .author()
431                .ok()
432                .map(|a| a.name.to_string())
433                .filter(|n| !n.trim().is_empty());
434            let at = commit.time().map_err(ge)?.seconds;
435            for path in resolved {
436                pending.remove(path);
437                if let Some(name) = author.clone() {
438                    out.insert(path.to_owned(), PathAuthor { name, at });
439                }
440            }
441        }
442        Ok(out)
443    }
444
445    /// The `origin` remote's fetch URL, if one is configured — e.g. to derive a
446    /// web "blob" base for source links. `None` when there is no `origin` remote.
447    #[must_use]
448    pub fn origin_url(&self) -> Option<String> {
449        let remote = self.inner.find_remote("origin").ok()?;
450        let url = remote.url(gix::remote::Direction::Fetch)?;
451        Some(url.to_bstring().to_string())
452    }
453
454    /// Every blob reachable from the `HEAD` tree, with full paths.
455    ///
456    /// # Errors
457    /// Returns [`GitError`] if the tree cannot be traversed or a path is not
458    /// valid UTF-8.
459    pub fn walk_blobs(&self) -> Result<Vec<BlobRef>, GitError> {
460        let tree = self.inner.head_tree().map_err(ge)?;
461        walk_tree_blobs(&tree)
462    }
463
464    /// Every blob reachable from an arbitrary commit-or-tree `rev` (a hex oid),
465    /// with full paths — like [`Repo::walk_blobs`] but for any point in history,
466    /// not just `HEAD`. A commit oid is peeled to its tree, so a submodule pin (a
467    /// commit sha) works directly. The primitive for extracting a repo's graph at
468    /// the version a spoke pins (ADR-0009 step 8 — version-pin resolution).
469    ///
470    /// # Errors
471    /// Returns [`GitError`] if `rev` cannot be resolved to a tree, the tree cannot
472    /// be traversed, or a path is not valid UTF-8.
473    pub fn blobs_at(&self, rev: &str) -> Result<Vec<BlobRef>, GitError> {
474        let tree = self.tree_by_rev(rev)?;
475        walk_tree_blobs(&tree)
476    }
477
478    /// The hex tree id an arbitrary revspec resolves to (a commit peels to its
479    /// tree) — an **O(1)** resolution that does not walk the tree, so it doubles as
480    /// a cheap "does this ref exist?" check (ADR-0009 step 8b/8c).
481    ///
482    /// # Errors
483    /// Returns [`GitError`] if `rev` cannot be resolved to a tree.
484    pub fn tree_id_at(&self, rev: &str) -> Result<String, GitError> {
485        Ok(self.tree_by_rev(rev)?.id().to_hex().to_string())
486    }
487
488    /// Every git submodule pinned in the `HEAD` tree, sorted by path: a gitlink
489    /// (commit) entry gives the path and the commit it points at, enriched with
490    /// its `.gitmodules` URL when declared. The pinned commit is the **version a
491    /// deployment repo ships** (ADR-0009 derived facts). Empty when there are none.
492    ///
493    /// # Errors
494    /// Returns [`GitError`] if the tree cannot be traversed, `.gitmodules` cannot
495    /// be read, or a path is not valid UTF-8.
496    pub fn submodules(&self, paths: &crate::PathPolicy) -> Result<Vec<Submodule>, GitError> {
497        let tree = self.inner.head_tree().map_err(ge)?;
498        self.submodules_in_tree(&tree, paths)
499    }
500
501    /// Every git submodule pinned at an arbitrary commit/tree `rev`, sorted by path
502    /// — like [`Repo::submodules`] but for a historical point, so a hub graph
503    /// extracted at a pinned version (ADR-0009 step 8) carries its own submodules
504    /// as they were then.
505    ///
506    /// # Errors
507    /// As [`Repo::submodules`], plus if `rev` cannot be resolved to a tree.
508    pub fn submodules_at(
509        &self,
510        rev: &str,
511        paths: &crate::PathPolicy,
512    ) -> Result<Vec<Submodule>, GitError> {
513        let tree = self.tree_by_rev(rev)?;
514        self.submodules_in_tree(&tree, paths)
515    }
516
517    /// Collect the submodule gitlinks (and `.gitmodules` URLs) in `tree`.
518    fn submodules_in_tree(
519        &self,
520        tree: &gix::Tree<'_>,
521        paths: &crate::PathPolicy,
522    ) -> Result<Vec<Submodule>, GitError> {
523        let mut recorder = gix::traverse::tree::Recorder::default();
524        tree.traverse().breadthfirst(&mut recorder).map_err(ge)?;
525
526        let mut links: Vec<(String, String)> = Vec::new();
527        let mut gitmodules: Option<gix::ObjectId> = None;
528        for entry in &recorder.records {
529            if entry.mode.is_commit() {
530                let path = String::from_utf8(entry.filepath.clone().into())
531                    .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
532                links.push((path, entry.oid.to_hex().to_string()));
533            } else if entry.mode.is_blob() && entry.filepath.as_slice() == b".gitmodules" {
534                gitmodules = Some(entry.oid);
535            }
536        }
537        self.assemble_submodules(links, gitmodules, paths)
538    }
539
540    /// Every git submodule pinned in the **staged index** (the tree a commit would
541    /// record), sorted by path. Same shape as [`Repo::submodules`] but reads the
542    /// gitlinks (and `.gitmodules`) from the index, so the index-aware sync — the
543    /// pre-commit gate — reflects a *staged* submodule bump, not the `HEAD` pin.
544    ///
545    /// # Errors
546    /// As [`Repo::submodules`], plus index-load failure.
547    pub fn index_submodules(&self, paths: &crate::PathPolicy) -> Result<Vec<Submodule>, GitError> {
548        use gix::index::entry::Mode;
549        let index = self.inner.index_or_load_from_head().map_err(ge)?;
550        let mut links: Vec<(String, String)> = Vec::new();
551        let mut gitmodules: Option<gix::ObjectId> = None;
552        for entry in index.entries() {
553            if entry.stage_raw() != 0 {
554                continue;
555            }
556            let path = String::from_utf8(entry.path(&index).to_vec())
557                .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
558            if entry.mode == Mode::COMMIT {
559                links.push((path, entry.id.to_hex().to_string()));
560            } else if path == ".gitmodules"
561                && matches!(entry.mode, Mode::FILE | Mode::FILE_EXECUTABLE)
562            {
563                gitmodules = Some(entry.id);
564            }
565        }
566        self.assemble_submodules(links, gitmodules, paths)
567    }
568
569    /// Assemble `(path, sha)` gitlinks into sorted [`Submodule`]s, resolving each
570    /// path's URL from the `.gitmodules` blob at `gitmodules` (when present). Shared
571    /// by the `HEAD`-tree and index submodule readers.
572    ///
573    /// # Two paths, two declarations
574    ///
575    /// This is the one production reader in the workspace that reads **one** file
576    /// to derive facts about **another**: `.gitmodules` supplies the URLs, and the
577    /// facts are about `vendor/dep`. Two repo-relative paths therefore appear, a
578    /// repository may declare either, and they mean different things — so `paths`
579    /// is consulted for the *source* here and for the *subject* where the nodes
580    /// are built (`crate::sync::append_submodule_nodes`). Gating one key and not
581    /// the other was a live defect: `exclude = [".gitmodules"]` still read the
582    /// blob and still produced nodes, and `opaque = [".gitmodules"]` still put
583    /// URLs parsed out of it into the graph.
584    ///
585    /// The two classes act on the two questions the policy already asks:
586    ///
587    /// * [`crate::PathClass::reads`] is false for `exclude`, so the blob is not
588    ///   read at all — which is what makes "the bytes are never read" true rather
589    ///   than nearly true.
590    /// * [`crate::PathClass::mines`] is false for `opaque` as well, so no URL is
591    ///   derived. The path and sha survive, because those are **gitlink** facts
592    ///   read out of the tree rather than claims made by `.gitmodules`.
593    fn assemble_submodules(
594        &self,
595        links: Vec<(String, String)>,
596        gitmodules: Option<gix::ObjectId>,
597        paths: &crate::PathPolicy,
598    ) -> Result<Vec<Submodule>, GitError> {
599        if links.is_empty() {
600            return Ok(Vec::new());
601        }
602        let mines_gitmodules = paths.classify(GITMODULES).mines();
603        let urls = match gitmodules {
604            // `mines()` rather than `reads()`: an opaque `.gitmodules` may be read
605            // for its own identity, but nothing may be derived from what it says,
606            // and a URL is exactly that. Skipping the read for both classes is the
607            // stronger half of the same answer and costs nothing here — there is
608            // no other fact this blob contributes.
609            Some(oid) if mines_gitmodules => {
610                let bytes = self.read_blob(&oid.to_hex().to_string())?;
611                parse_gitmodules(&String::from_utf8_lossy(&bytes))
612            }
613            _ => std::collections::HashMap::new(),
614        };
615        let mut out: Vec<Submodule> = links
616            .into_iter()
617            .map(|(path, sha)| {
618                let url = urls.get(&path).cloned();
619                Submodule { path, sha, url }
620            })
621            .collect();
622        out.sort_by(|a, b| a.path.cmp(&b.path));
623        Ok(out)
624    }
625
626    /// **What a `--base <spec>` actually bound to** — the ref, the commit, and how
627    /// that commit stands against its upstream (issue #649).
628    ///
629    /// # The defect this exists to make visible
630    ///
631    /// [`Repo::changed_between`] resolves its base with `rev_parse_single`, so the
632    /// bare name `main` binds to the **local branch**, never to
633    /// `refs/remotes/origin/main`. That is correct for `rev_parse_single` and it is
634    /// what git itself does; the problem was that nothing surfaced the consequence.
635    /// A local `main` seventeen commits behind its upstream answers a *different
636    /// question* from the one that was asked, and answers it in output textually
637    /// identical to a correct run: `review --base main` reported 33 changed files
638    /// where the real footprint was 2, with `drift: []` and exit 0.
639    ///
640    /// Rebasing does not save you, which is what made it durable — rebasing the
641    /// branch does not move the local `main` ref.
642    ///
643    /// # Why a superset is the *silent* case and divergence is the dangerous one
644    ///
645    /// When the base is an **ancestor** of its upstream ([`Upstream::ahead`] is 0),
646    /// the diff is a strict superset of the true one: more files, all of yours
647    /// included. Every drift item that would have been found is still found, so the
648    /// gate still holds and the run looks *more* thorough rather than less. Nothing
649    /// fails, which is why it went unnoticed for a whole session.
650    ///
651    /// When the two have **diverged** ([`Upstream::ahead`] and [`Upstream::behind`]
652    /// are both non-zero), the diff can *omit* changes that exist on both sides of
653    /// the fork, and then a clean verdict is worthless rather than merely wide.
654    /// [`Upstream::diverged`] separates the two so a caller can say different
655    /// things about them.
656    ///
657    /// # Errors
658    /// Returns [`GitError::Git`] if `spec` cannot be resolved to a single commit,
659    /// or if a reachability walk against the upstream fails.
660    pub fn resolve_base(&self, spec: &str) -> Result<BaseResolution, GitError> {
661        // Two reads of one spec, deliberately. `rev_parse` is the only one that
662        // reports *which ref* the name bound to — the whole point here, since
663        // `main` and `origin/main` are the two answers a reader has to be able to
664        // tell apart — while `single()` yields the same commit `changed_between`
665        // resolves. A spec that names no ref at all (a raw sha, `HEAD~3`) leaves
666        // `reference` as `None`, which is honest: there is no upstream question to
667        // ask about a commit nobody named.
668        let parsed = self.inner.rev_parse(spec).map_err(ge)?;
669        let reference = parsed
670            .first_reference()
671            .map(|r| r.name.as_bstr().to_string());
672        let oid = parsed
673            .single()
674            .ok_or_else(|| GitError::Git(format!("`{spec}` names a range, not a single commit")))?
675            .detach();
676
677        let upstream = match reference.as_deref() {
678            Some(name) => self.upstream_of(name, oid)?,
679            None => None,
680        };
681        Ok(BaseResolution {
682            spec: spec.to_owned(),
683            reference,
684            commit: oid.to_hex().to_string(),
685            upstream,
686        })
687    }
688
689    /// The remote-tracking ref configured for the local branch `reference`, with
690    /// the two reachability counts, or `None` when there is no upstream to compare
691    /// against.
692    ///
693    /// `None` is the ordinary answer for most inputs and is never an error: a
694    /// remote-tracking ref (`origin/main`) has no upstream of its own, a tag has
695    /// none, and a local branch that was never pushed has none. A caller that
696    /// cannot find out whether a base is stale must proceed exactly as it did
697    /// before rather than refuse, so every failure to answer here resolves to
698    /// `None` — the base is still resolved and still reported, and only the
699    /// staleness check is missing.
700    fn upstream_of(
701        &self,
702        reference: &str,
703        base: gix::ObjectId,
704    ) -> Result<Option<Upstream>, GitError> {
705        let Ok(full) = gix::refs::FullName::try_from(reference) else {
706            return Ok(None);
707        };
708        // `Fetch`, not `Push`: the question is "has the world moved on since this
709        // ref last caught up", which is what a fetch would bring in. A `pushRemote`
710        // pointing elsewhere does not make the base any less stale.
711        // A misconfigured tracking setting — a `branch.<name>.merge` that no fetch
712        // refspec maps — lands here as `Some(Err(_))` and is treated as "no
713        // upstream", not as a failed review.
714        let Some(Ok(tracking)) = self
715            .inner
716            .branch_remote_tracking_ref_name(full.as_ref(), gix::remote::Direction::Fetch)
717        else {
718            return Ok(None);
719        };
720        let Ok(mut tracking_ref) = self.inner.find_reference(tracking.as_ref()) else {
721            // Configured but not present: a branch whose upstream has never been
722            // fetched into this clone. Nothing to compare against.
723            return Ok(None);
724        };
725        let Ok(upstream_id) = tracking_ref.peel_to_id() else {
726            return Ok(None);
727        };
728        let upstream = upstream_id.detach();
729
730        // The overwhelmingly common case, and it costs nothing: an up-to-date base
731        // needs no traversal at all. Short-circuited rather than left to the walk
732        // because `with_hidden` is documented to be able to visit every commit when
733        // the two sides are disjoint, and paying that on every `--base main` of a
734        // healthy branch would be a real cost for a guaranteed pair of zeroes.
735        let (behind, ahead) = if upstream == base {
736            (0, 0)
737        } else {
738            (
739                self.count_reachable(upstream, base)?,
740                self.count_reachable(base, upstream)?,
741            )
742        };
743        Ok(Some(Upstream {
744            reference: tracking.as_bstr().to_string(),
745            commit: upstream.to_hex().to_string(),
746            behind,
747            ahead,
748        }))
749    }
750
751    /// Commits reachable from `tip` but not from `hidden` — `git rev-list --count
752    /// hidden..tip`.
753    fn count_reachable(
754        &self,
755        tip: gix::ObjectId,
756        hidden: gix::ObjectId,
757    ) -> Result<usize, GitError> {
758        let walk = self
759            .inner
760            .rev_walk([tip])
761            .with_hidden([hidden])
762            .all()
763            .map_err(ge)?;
764        let mut n = 0usize;
765        for info in walk {
766            info.map_err(ge)?;
767            n += 1;
768        }
769        Ok(n)
770    }
771
772    /// The tracked files that differ between `base` (any revspec — a branch,
773    /// `HEAD~3`, a sha) and the current `HEAD`, sorted by path. Used for
774    /// change-scoped tooling over a commit range (e.g. `roteiro review --base
775    /// main`), distinct from [`Repo::changed_files`], which compares the working
776    /// tree to `HEAD`. A path only in `HEAD` is added, only in `base` is deleted.
777    ///
778    /// Callers that report *what they compared against* should resolve the spec
779    /// once with [`Repo::resolve_base`] and pass [`BaseResolution::commit`] here,
780    /// so the commit named in the report and the commit actually diffed cannot be
781    /// two different answers to one question.
782    ///
783    /// # Errors
784    /// Returns [`GitError`] if `base` cannot be resolved to a tree, a tree cannot
785    /// be traversed, or a path is not valid UTF-8.
786    pub fn changed_between(&self, base: &str) -> Result<Vec<ChangedFile>, GitError> {
787        let base_tree = self
788            .inner
789            .rev_parse_single(base)
790            .map_err(ge)?
791            .object()
792            .map_err(ge)?
793            .peel_to_tree()
794            .map_err(ge)?;
795        let base_oid = base_tree.id().to_hex().to_string();
796        let head_oid = self.head_tree_id()?;
797
798        // Reuse the subtree-pruning tree diff, then flatten to the `ChangedFile`
799        // (path, status) shape this API exposes. `diff_trees` already sorts and
800        // prunes unchanged subtrees, so this is O(change), not a full walk.
801        let diff = self.diff_trees(&base_oid, &head_oid)?;
802        // A tree diff's `changed` set conflates genuinely-new files with edits to
803        // existing ones, so range review labels them `Modified` rather than
804        // distinguishing `Added` (which would need the base file set).
805        let mut out: Vec<ChangedFile> = diff
806            .changed
807            .into_iter()
808            .map(|b| ChangedFile {
809                path: b.path,
810                status: ChangeStatus::Modified,
811            })
812            .chain(diff.deleted.into_iter().map(|path| ChangedFile {
813                path,
814                status: ChangeStatus::Deleted,
815            }))
816            .collect();
817        out.sort_by(|a, b| a.path.cmp(&b.path));
818        Ok(out)
819    }
820
821    /// Read the bytes of the blob with hex object id `oid`.
822    ///
823    /// # Errors
824    /// Returns [`GitError::Git`] if the id is malformed or the object is absent.
825    pub fn read_blob(&self, oid: &str) -> Result<Vec<u8>, GitError> {
826        let id = gix::ObjectId::from_hex(oid.as_bytes()).map_err(ge)?;
827        // `detach()` moves the owned data out without cloning; `Object` itself
828        // implements `Drop`, so the bare field cannot be moved out directly.
829        Ok(self.inner.find_object(id).map_err(ge)?.detach().data)
830    }
831
832    /// The bytes of a tracked file's **authored source**, from the tree named by
833    /// `source`: the committed `HEAD` blob, the staged blob, or the file as it
834    /// stands on disk (unstaged edits included, and *not* the git index).
835    ///
836    /// The `Worktree` reading matches [`crate::sync_worktree`], which the derived
837    /// graph is built from, so the authored and derived layers stay consistent —
838    /// see [`GraphSource`] for why that pairing is one type rather than two
839    /// independent choices.
840    ///
841    /// Returns `Ok(None)` when a worktree file has been deleted, so the caller
842    /// drops it.
843    ///
844    /// # Errors
845    /// Returns [`GitError::Git`] if the blob cannot be read, or if reading the
846    /// working-tree copy fails for any reason other than the file being absent.
847    pub fn read_source(
848        &self,
849        blob: &BlobRef,
850        source: GraphSource,
851    ) -> Result<Option<Vec<u8>>, GitError> {
852        match source {
853            // Worktree: the file as it stands on disk (unstaged edits included),
854            // or `None` if it was deleted there.
855            GraphSource::Worktree => match self.workdir() {
856                Some(workdir) => match std::fs::read(workdir.join(&blob.path)) {
857                    Ok(bytes) => Ok(Some(bytes)),
858                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
859                    // Folded into `GitError::Git` with the path rather than
860                    // carried as its own variant: `GitError` is not
861                    // `#[non_exhaustive]`, so a new variant would break every
862                    // downstream exhaustive match for a message this already
863                    // preserves.
864                    Err(e) => Err(GitError::Git(format!("reading {}: {e}", blob.path))),
865                },
866                None => Ok(Some(self.read_blob(&blob.oid)?)),
867            },
868            // Committed reads the `HEAD` blob; Index reads the staged blob — for
869            // both, `blob.oid` is already the right object (the blob list came
870            // from that tree), so read it directly.
871            GraphSource::Committed | GraphSource::Index => Ok(Some(self.read_blob(&blob.oid)?)),
872        }
873    }
874
875    /// Tracked files whose working-tree content differs from `HEAD` — the change
876    /// about to be committed. A file is *changed* when its working-copy bytes hash
877    /// to a different blob id than the committed one (content, not mtime), and
878    /// *deleted* when it is absent from the working tree. Untracked new files are
879    /// not reported (they are not in the `HEAD` tree). Same detection as
880    /// [`crate::sync_worktree`], surfaced for change-scoped tooling.
881    ///
882    /// # Errors
883    /// Returns [`GitError`] on a git failure. In a bare repo (no working tree)
884    /// the change set is empty.
885    pub fn changed_files(&self, paths: &crate::PathPolicy) -> Result<Vec<ChangedFile>, GitError> {
886        let mut out = Vec::new();
887        let Some(workdir) = self.workdir() else {
888            return Ok(out);
889        };
890        for blob in self.walk_blobs()? {
891            // Asked **before** the read, not after. This walk hashes every
892            // tracked path's working-tree bytes to find the dirty ones, so a
893            // caller that filtered the result instead would still have read every
894            // byte of an excluded corpus — and `exclude`'s contract is that the
895            // bytes are never read, which is the half that matters when the
896            // corpus is gigabytes of PDFs (ADR-0007 `[paths]`).
897            if !paths.classify(&blob.path).reads() {
898                continue;
899            }
900            match std::fs::read(workdir.join(&blob.path)) {
901                Ok(bytes) => {
902                    if self.blob_oid(&bytes)? != blob.oid {
903                        out.push(ChangedFile {
904                            path: blob.path,
905                            status: ChangeStatus::Modified,
906                        });
907                    }
908                }
909                Err(e) if e.kind() == std::io::ErrorKind::NotFound => out.push(ChangedFile {
910                    path: blob.path,
911                    status: ChangeStatus::Deleted,
912                }),
913                Err(e) => return Err(GitError::Git(e.to_string())),
914            }
915        }
916        // `walk_blobs` order is an implementation detail; sort so `roteiro review`
917        // output is deterministic across platforms and gix versions.
918        out.sort_by(|a, b| a.path.cmp(&b.path));
919        Ok(out)
920    }
921
922    /// The **staged** files: each regular blob in the git index with its staged
923    /// object id, sorted by path. This is the tree that a commit would record —
924    /// unlike [`Repo::changed_files`] (the working tree) — so it lets tooling gate
925    /// exactly what is about to be committed (the pre-commit index-aware `check`).
926    /// Conflict (unmerged) entries, directories, submodules and symlinks are
927    /// skipped.
928    ///
929    /// # Errors
930    /// Returns [`GitError`] if the index cannot be loaded or a path is not valid
931    /// UTF-8.
932    pub fn index_files(&self) -> Result<Vec<BlobRef>, GitError> {
933        use gix::index::entry::Mode;
934        let index = self.inner.index_or_load_from_head().map_err(ge)?;
935        let mut out = Vec::new();
936        for entry in index.entries() {
937            if entry.stage_raw() != 0 || !matches!(entry.mode, Mode::FILE | Mode::FILE_EXECUTABLE) {
938                continue;
939            }
940            let path = String::from_utf8(entry.path(&index).to_vec())
941                .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
942            out.push(BlobRef {
943                path,
944                oid: entry.id.to_hex().to_string(),
945            });
946        }
947        out.sort_by(|a, b| a.path.cmp(&b.path));
948        Ok(out)
949    }
950
951    /// Every path the working tree has that `head_paths` does not — the files
952    /// a commit would **add**, whether or not they have been staged yet.
953    ///
954    /// # Why this exists rather than `untracked_files` alone
955    ///
956    /// The obvious spelling of "new files in the working tree" is
957    /// [`Repo::untracked_files`], and it is wrong in a way that reads as
958    /// correct. The two sets classify against **different trees**:
959    /// `untracked_files` is defined against the **index**, and a caller's
960    /// `head_paths` comes from **HEAD**. So `git add` on a new file removes it
961    /// from the untracked set without adding it to HEAD, and the union of the
962    /// two has a hole exactly the size of "staged, not yet committed".
963    ///
964    /// That hole has been found three times, in three surfaces, each time as a
965    /// silent wrong answer rather than a failure:
966    ///
967    /// - issue #636 — `sync` deleted a node from the graph on `git add`;
968    /// - issue #649 — `review` said "no working-tree changes to review" on a
969    ///   tree with a staged addition in it;
970    /// - issue #657 — `check` reported **0 violations** on drift it had caught
971    ///   one `git add` earlier, silencing the gate at the moment it matters most.
972    ///
973    /// Each was fixed where it was found, which left three copies of one rule.
974    /// This is the rule, once, so the fourth surface inherits it instead of
975    /// re-deriving it.
976    ///
977    /// `head_paths` is a parameter rather than something walked here because
978    /// every caller already holds HEAD's paths for its own reasons; walking the
979    /// tree again to re-derive them would make the shared version cost more than
980    /// the copies it replaces.
981    ///
982    /// `.gitignore` is honoured, and the union states *how*: an ignored file is
983    /// absent from the dirwalk, so it enters only by being in the index — which
984    /// takes a deliberate `git add -f`. That is the right outcome rather than a
985    /// leak, because force-adding overrides the ignore and the file will be
986    /// committed regardless.
987    ///
988    /// # Errors
989    /// Returns [`GitError`] if the dirwalk or the index cannot be read.
990    pub fn added_since_head(
991        &self,
992        head_paths: &std::collections::BTreeSet<&str>,
993    ) -> Result<std::collections::BTreeSet<String>, GitError> {
994        // **Both** sources are filtered against HEAD, not only the index one.
995        // `untracked_files` classifies against the index, so a path can be in
996        // HEAD *and* reported untracked simultaneously: `git rm --cached f`
997        // drops `f` from the index and leaves it on disk, and git then calls it
998        // untracked while HEAD still carries it. Taking that set wholesale
999        // labels a tracked file an addition. Found by Copilot on #656 and fixed
1000        // there inline; collapsing the call sites onto this helper would have
1001        // undone it, which is what the rebase conflict was really about.
1002        let mut out: std::collections::BTreeSet<String> = self
1003            .untracked_files()?
1004            .into_iter()
1005            .filter(|p| !head_paths.contains(p.as_str()))
1006            .collect();
1007        for entry in self.index_files()? {
1008            if !head_paths.contains(entry.path.as_str()) {
1009                out.insert(entry.path);
1010            }
1011        }
1012        Ok(out)
1013    }
1014
1015    /// Untracked, non-ignored regular files in the working tree: everything the
1016    /// dirwalk finds that the **index** does not carry.
1017    ///
1018    /// Not "files in neither `HEAD` nor the index", which this said until #662
1019    /// pointed at the contradiction with [`Repo::added_since_head`] directly
1020    /// above. The set is defined against the index *alone*, so a path can be in
1021    /// `HEAD` and in here at once: `git rm --cached f` drops `f` from the index
1022    /// and leaves it on disk, and git then reports it untracked while `HEAD`
1023    /// still carries it.
1024    ///
1025    /// **This is not "the new files in the working tree".** It is defined against
1026    /// the **index**, so `git add` removes a file from it. A caller that unions
1027    /// this with a HEAD-derived set has a hole exactly the size of "staged, not
1028    /// yet committed" — which is issues #636, #649 and #657, three surfaces that
1029    /// each made that union by hand and each gave a silently wrong answer. Use
1030    /// [`Repo::added_since_head`] instead; it is that union, correct, in one place.
1031    ///
1032    /// Respects `.gitignore` / `.git/info/exclude` / global excludes, skips nested
1033    /// repositories and non-regular files (symlinks, dirs, submodules), and returns
1034    /// repository-relative, unix-separated paths, sorted. Empty in a bare repo.
1035    ///
1036    /// # Errors
1037    /// Returns [`GitError`] on a git failure or a non-UTF-8 path.
1038    pub fn untracked_files(&self) -> Result<Vec<String>, GitError> {
1039        use gix::dir::entry::{Kind, Status};
1040        use gix::dir::walk::EmissionMode;
1041
1042        if self.inner.workdir().is_none() {
1043            return Ok(Vec::new());
1044        }
1045        // Classify the working tree against the index; emit each untracked file
1046        // (not whole collapsed dirs), leaving ignored files unemitted (the default)
1047        // so `.gitignore` is honoured.
1048        let index = self.inner.index_or_empty().map_err(ge)?;
1049        let options = self
1050            .inner
1051            .dirwalk_options()
1052            .map_err(ge)?
1053            .emit_untracked(EmissionMode::Matching);
1054        // A never-set interrupt flag: the walk is a bounded, synchronous pass, so
1055        // there is nothing to cancel it from. (`gix` wants an owned/static flag;
1056        // its private wrapper type isn't nameable, so build one via `Arc`.)
1057        let never = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1058        let iter = self
1059            .inner
1060            .dirwalk_iter(index, std::iter::empty::<&str>(), never.into(), options)
1061            .map_err(ge)?;
1062
1063        let mut out = Vec::new();
1064        for item in iter {
1065            let entry = item.map_err(ge)?.entry;
1066            // Only brand-new regular files; symlinks/dirs/submodules are excluded
1067            // by the `File` disk kind, ignored files by the emission mode above.
1068            if entry.status == Status::Untracked && entry.disk_kind == Some(Kind::File) {
1069                let path = String::from_utf8(entry.rela_path.into())
1070                    .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
1071                out.push(path);
1072            }
1073        }
1074        out.sort();
1075        Ok(out)
1076    }
1077}
1078
1079/// Collect every blob reachable from `tree`, with full repository-relative paths.
1080fn walk_tree_blobs(tree: &gix::Tree<'_>) -> Result<Vec<BlobRef>, GitError> {
1081    let mut recorder = gix::traverse::tree::Recorder::default();
1082    tree.traverse().breadthfirst(&mut recorder).map_err(ge)?;
1083    let mut out = Vec::new();
1084    for entry in recorder.records {
1085        if !entry.mode.is_blob() {
1086            continue;
1087        }
1088        let path = String::from_utf8(entry.filepath.into())
1089            .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
1090        out.push(BlobRef {
1091            path,
1092            oid: entry.oid.to_hex().to_string(),
1093        });
1094    }
1095    Ok(out)
1096}
1097
1098/// A git submodule pinned in a tree: its repo-relative path, the commit it points
1099/// at (the gitlink oid — the **version pin** a deployment ships), and its
1100/// configured URL from `.gitmodules` when registered there.
1101#[derive(Debug, Clone, PartialEq, Eq)]
1102pub struct Submodule {
1103    /// Repo-relative path the submodule is mounted at.
1104    pub path: String,
1105    /// Hex commit id the gitlink points at (the pinned version).
1106    pub sha: String,
1107    /// The submodule's URL from `.gitmodules`, if declared there.
1108    pub url: Option<String>,
1109}
1110
1111/// Parse a `.gitmodules` file into a `path → url` map. INI-like: each
1112/// `[submodule "<name>"]` section carries a `path` and a `url`.
1113fn parse_gitmodules(text: &str) -> std::collections::HashMap<String, String> {
1114    let mut map = std::collections::HashMap::new();
1115    let (mut path, mut url) = (None, None);
1116    let mut in_submodule = false;
1117    let mut flush = |path: &mut Option<String>, url: &mut Option<String>| {
1118        if let (Some(p), Some(u)) = (path.take(), url.take()) {
1119            map.insert(p, u);
1120        }
1121    };
1122    for line in text.lines() {
1123        let line = line.trim();
1124        if line.starts_with('[') {
1125            flush(&mut path, &mut url);
1126            in_submodule = line.starts_with("[submodule");
1127        } else if in_submodule {
1128            if let Some(v) = line
1129                .strip_prefix("path")
1130                .and_then(|r| r.trim_start().strip_prefix('='))
1131            {
1132                path = Some(v.trim().to_owned());
1133            } else if let Some(v) = line
1134                .strip_prefix("url")
1135                .and_then(|r| r.trim_start().strip_prefix('='))
1136            {
1137                url = Some(v.trim().to_owned());
1138            }
1139        }
1140    }
1141    flush(&mut path, &mut url);
1142    map
1143}
1144
1145/// How a file changed relative to the comparison baseline — for review labelling.
1146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1147pub enum ChangeStatus {
1148    /// A new file, absent from the baseline (e.g. a brand-new untracked file).
1149    Added,
1150    /// Present on both sides, with different content.
1151    Modified,
1152    /// Removed from the working tree (or the `HEAD` side of a range).
1153    Deleted,
1154}
1155
1156impl ChangeStatus {
1157    /// Stable lowercase label (`added` | `modified` | `deleted`).
1158    #[must_use]
1159    pub fn as_str(self) -> &'static str {
1160        match self {
1161            Self::Added => "added",
1162            Self::Modified => "modified",
1163            Self::Deleted => "deleted",
1164        }
1165    }
1166}
1167
1168/// What a review's `--base <spec>` resolved to — see [`Repo::resolve_base`].
1169///
1170/// Serialisable because a report that does not say what it compared against
1171/// cannot be checked against the question it was asked. Issue #649's whole
1172/// complaint about `review` was that its output under-describes its own basis:
1173/// the diff was missing, and so was this.
1174#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1175pub struct BaseResolution {
1176    /// The revspec exactly as it was given on the command line.
1177    ///
1178    /// Kept beside [`BaseResolution::reference`] rather than replaced by it,
1179    /// because the gap between the two *is* the defect: `main` and
1180    /// `refs/heads/main` printed together are what let a reader see that they did
1181    /// not ask for `refs/remotes/origin/main`.
1182    pub spec: String,
1183    /// The full name of the ref the spec bound to (`refs/heads/main`), or `None`
1184    /// when the spec named no ref — a raw sha, `HEAD~3`.
1185    #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
1186    pub reference: Option<String>,
1187    /// The commit it resolved to, in full hex.
1188    pub commit: String,
1189    /// How that commit stands against its upstream, when it has one.
1190    #[serde(skip_serializing_if = "Option::is_none")]
1191    pub upstream: Option<Upstream>,
1192}
1193
1194impl BaseResolution {
1195    /// The first twelve hex digits of [`BaseResolution::commit`], for a summary
1196    /// line.
1197    ///
1198    /// Twelve rather than git's default seven: seven is chosen for typing, and
1199    /// this is chosen for *comparing* — the reader's next move is
1200    /// `git rev-parse --short main origin/main`, whose seven-digit answer is a
1201    /// prefix of this one, so the comparison still works in the direction it is
1202    /// actually made.
1203    #[must_use]
1204    pub fn short_commit(&self) -> &str {
1205        let n = self.commit.len().min(12);
1206        &self.commit[..n]
1207    }
1208}
1209
1210/// The remote-tracking ref a resolved base is measured against, and the two
1211/// reachability counts that say how far apart they are.
1212///
1213/// Both counts, never one. "Behind by 17" and "behind by 17, ahead by 3" are
1214/// different situations with different consequences — the first over-reports a
1215/// change, the second can *omit* it — and a single number could not tell them
1216/// apart. See [`Repo::resolve_base`].
1217#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1218pub struct Upstream {
1219    /// Full name of the tracking ref (`refs/remotes/origin/main`).
1220    #[serde(rename = "ref")]
1221    pub reference: String,
1222    /// The commit it points at, in full hex.
1223    pub commit: String,
1224    /// Commits the upstream has that the base does not — `base..upstream`.
1225    pub behind: usize,
1226    /// Commits the base has that the upstream does not — `upstream..base`.
1227    pub ahead: usize,
1228}
1229
1230impl Upstream {
1231    /// The first twelve hex digits of [`Upstream::commit`]; see
1232    /// [`BaseResolution::short_commit`].
1233    #[must_use]
1234    pub fn short_commit(&self) -> &str {
1235        let n = self.commit.len().min(12);
1236        &self.commit[..n]
1237    }
1238
1239    /// Whether the base is missing commits its upstream has — the diff is then a
1240    /// superset of the true one, which is the *silent* failure: more files, all of
1241    /// yours included, so nothing fails and the run reads as more thorough.
1242    #[must_use]
1243    pub fn is_behind(&self) -> bool {
1244        self.behind > 0
1245    }
1246
1247    /// Whether the two have genuinely forked. This is the case a clean verdict
1248    /// actively misleads about, because the diff can omit changes present on both
1249    /// sides of the fork.
1250    #[must_use]
1251    pub fn diverged(&self) -> bool {
1252        self.behind > 0 && self.ahead > 0
1253    }
1254}
1255
1256/// A file that differs between the working tree (or a base revision) and `HEAD`.
1257#[derive(Debug, Clone, PartialEq, Eq)]
1258pub struct ChangedFile {
1259    /// Repository-relative path.
1260    pub path: String,
1261    /// How the file changed.
1262    pub status: ChangeStatus,
1263}
1264
1265/// The blob-level difference between two trees: paths added or modified (with
1266/// their new blob oid) and paths deleted. See [`Repo::diff_trees`].
1267#[derive(Debug, Clone, Default, PartialEq, Eq)]
1268pub struct TreeDiff {
1269    /// Blobs whose *tree entry* differs from the old tree — a changed blob oid,
1270    /// or a mode change (e.g. the executable bit) on otherwise-identical content —
1271    /// as `(path, new blob oid)`. These are the paths to re-extract; a mode-only
1272    /// change re-extracts to identical facts (extraction is content-addressed), a
1273    /// harmless cache hit.
1274    pub changed: Vec<BlobRef>,
1275    /// Blobs present in the old tree but absent from the new — paths whose facts
1276    /// must be dropped.
1277    pub deleted: Vec<String>,
1278}
1279
1280impl Repo {
1281    /// The blob-level diff between two tree object ids (`old` → `new`), pruning
1282    /// unchanged subtrees: gix descends only into subtrees whose oid differs, so
1283    /// the cost is proportional to the *change*, not the tree size. Renames are
1284    /// reported as a delete plus an add (rewrite tracking is off), which is what
1285    /// the path-scoped extractor wants. Results are sorted by path for determinism.
1286    ///
1287    /// This is the incremental-sync counterpart to [`Repo::walk_blobs`]: given the
1288    /// last-synced tree and `HEAD`, it yields exactly the paths that changed.
1289    ///
1290    /// # Errors
1291    /// Returns [`GitError`] if either id is not a tree, the diff fails, or a path
1292    /// is not valid UTF-8.
1293    pub fn diff_trees(&self, old: &str, new: &str) -> Result<TreeDiff, GitError> {
1294        let old_tree = self.tree_by_hex(old)?;
1295        let new_tree = self.tree_by_hex(new)?;
1296
1297        let mut changed = Vec::new();
1298        let mut deleted = Vec::new();
1299        let mut err: Option<GitError> = None;
1300
1301        let mut platform = old_tree.changes().map_err(ge)?;
1302        platform.options(|o| {
1303            o.track_rewrites(None);
1304        });
1305        platform
1306            .for_each_to_obtain_tree(&new_tree, |change| {
1307                use gix::object::tree::diff::Change;
1308                let record = |path: &gix::bstr::BStr| -> Result<String, GitError> {
1309                    String::from_utf8(path.to_vec())
1310                        .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))
1311                };
1312                match change {
1313                    Change::Addition {
1314                        location,
1315                        entry_mode,
1316                        id,
1317                        ..
1318                    }
1319                    | Change::Modification {
1320                        location,
1321                        entry_mode,
1322                        id,
1323                        ..
1324                    } => {
1325                        if entry_mode.is_blob() {
1326                            match record(location) {
1327                                Ok(path) => changed.push(BlobRef {
1328                                    path,
1329                                    oid: id.to_hex().to_string(),
1330                                }),
1331                                Err(e) => err = Some(e),
1332                            }
1333                        }
1334                    }
1335                    Change::Deletion {
1336                        location,
1337                        entry_mode,
1338                        ..
1339                    } => {
1340                        if entry_mode.is_blob() {
1341                            match record(location) {
1342                                Ok(path) => deleted.push(path),
1343                                Err(e) => err = Some(e),
1344                            }
1345                        }
1346                    }
1347                    // Rewrite tracking is disabled, so renames arrive as
1348                    // Deletion + Addition; this arm is unreachable in practice.
1349                    Change::Rewrite { .. } => {}
1350                }
1351                Ok::<_, std::convert::Infallible>(gix::object::tree::diff::Action::Continue(()))
1352            })
1353            .map_err(ge)?;
1354
1355        if let Some(e) = err {
1356            return Err(e);
1357        }
1358        changed.sort_by(|a, b| a.path.cmp(&b.path));
1359        deleted.sort();
1360        Ok(TreeDiff { changed, deleted })
1361    }
1362
1363    /// Resolve a hex object id to a [`gix::Tree`].
1364    fn tree_by_hex(&self, hex: &str) -> Result<gix::Tree<'_>, GitError> {
1365        let id = gix::ObjectId::from_hex(hex.as_bytes()).map_err(ge)?;
1366        self.inner
1367            .find_object(id)
1368            .map_err(ge)?
1369            .peel_to_tree()
1370            .map_err(ge)
1371    }
1372
1373    /// Resolve **any git revspec** — a sha, a tag, a branch, `HEAD~1` — to its
1374    /// tree. Unlike [`Repo::tree_by_hex`] (raw oids only), this accepts the tag /
1375    /// branch names the pinned-version resolution (`--hub-rev`, an image tag) can
1376    /// carry. Mirrors the resolution in [`Repo::changed_between`].
1377    fn tree_by_rev(&self, rev: &str) -> Result<gix::Tree<'_>, GitError> {
1378        self.inner
1379            .rev_parse_single(rev)
1380            .map_err(ge)?
1381            .object()
1382            .map_err(ge)?
1383            .peel_to_tree()
1384            .map_err(ge)
1385    }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::parse_gitmodules;
1391
1392    #[test]
1393    fn parse_gitmodules_maps_path_to_url_in_either_field_order() {
1394        let text = "\
1395[submodule \"vendor/app\"]\n\
1396\tpath = vendor/app\n\
1397\turl = https://github.com/acme/app.git\n\
1398[submodule \"libs/util\"]\n\
1399\turl = git@github.com:acme/util.git\n\
1400\tpath = libs/util\n";
1401        let map = parse_gitmodules(text);
1402        assert_eq!(
1403            map.get("vendor/app").map(String::as_str),
1404            Some("https://github.com/acme/app.git")
1405        );
1406        // URL declared before path in its section still maps.
1407        assert_eq!(
1408            map.get("libs/util").map(String::as_str),
1409            Some("git@github.com:acme/util.git")
1410        );
1411        assert_eq!(map.len(), 2);
1412    }
1413
1414    #[test]
1415    fn parse_gitmodules_ignores_non_submodule_sections() {
1416        let map = parse_gitmodules("[core]\n\tbare = false\n[submodule \"a\"]\npath=a\nurl=u\n");
1417        assert_eq!(map.len(), 1);
1418        assert_eq!(map.get("a").map(String::as_str), Some("u"));
1419    }
1420}