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