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}