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