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/// A discovered git repository.
63pub struct Repo {
64    inner: gix::Repository,
65}
66
67impl Repo {
68    /// Discover the repository containing `path` (walking upwards to the `.git`).
69    ///
70    /// # Errors
71    /// Returns [`GitError::Git`] if no repository is found or it cannot be opened.
72    pub fn discover(path: &Path) -> Result<Self, GitError> {
73        Ok(Self {
74            inner: gix::discover(path).map_err(ge)?,
75        })
76    }
77
78    /// The repository's *common* git directory. The cache lives under here so it
79    /// is shared across linked worktrees (which each have their own git dir).
80    #[must_use]
81    pub fn common_dir(&self) -> &Path {
82        self.inner.common_dir()
83    }
84
85    /// This worktree's git directory (per-worktree; the graph DB lives here).
86    #[must_use]
87    pub fn git_dir(&self) -> &Path {
88        self.inner.git_dir()
89    }
90
91    /// The directory git actually looks in for hooks. Honours `core.hooksPath`
92    /// (absolute, or relative to the working-tree root — else the git dir); when
93    /// unset it is `<common git dir>/hooks`, so managed hooks are shared across
94    /// linked worktrees. `roteiro init` installs into this so its hooks run
95    /// wherever git expects them.
96    #[must_use]
97    pub fn hooks_dir(&self) -> std::path::PathBuf {
98        let configured = self.inner.config_snapshot().string("core.hooksPath");
99        // An empty `core.hooksPath` (e.g. `git -c core.hooksPath=`) means "unset".
100        let configured = configured.filter(|c| !AsRef::<[u8]>::as_ref(c).is_empty());
101        if let Some(configured) = configured {
102            let bytes: &[u8] = configured.as_ref();
103            let path = std::path::PathBuf::from(String::from_utf8_lossy(bytes).into_owned());
104            if path.is_absolute() {
105                return path;
106            }
107            let base = self.inner.workdir().unwrap_or_else(|| self.inner.git_dir());
108            return base.join(path);
109        }
110        self.common_dir().join("hooks")
111    }
112
113    /// The working directory, if this is not a bare repository. The dirty
114    /// overlay reads uncommitted file contents from here.
115    #[must_use]
116    pub fn workdir(&self) -> Option<&Path> {
117        self.inner.workdir()
118    }
119
120    /// The hex git blob object id that `bytes` would have, without writing
121    /// anything. Used to detect whether a working-copy file differs from the
122    /// committed blob (same content ⇒ same id).
123    ///
124    /// # Errors
125    /// Returns [`GitError::Git`] if hashing fails.
126    pub fn blob_oid(&self, bytes: &[u8]) -> Result<String, GitError> {
127        let id = gix::objs::compute_hash(self.inner.object_hash(), gix::objs::Kind::Blob, bytes)
128            .map_err(ge)?;
129        Ok(id.to_hex().to_string())
130    }
131
132    /// Hex object id of the tree at `HEAD`.
133    ///
134    /// # Errors
135    /// Returns [`GitError::Git`] if `HEAD` cannot be resolved to a tree.
136    pub fn head_tree_id(&self) -> Result<String, GitError> {
137        let tree = self.inner.head_tree().map_err(ge)?;
138        Ok(tree.id().to_hex().to_string())
139    }
140
141    /// Hex object id of the commit at `HEAD` — a stable permalink ref for the tree
142    /// the graph was built from (used to build source links).
143    ///
144    /// # Errors
145    /// Returns [`GitError::Git`] if `HEAD` cannot be resolved to a commit.
146    pub fn head_commit_id(&self) -> Result<String, GitError> {
147        Ok(self
148            .inner
149            .head_id()
150            .map_err(ge)?
151            .detach()
152            .to_hex()
153            .to_string())
154    }
155
156    /// Seconds since the Unix epoch of the `HEAD` commit's commit time, in UTC.
157    ///
158    /// Added for analyzer-asset provisioning: an advisory database that is a git
159    /// checkout has no publication date of its own, and `cargo audit` reports
160    /// none at all when it is pointed at a database with `--db` rather than
161    /// resolving one itself. The commit time is the publication date, and it is
162    /// what lets a result be labelled *possibly stale* with a number attached
163    /// (ADR-0012).
164    ///
165    /// # Errors
166    /// Returns [`GitError::Git`] if `HEAD` cannot be resolved to a commit or the
167    /// commit carries no readable time.
168    pub fn head_commit_time(&self) -> Result<i64, GitError> {
169        let commit = self.inner.head_commit().map_err(ge)?;
170        Ok(commit.time().map_err(ge)?.seconds)
171    }
172
173    /// The `origin` remote's fetch URL, if one is configured — e.g. to derive a
174    /// web "blob" base for source links. `None` when there is no `origin` remote.
175    #[must_use]
176    pub fn origin_url(&self) -> Option<String> {
177        let remote = self.inner.find_remote("origin").ok()?;
178        let url = remote.url(gix::remote::Direction::Fetch)?;
179        Some(url.to_bstring().to_string())
180    }
181
182    /// Every blob reachable from the `HEAD` tree, with full paths.
183    ///
184    /// # Errors
185    /// Returns [`GitError`] if the tree cannot be traversed or a path is not
186    /// valid UTF-8.
187    pub fn walk_blobs(&self) -> Result<Vec<BlobRef>, GitError> {
188        let tree = self.inner.head_tree().map_err(ge)?;
189        walk_tree_blobs(&tree)
190    }
191
192    /// Every blob reachable from an arbitrary commit-or-tree `rev` (a hex oid),
193    /// with full paths — like [`Repo::walk_blobs`] but for any point in history,
194    /// not just `HEAD`. A commit oid is peeled to its tree, so a submodule pin (a
195    /// commit sha) works directly. The primitive for extracting a repo's graph at
196    /// the version a spoke pins (ADR-0009 step 8 — version-pin resolution).
197    ///
198    /// # Errors
199    /// Returns [`GitError`] if `rev` cannot be resolved to a tree, the tree cannot
200    /// be traversed, or a path is not valid UTF-8.
201    pub fn blobs_at(&self, rev: &str) -> Result<Vec<BlobRef>, GitError> {
202        let tree = self.tree_by_rev(rev)?;
203        walk_tree_blobs(&tree)
204    }
205
206    /// The hex tree id an arbitrary revspec resolves to (a commit peels to its
207    /// tree) — an **O(1)** resolution that does not walk the tree, so it doubles as
208    /// a cheap "does this ref exist?" check (ADR-0009 step 8b/8c).
209    ///
210    /// # Errors
211    /// Returns [`GitError`] if `rev` cannot be resolved to a tree.
212    pub fn tree_id_at(&self, rev: &str) -> Result<String, GitError> {
213        Ok(self.tree_by_rev(rev)?.id().to_hex().to_string())
214    }
215
216    /// Every git submodule pinned in the `HEAD` tree, sorted by path: a gitlink
217    /// (commit) entry gives the path and the commit it points at, enriched with
218    /// its `.gitmodules` URL when declared. The pinned commit is the **version a
219    /// deployment repo ships** (ADR-0009 derived facts). Empty when there are none.
220    ///
221    /// # Errors
222    /// Returns [`GitError`] if the tree cannot be traversed, `.gitmodules` cannot
223    /// be read, or a path is not valid UTF-8.
224    pub fn submodules(&self) -> Result<Vec<Submodule>, GitError> {
225        let tree = self.inner.head_tree().map_err(ge)?;
226        self.submodules_in_tree(&tree)
227    }
228
229    /// Every git submodule pinned at an arbitrary commit/tree `rev`, sorted by path
230    /// — like [`Repo::submodules`] but for a historical point, so a hub graph
231    /// extracted at a pinned version (ADR-0009 step 8) carries its own submodules
232    /// as they were then.
233    ///
234    /// # Errors
235    /// As [`Repo::submodules`], plus if `rev` cannot be resolved to a tree.
236    pub fn submodules_at(&self, rev: &str) -> Result<Vec<Submodule>, GitError> {
237        let tree = self.tree_by_rev(rev)?;
238        self.submodules_in_tree(&tree)
239    }
240
241    /// Collect the submodule gitlinks (and `.gitmodules` URLs) in `tree`.
242    fn submodules_in_tree(&self, tree: &gix::Tree<'_>) -> Result<Vec<Submodule>, GitError> {
243        let mut recorder = gix::traverse::tree::Recorder::default();
244        tree.traverse().breadthfirst(&mut recorder).map_err(ge)?;
245
246        let mut links: Vec<(String, String)> = Vec::new();
247        let mut gitmodules: Option<gix::ObjectId> = None;
248        for entry in &recorder.records {
249            if entry.mode.is_commit() {
250                let path = String::from_utf8(entry.filepath.clone().into())
251                    .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
252                links.push((path, entry.oid.to_hex().to_string()));
253            } else if entry.mode.is_blob() && entry.filepath.as_slice() == b".gitmodules" {
254                gitmodules = Some(entry.oid);
255            }
256        }
257        self.assemble_submodules(links, gitmodules)
258    }
259
260    /// Every git submodule pinned in the **staged index** (the tree a commit would
261    /// record), sorted by path. Same shape as [`Repo::submodules`] but reads the
262    /// gitlinks (and `.gitmodules`) from the index, so the index-aware sync — the
263    /// pre-commit gate — reflects a *staged* submodule bump, not the `HEAD` pin.
264    ///
265    /// # Errors
266    /// As [`Repo::submodules`], plus index-load failure.
267    pub fn index_submodules(&self) -> Result<Vec<Submodule>, GitError> {
268        use gix::index::entry::Mode;
269        let index = self.inner.index_or_load_from_head().map_err(ge)?;
270        let mut links: Vec<(String, String)> = Vec::new();
271        let mut gitmodules: Option<gix::ObjectId> = None;
272        for entry in index.entries() {
273            if entry.stage_raw() != 0 {
274                continue;
275            }
276            let path = String::from_utf8(entry.path(&index).to_vec())
277                .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
278            if entry.mode == Mode::COMMIT {
279                links.push((path, entry.id.to_hex().to_string()));
280            } else if path == ".gitmodules"
281                && matches!(entry.mode, Mode::FILE | Mode::FILE_EXECUTABLE)
282            {
283                gitmodules = Some(entry.id);
284            }
285        }
286        self.assemble_submodules(links, gitmodules)
287    }
288
289    /// Assemble `(path, sha)` gitlinks into sorted [`Submodule`]s, resolving each
290    /// path's URL from the `.gitmodules` blob at `gitmodules` (when present). Shared
291    /// by the `HEAD`-tree and index submodule readers.
292    fn assemble_submodules(
293        &self,
294        links: Vec<(String, String)>,
295        gitmodules: Option<gix::ObjectId>,
296    ) -> Result<Vec<Submodule>, GitError> {
297        if links.is_empty() {
298            return Ok(Vec::new());
299        }
300        let urls = match gitmodules {
301            Some(oid) => {
302                let bytes = self.read_blob(&oid.to_hex().to_string())?;
303                parse_gitmodules(&String::from_utf8_lossy(&bytes))
304            }
305            None => std::collections::HashMap::new(),
306        };
307        let mut out: Vec<Submodule> = links
308            .into_iter()
309            .map(|(path, sha)| {
310                let url = urls.get(&path).cloned();
311                Submodule { path, sha, url }
312            })
313            .collect();
314        out.sort_by(|a, b| a.path.cmp(&b.path));
315        Ok(out)
316    }
317
318    /// The tracked files that differ between `base` (any revspec — a branch,
319    /// `HEAD~3`, a sha) and the current `HEAD`, sorted by path. Used for
320    /// change-scoped tooling over a commit range (e.g. `roteiro review --base
321    /// main`), distinct from [`Repo::changed_files`], which compares the working
322    /// tree to `HEAD`. A path only in `HEAD` is added, only in `base` is deleted.
323    ///
324    /// # Errors
325    /// Returns [`GitError`] if `base` cannot be resolved to a tree, a tree cannot
326    /// be traversed, or a path is not valid UTF-8.
327    pub fn changed_between(&self, base: &str) -> Result<Vec<ChangedFile>, GitError> {
328        let base_tree = self
329            .inner
330            .rev_parse_single(base)
331            .map_err(ge)?
332            .object()
333            .map_err(ge)?
334            .peel_to_tree()
335            .map_err(ge)?;
336        let base_oid = base_tree.id().to_hex().to_string();
337        let head_oid = self.head_tree_id()?;
338
339        // Reuse the subtree-pruning tree diff, then flatten to the `ChangedFile`
340        // (path, status) shape this API exposes. `diff_trees` already sorts and
341        // prunes unchanged subtrees, so this is O(change), not a full walk.
342        let diff = self.diff_trees(&base_oid, &head_oid)?;
343        // A tree diff's `changed` set conflates genuinely-new files with edits to
344        // existing ones, so range review labels them `Modified` rather than
345        // distinguishing `Added` (which would need the base file set).
346        let mut out: Vec<ChangedFile> = diff
347            .changed
348            .into_iter()
349            .map(|b| ChangedFile {
350                path: b.path,
351                status: ChangeStatus::Modified,
352            })
353            .chain(diff.deleted.into_iter().map(|path| ChangedFile {
354                path,
355                status: ChangeStatus::Deleted,
356            }))
357            .collect();
358        out.sort_by(|a, b| a.path.cmp(&b.path));
359        Ok(out)
360    }
361
362    /// Read the bytes of the blob with hex object id `oid`.
363    ///
364    /// # Errors
365    /// Returns [`GitError::Git`] if the id is malformed or the object is absent.
366    pub fn read_blob(&self, oid: &str) -> Result<Vec<u8>, GitError> {
367        let id = gix::ObjectId::from_hex(oid.as_bytes()).map_err(ge)?;
368        // `detach()` moves the owned data out without cloning; `Object` itself
369        // implements `Drop`, so the bare field cannot be moved out directly.
370        Ok(self.inner.find_object(id).map_err(ge)?.detach().data)
371    }
372
373    /// The bytes of a tracked file's **authored source**, from the tree named by
374    /// `source`: the committed `HEAD` blob, the staged blob, or the file as it
375    /// stands on disk (unstaged edits included, and *not* the git index).
376    ///
377    /// The `Worktree` reading matches [`crate::sync_worktree`], which the derived
378    /// graph is built from, so the authored and derived layers stay consistent —
379    /// see [`GraphSource`] for why that pairing is one type rather than two
380    /// independent choices.
381    ///
382    /// Returns `Ok(None)` when a worktree file has been deleted, so the caller
383    /// drops it.
384    ///
385    /// # Errors
386    /// Returns [`GitError::Git`] if the blob cannot be read, or if reading the
387    /// working-tree copy fails for any reason other than the file being absent.
388    pub fn read_source(
389        &self,
390        blob: &BlobRef,
391        source: GraphSource,
392    ) -> Result<Option<Vec<u8>>, GitError> {
393        match source {
394            // Worktree: the file as it stands on disk (unstaged edits included),
395            // or `None` if it was deleted there.
396            GraphSource::Worktree => match self.workdir() {
397                Some(workdir) => match std::fs::read(workdir.join(&blob.path)) {
398                    Ok(bytes) => Ok(Some(bytes)),
399                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
400                    // Folded into `GitError::Git` with the path rather than
401                    // carried as its own variant: `GitError` is not
402                    // `#[non_exhaustive]`, so a new variant would break every
403                    // downstream exhaustive match for a message this already
404                    // preserves.
405                    Err(e) => Err(GitError::Git(format!("reading {}: {e}", blob.path))),
406                },
407                None => Ok(Some(self.read_blob(&blob.oid)?)),
408            },
409            // Committed reads the `HEAD` blob; Index reads the staged blob — for
410            // both, `blob.oid` is already the right object (the blob list came
411            // from that tree), so read it directly.
412            GraphSource::Committed | GraphSource::Index => Ok(Some(self.read_blob(&blob.oid)?)),
413        }
414    }
415
416    /// Tracked files whose working-tree content differs from `HEAD` — the change
417    /// about to be committed. A file is *changed* when its working-copy bytes hash
418    /// to a different blob id than the committed one (content, not mtime), and
419    /// *deleted* when it is absent from the working tree. Untracked new files are
420    /// not reported (they are not in the `HEAD` tree). Same detection as
421    /// [`crate::sync_worktree`], surfaced for change-scoped tooling.
422    ///
423    /// # Errors
424    /// Returns [`GitError`] on a git failure. In a bare repo (no working tree)
425    /// the change set is empty.
426    pub fn changed_files(&self) -> Result<Vec<ChangedFile>, GitError> {
427        let mut out = Vec::new();
428        let Some(workdir) = self.workdir() else {
429            return Ok(out);
430        };
431        for blob in self.walk_blobs()? {
432            match std::fs::read(workdir.join(&blob.path)) {
433                Ok(bytes) => {
434                    if self.blob_oid(&bytes)? != blob.oid {
435                        out.push(ChangedFile {
436                            path: blob.path,
437                            status: ChangeStatus::Modified,
438                        });
439                    }
440                }
441                Err(e) if e.kind() == std::io::ErrorKind::NotFound => out.push(ChangedFile {
442                    path: blob.path,
443                    status: ChangeStatus::Deleted,
444                }),
445                Err(e) => return Err(GitError::Git(e.to_string())),
446            }
447        }
448        // `walk_blobs` order is an implementation detail; sort so `roteiro review`
449        // output is deterministic across platforms and gix versions.
450        out.sort_by(|a, b| a.path.cmp(&b.path));
451        Ok(out)
452    }
453
454    /// The **staged** files: each regular blob in the git index with its staged
455    /// object id, sorted by path. This is the tree that a commit would record —
456    /// unlike [`Repo::changed_files`] (the working tree) — so it lets tooling gate
457    /// exactly what is about to be committed (the pre-commit index-aware `check`).
458    /// Conflict (unmerged) entries, directories, submodules and symlinks are
459    /// skipped.
460    ///
461    /// # Errors
462    /// Returns [`GitError`] if the index cannot be loaded or a path is not valid
463    /// UTF-8.
464    pub fn index_files(&self) -> Result<Vec<BlobRef>, GitError> {
465        use gix::index::entry::Mode;
466        let index = self.inner.index_or_load_from_head().map_err(ge)?;
467        let mut out = Vec::new();
468        for entry in index.entries() {
469            if entry.stage_raw() != 0 || !matches!(entry.mode, Mode::FILE | Mode::FILE_EXECUTABLE) {
470                continue;
471            }
472            let path = String::from_utf8(entry.path(&index).to_vec())
473                .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
474            out.push(BlobRef {
475                path,
476                oid: entry.id.to_hex().to_string(),
477            });
478        }
479        out.sort_by(|a, b| a.path.cmp(&b.path));
480        Ok(out)
481    }
482
483    /// Untracked, non-ignored regular files in the working tree — brand-new files
484    /// that are in neither `HEAD` nor the index, so [`Repo::walk_blobs`] and
485    /// [`Repo::changed_files`] (both HEAD-tree based) miss them. The working-tree
486    /// `sync`/`check`/`review` overlay these so a new-but-unstaged file is seen.
487    ///
488    /// Respects `.gitignore` / `.git/info/exclude` / global excludes, skips nested
489    /// repositories and non-regular files (symlinks, dirs, submodules), and returns
490    /// repository-relative, unix-separated paths, sorted. Empty in a bare repo.
491    ///
492    /// # Errors
493    /// Returns [`GitError`] on a git failure or a non-UTF-8 path.
494    pub fn untracked_files(&self) -> Result<Vec<String>, GitError> {
495        use gix::dir::entry::{Kind, Status};
496        use gix::dir::walk::EmissionMode;
497
498        if self.inner.workdir().is_none() {
499            return Ok(Vec::new());
500        }
501        // Classify the working tree against the index; emit each untracked file
502        // (not whole collapsed dirs), leaving ignored files unemitted (the default)
503        // so `.gitignore` is honoured.
504        let index = self.inner.index_or_empty().map_err(ge)?;
505        let options = self
506            .inner
507            .dirwalk_options()
508            .map_err(ge)?
509            .emit_untracked(EmissionMode::Matching);
510        // A never-set interrupt flag: the walk is a bounded, synchronous pass, so
511        // there is nothing to cancel it from. (`gix` wants an owned/static flag;
512        // its private wrapper type isn't nameable, so build one via `Arc`.)
513        let never = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
514        let iter = self
515            .inner
516            .dirwalk_iter(index, std::iter::empty::<&str>(), never.into(), options)
517            .map_err(ge)?;
518
519        let mut out = Vec::new();
520        for item in iter {
521            let entry = item.map_err(ge)?.entry;
522            // Only brand-new regular files; symlinks/dirs/submodules are excluded
523            // by the `File` disk kind, ignored files by the emission mode above.
524            if entry.status == Status::Untracked && entry.disk_kind == Some(Kind::File) {
525                let path = String::from_utf8(entry.rela_path.into())
526                    .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
527                out.push(path);
528            }
529        }
530        out.sort();
531        Ok(out)
532    }
533}
534
535/// Collect every blob reachable from `tree`, with full repository-relative paths.
536fn walk_tree_blobs(tree: &gix::Tree<'_>) -> Result<Vec<BlobRef>, GitError> {
537    let mut recorder = gix::traverse::tree::Recorder::default();
538    tree.traverse().breadthfirst(&mut recorder).map_err(ge)?;
539    let mut out = Vec::new();
540    for entry in recorder.records {
541        if !entry.mode.is_blob() {
542            continue;
543        }
544        let path = String::from_utf8(entry.filepath.into())
545            .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
546        out.push(BlobRef {
547            path,
548            oid: entry.oid.to_hex().to_string(),
549        });
550    }
551    Ok(out)
552}
553
554/// A git submodule pinned in a tree: its repo-relative path, the commit it points
555/// at (the gitlink oid — the **version pin** a deployment ships), and its
556/// configured URL from `.gitmodules` when registered there.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct Submodule {
559    /// Repo-relative path the submodule is mounted at.
560    pub path: String,
561    /// Hex commit id the gitlink points at (the pinned version).
562    pub sha: String,
563    /// The submodule's URL from `.gitmodules`, if declared there.
564    pub url: Option<String>,
565}
566
567/// Parse a `.gitmodules` file into a `path → url` map. INI-like: each
568/// `[submodule "<name>"]` section carries a `path` and a `url`.
569fn parse_gitmodules(text: &str) -> std::collections::HashMap<String, String> {
570    let mut map = std::collections::HashMap::new();
571    let (mut path, mut url) = (None, None);
572    let mut in_submodule = false;
573    let mut flush = |path: &mut Option<String>, url: &mut Option<String>| {
574        if let (Some(p), Some(u)) = (path.take(), url.take()) {
575            map.insert(p, u);
576        }
577    };
578    for line in text.lines() {
579        let line = line.trim();
580        if line.starts_with('[') {
581            flush(&mut path, &mut url);
582            in_submodule = line.starts_with("[submodule");
583        } else if in_submodule {
584            if let Some(v) = line
585                .strip_prefix("path")
586                .and_then(|r| r.trim_start().strip_prefix('='))
587            {
588                path = Some(v.trim().to_owned());
589            } else if let Some(v) = line
590                .strip_prefix("url")
591                .and_then(|r| r.trim_start().strip_prefix('='))
592            {
593                url = Some(v.trim().to_owned());
594            }
595        }
596    }
597    flush(&mut path, &mut url);
598    map
599}
600
601/// How a file changed relative to the comparison baseline — for review labelling.
602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
603pub enum ChangeStatus {
604    /// A new file, absent from the baseline (e.g. a brand-new untracked file).
605    Added,
606    /// Present on both sides, with different content.
607    Modified,
608    /// Removed from the working tree (or the `HEAD` side of a range).
609    Deleted,
610}
611
612impl ChangeStatus {
613    /// Stable lowercase label (`added` | `modified` | `deleted`).
614    #[must_use]
615    pub fn as_str(self) -> &'static str {
616        match self {
617            Self::Added => "added",
618            Self::Modified => "modified",
619            Self::Deleted => "deleted",
620        }
621    }
622}
623
624/// A file that differs between the working tree (or a base revision) and `HEAD`.
625#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct ChangedFile {
627    /// Repository-relative path.
628    pub path: String,
629    /// How the file changed.
630    pub status: ChangeStatus,
631}
632
633/// The blob-level difference between two trees: paths added or modified (with
634/// their new blob oid) and paths deleted. See [`Repo::diff_trees`].
635#[derive(Debug, Clone, Default, PartialEq, Eq)]
636pub struct TreeDiff {
637    /// Blobs whose *tree entry* differs from the old tree — a changed blob oid,
638    /// or a mode change (e.g. the executable bit) on otherwise-identical content —
639    /// as `(path, new blob oid)`. These are the paths to re-extract; a mode-only
640    /// change re-extracts to identical facts (extraction is content-addressed), a
641    /// harmless cache hit.
642    pub changed: Vec<BlobRef>,
643    /// Blobs present in the old tree but absent from the new — paths whose facts
644    /// must be dropped.
645    pub deleted: Vec<String>,
646}
647
648impl Repo {
649    /// The blob-level diff between two tree object ids (`old` → `new`), pruning
650    /// unchanged subtrees: gix descends only into subtrees whose oid differs, so
651    /// the cost is proportional to the *change*, not the tree size. Renames are
652    /// reported as a delete plus an add (rewrite tracking is off), which is what
653    /// the path-scoped extractor wants. Results are sorted by path for determinism.
654    ///
655    /// This is the incremental-sync counterpart to [`Repo::walk_blobs`]: given the
656    /// last-synced tree and `HEAD`, it yields exactly the paths that changed.
657    ///
658    /// # Errors
659    /// Returns [`GitError`] if either id is not a tree, the diff fails, or a path
660    /// is not valid UTF-8.
661    pub fn diff_trees(&self, old: &str, new: &str) -> Result<TreeDiff, GitError> {
662        let old_tree = self.tree_by_hex(old)?;
663        let new_tree = self.tree_by_hex(new)?;
664
665        let mut changed = Vec::new();
666        let mut deleted = Vec::new();
667        let mut err: Option<GitError> = None;
668
669        let mut platform = old_tree.changes().map_err(ge)?;
670        platform.options(|o| {
671            o.track_rewrites(None);
672        });
673        platform
674            .for_each_to_obtain_tree(&new_tree, |change| {
675                use gix::object::tree::diff::Change;
676                let record = |path: &gix::bstr::BStr| -> Result<String, GitError> {
677                    String::from_utf8(path.to_vec())
678                        .map_err(|e| GitError::NonUtf8Path(e.into_bytes()))
679                };
680                match change {
681                    Change::Addition {
682                        location,
683                        entry_mode,
684                        id,
685                        ..
686                    }
687                    | Change::Modification {
688                        location,
689                        entry_mode,
690                        id,
691                        ..
692                    } => {
693                        if entry_mode.is_blob() {
694                            match record(location) {
695                                Ok(path) => changed.push(BlobRef {
696                                    path,
697                                    oid: id.to_hex().to_string(),
698                                }),
699                                Err(e) => err = Some(e),
700                            }
701                        }
702                    }
703                    Change::Deletion {
704                        location,
705                        entry_mode,
706                        ..
707                    } => {
708                        if entry_mode.is_blob() {
709                            match record(location) {
710                                Ok(path) => deleted.push(path),
711                                Err(e) => err = Some(e),
712                            }
713                        }
714                    }
715                    // Rewrite tracking is disabled, so renames arrive as
716                    // Deletion + Addition; this arm is unreachable in practice.
717                    Change::Rewrite { .. } => {}
718                }
719                Ok::<_, std::convert::Infallible>(gix::object::tree::diff::Action::Continue(()))
720            })
721            .map_err(ge)?;
722
723        if let Some(e) = err {
724            return Err(e);
725        }
726        changed.sort_by(|a, b| a.path.cmp(&b.path));
727        deleted.sort();
728        Ok(TreeDiff { changed, deleted })
729    }
730
731    /// Resolve a hex object id to a [`gix::Tree`].
732    fn tree_by_hex(&self, hex: &str) -> Result<gix::Tree<'_>, GitError> {
733        let id = gix::ObjectId::from_hex(hex.as_bytes()).map_err(ge)?;
734        self.inner
735            .find_object(id)
736            .map_err(ge)?
737            .peel_to_tree()
738            .map_err(ge)
739    }
740
741    /// Resolve **any git revspec** — a sha, a tag, a branch, `HEAD~1` — to its
742    /// tree. Unlike [`Repo::tree_by_hex`] (raw oids only), this accepts the tag /
743    /// branch names the pinned-version resolution (`--hub-rev`, an image tag) can
744    /// carry. Mirrors the resolution in [`Repo::changed_between`].
745    fn tree_by_rev(&self, rev: &str) -> Result<gix::Tree<'_>, GitError> {
746        self.inner
747            .rev_parse_single(rev)
748            .map_err(ge)?
749            .object()
750            .map_err(ge)?
751            .peel_to_tree()
752            .map_err(ge)
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::parse_gitmodules;
759
760    #[test]
761    fn parse_gitmodules_maps_path_to_url_in_either_field_order() {
762        let text = "\
763[submodule \"vendor/app\"]\n\
764\tpath = vendor/app\n\
765\turl = https://github.com/acme/app.git\n\
766[submodule \"libs/util\"]\n\
767\turl = git@github.com:acme/util.git\n\
768\tpath = libs/util\n";
769        let map = parse_gitmodules(text);
770        assert_eq!(
771            map.get("vendor/app").map(String::as_str),
772            Some("https://github.com/acme/app.git")
773        );
774        // URL declared before path in its section still maps.
775        assert_eq!(
776            map.get("libs/util").map(String::as_str),
777            Some("git@github.com:acme/util.git")
778        );
779        assert_eq!(map.len(), 2);
780    }
781
782    #[test]
783    fn parse_gitmodules_ignores_non_submodule_sections() {
784        let map = parse_gitmodules("[core]\n\tbare = false\n[submodule \"a\"]\npath=a\nurl=u\n");
785        assert_eq!(map.len(), 1);
786        assert_eq!(map.get("a").map(String::as_str), Some("u"));
787    }
788}