Skip to main content

journey/backend/
git2_backend.rs

1//! The live [`RepoBackend`] backed by `git2` / libgit2.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use git2::{
7    BranchType, Commit, Delta, DiffFormat, DiffLineType, DiffOptions, Oid, Repository, Sort,
8};
9
10use super::{
11    BlobPair, BranchInfo, ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange,
12    RefKind, RefLabel, RepoBackend, WorkingStatus,
13};
14
15/// Cap on the rename/copy-detection workload, in candidate pairs. `find_similar`
16/// builds an O(added × deleted) content-similarity matrix, so its cost tracks
17/// that product — **not** the total number of changed files: a modification-only
18/// commit is cheap at any size, while an add/delete-heavy one is costly even when
19/// smaller (a 4240-file all-additions commit detects in ~0.1ms; an 8157-file
20/// merge with ~4100 adds × ~3500 deletes took ~49s and froze the UI). Past this
21/// many pairs we skip rename detection so a big merge can't hang the UI; ~100k
22/// keeps the matching well under a second while still catching renames among a
23/// few hundred files on each side. The cost of skipping is that renames in such
24/// a diff show as add/delete pairs (git's CLI does the same past
25/// `diff.renameLimit`).
26const MAX_RENAME_PAIRS: usize = 100_000;
27
28/// Cap on the number of lines [`render_diff`] emits for one diff. A pathological
29/// commit (again, a big merge) can produce well over a million patch lines;
30/// materializing them all freezes the UI for tens of seconds and wastes memory
31/// on a diff no one scrolls through. Past the cap the diff is truncated with a
32/// trailing marker; the per-file lists still show every changed file.
33const MAX_DIFF_LINES: usize = 50_000;
34
35/// Opens a repository and reads commits/diffs through libgit2.
36pub struct Git2Backend {
37    path: String,
38    repo: Repository,
39    commits: Vec<CommitInfo>,
40}
41
42impl Git2Backend {
43    /// Open the repository at (or above) `path` and load its commit history.
44    pub fn open(path: impl AsRef<str>) -> Result<Self, git2::Error> {
45        let path = path.as_ref().to_string();
46        let repo = Repository::discover(&path)?;
47        // Prefer the repository's working-directory path for display; fall
48        // back to the .git dir for bare repos.
49        let display_path = repo
50            .workdir()
51            .map(|p| p.display().to_string())
52            .unwrap_or_else(|| repo.path().display().to_string());
53
54        let refs = collect_refs(&repo)?;
55        let commits = load_commits(&repo, &refs)?;
56
57        Ok(Self {
58            path: display_path,
59            repo,
60            commits,
61        })
62    }
63
64    fn commit_at(&self, index: usize) -> Option<Commit<'_>> {
65        let info = self.commits.get(index)?;
66        let oid = Oid::from_str(&info.id).ok()?;
67        self.repo.find_commit(oid).ok()
68    }
69
70    /// Build a libgit2 diff for a commit against its first parent, optionally
71    /// restricted to a single path. Renames are detected so the file list can
72    /// show `old -> new`.
73    fn build_diff(&self, index: usize, path: Option<&str>) -> Option<git2::Diff<'_>> {
74        let commit = self.commit_at(index)?;
75        let new_tree = commit.tree().ok()?;
76        let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
77
78        let mut opts = DiffOptions::new();
79        opts.context_lines(3);
80        if let Some(path) = path {
81            opts.pathspec(path);
82        }
83
84        let mut diff = self
85            .repo
86            .diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))
87            .ok()?;
88        detect_renames(&mut diff);
89        Some(diff)
90    }
91
92    /// Read the bytes of `path`'s blob in `tree`, if it exists there and is a
93    /// blob (not a submodule or directory).
94    fn blob_in_tree(&self, tree: &git2::Tree, path: &str) -> Option<Vec<u8>> {
95        let entry = tree.get_path(Path::new(path)).ok()?;
96        let obj = entry.to_object(&self.repo).ok()?;
97        obj.as_blob().map(|b| b.content().to_vec())
98    }
99
100    /// The bytes of `path`'s entry in the current index (the staged copy).
101    fn blob_in_index(&self, path: &str) -> Option<Vec<u8>> {
102        let index = self.repo.index().ok()?;
103        let entry = index.get_path(Path::new(path), 0)?;
104        let blob = self.repo.find_blob(entry.id).ok()?;
105        Some(blob.content().to_vec())
106    }
107
108    /// The raw bytes of `path` in the working tree on disk.
109    fn blob_in_workdir(&self, path: &str) -> Option<Vec<u8>> {
110        let workdir = self.repo.workdir()?;
111        std::fs::read(workdir.join(path)).ok()
112    }
113
114    /// The branch every review diff is measured against — the repository's
115    /// default branch: the remote's declared default (`origin/HEAD`) when
116    /// known, otherwise `main` / `master`, preferring a local branch over its
117    /// remote-tracking ref; the checked-out branch as a last resort. Returns
118    /// the display name and the tip commit id.
119    fn review_base(&self) -> Option<(String, Oid)> {
120        let remote_default = self
121            .repo
122            .find_reference("refs/remotes/origin/HEAD")
123            .ok()
124            .and_then(|r| r.symbolic_target().map(str::to_string))
125            .and_then(|t| t.strip_prefix("refs/remotes/origin/").map(str::to_string));
126        let mut candidates: Vec<&str> = Vec::new();
127        if let Some(name) = remote_default.as_deref() {
128            candidates.push(name);
129        }
130        for name in ["main", "master"] {
131            if !candidates.contains(&name) {
132                candidates.push(name);
133            }
134        }
135        for name in candidates {
136            if let Ok(branch) = self.repo.find_branch(name, BranchType::Local)
137                && let Ok(tip) = branch.get().peel_to_commit()
138            {
139                return Some((name.to_string(), tip.id()));
140            }
141            let remote_name = format!("origin/{name}");
142            if let Ok(branch) = self.repo.find_branch(&remote_name, BranchType::Remote)
143                && let Ok(tip) = branch.get().peel_to_commit()
144            {
145                return Some((remote_name, tip.id()));
146            }
147        }
148        let head = self.repo.head().ok()?;
149        let name = head.shorthand().unwrap_or("HEAD").to_string();
150        let tip = head.peel_to_commit().ok()?;
151        Some((name, tip.id()))
152    }
153
154    /// Assemble one review-list entry from a branch's tip commit, computing
155    /// its merge base with the review base branch.
156    fn branch_info(
157        &self,
158        name: String,
159        kind: RefKind,
160        tip: &Commit,
161        upstream: Option<String>,
162        base_name: &str,
163        base_oid: Oid,
164    ) -> BranchInfo {
165        let base_id = self
166            .repo
167            .merge_base(base_oid, tip.id())
168            .ok()
169            .map(|oid| oid.to_string());
170        let author = tip.author();
171        BranchInfo {
172            name,
173            kind,
174            tip_id: tip.id().to_string(),
175            summary: tip.summary().unwrap_or("").to_string(),
176            author: author.name().unwrap_or("").to_string(),
177            time_seconds: author.when().seconds(),
178            time_offset_minutes: author.when().offset_minutes(),
179            upstream,
180            base_name: base_name.to_string(),
181            base_id,
182        }
183    }
184
185    /// The two trees a branch review compares: the merge base (`None` for an
186    /// unrelated history, read as the empty tree) and the branch tip.
187    fn branch_trees(
188        &self,
189        branch: &BranchInfo,
190    ) -> Option<(Option<git2::Tree<'_>>, git2::Tree<'_>)> {
191        let tip_oid = Oid::from_str(&branch.tip_id).ok()?;
192        let tip = self.repo.find_commit(tip_oid).ok()?.tree().ok()?;
193        let base = branch
194            .base_id
195            .as_deref()
196            .and_then(|id| Oid::from_str(id).ok())
197            .and_then(|oid| self.repo.find_commit(oid).ok())
198            .and_then(|c| c.tree().ok());
199        Some((base, tip))
200    }
201
202    /// Build a libgit2 diff of everything `branch` contains (review base →
203    /// tip), optionally restricted to a single path.
204    fn build_branch_diff(&self, branch: &BranchInfo, path: Option<&str>) -> Option<git2::Diff<'_>> {
205        let (base, tip) = self.branch_trees(branch)?;
206        let mut opts = DiffOptions::new();
207        opts.context_lines(3);
208        if let Some(path) = path {
209            opts.pathspec(path);
210        }
211        let mut diff = self
212            .repo
213            .diff_tree_to_tree(base.as_ref(), Some(&tip), Some(&mut opts))
214            .ok()?;
215        detect_renames(&mut diff);
216        Some(diff)
217    }
218
219    /// The tree the staged side is diffed against: `HEAD`'s tree normally, or
220    /// `HEAD`'s parent's tree when amending. `None` means "no base" (unborn
221    /// `HEAD`, or amending the root commit) — the index is then diffed against
222    /// the empty tree, so everything reads as additions.
223    fn staged_base_tree(&self, amend: bool) -> Option<git2::Tree<'_>> {
224        let head = self.repo.head().ok()?.peel_to_commit().ok()?;
225        if amend {
226            head.parent(0).ok().and_then(|p| p.tree().ok())
227        } else {
228            head.tree().ok()
229        }
230    }
231}
232
233impl RepoBackend for Git2Backend {
234    fn path(&self) -> &str {
235        &self.path
236    }
237
238    fn commits(&self) -> &[CommitInfo] {
239        &self.commits
240    }
241
242    fn changed_files(&self, index: usize) -> Vec<FileChange> {
243        let Some(diff) = self.build_diff(index, None) else {
244            return Vec::new();
245        };
246        diff.deltas()
247            .map(|delta| file_change_from_delta(&delta))
248            .collect()
249    }
250
251    fn commit_diff(&self, index: usize) -> Diff {
252        self.build_diff(index, None)
253            .map(render_diff)
254            .unwrap_or_default()
255    }
256
257    fn file_diff(&self, index: usize, path: &str) -> Diff {
258        self.build_diff(index, Some(path))
259            .map(render_diff)
260            .unwrap_or_default()
261    }
262
263    fn commit_file_blobs(&self, index: usize, path: &str) -> BlobPair {
264        let Some(commit) = self.commit_at(index) else {
265            return BlobPair::default();
266        };
267        let new = commit.tree().ok().and_then(|t| self.blob_in_tree(&t, path));
268        // The version in the first parent (the diff base); absent for an added
269        // file, or for a rename where the old content lives under a different
270        // path (a rare case for images, left as a one-sided comparison).
271        let old = commit
272            .parent(0)
273            .ok()
274            .and_then(|p| p.tree().ok())
275            .and_then(|t| self.blob_in_tree(&t, path));
276        BlobPair { old, new }
277    }
278
279    fn branches(&self) -> Vec<BranchInfo> {
280        let Some((base_name, base_oid)) = self.review_base() else {
281            return Vec::new();
282        };
283        let mut branches = Vec::new();
284        // Remote-tracking branches folded into their local's row; collected
285        // over the locals so the remote pass below can skip them.
286        let mut folded: HashSet<String> = HashSet::new();
287
288        if let Ok(iter) = self.repo.branches(Some(BranchType::Local)) {
289            for (branch, _) in iter.flatten() {
290                let Ok(Some(name)) = branch.name() else {
291                    continue;
292                };
293                let name = name.to_string();
294                let Ok(tip) = branch.get().peel_to_commit() else {
295                    continue;
296                };
297                let kind = if branch.is_head() {
298                    RefKind::Head
299                } else {
300                    RefKind::LocalBranch
301                };
302                // A tracked upstream sitting at the same tip folds into this
303                // row; a diverged one reviews differently and keeps its own.
304                let upstream = branch.upstream().ok().and_then(|u| {
305                    let uname = u.name().ok().flatten()?.to_string();
306                    let utip = u.get().peel_to_commit().ok()?.id();
307                    (utip == tip.id()).then_some(uname)
308                });
309                if let Some(uname) = &upstream {
310                    folded.insert(uname.clone());
311                }
312                branches.push(self.branch_info(name, kind, &tip, upstream, &base_name, base_oid));
313            }
314        }
315        if let Ok(iter) = self.repo.branches(Some(BranchType::Remote)) {
316            for (branch, _) in iter.flatten() {
317                let Ok(Some(name)) = branch.name() else {
318                    continue;
319                };
320                // Skip the synthetic origin/HEAD pointer (as the log's labels
321                // do) and the remotes already folded into a local's row.
322                if name.ends_with("/HEAD") || folded.contains(name) {
323                    continue;
324                }
325                let name = name.to_string();
326                let Ok(tip) = branch.get().peel_to_commit() else {
327                    continue;
328                };
329                branches.push(self.branch_info(
330                    name,
331                    RefKind::RemoteBranch,
332                    &tip,
333                    None,
334                    &base_name,
335                    base_oid,
336                ));
337            }
338        }
339        // Checked-out branch first, then locals, then remotes, by name within.
340        branches.sort_by(|a, b| {
341            let rank = |k: RefKind| match k {
342                RefKind::Head | RefKind::DetachedHead => 0,
343                RefKind::LocalBranch => 1,
344                _ => 2,
345            };
346            rank(a.kind).cmp(&rank(b.kind)).then(a.name.cmp(&b.name))
347        });
348        branches
349    }
350
351    fn branch_files(&self, branch: &BranchInfo) -> Vec<FileChange> {
352        let Some(diff) = self.build_branch_diff(branch, None) else {
353            return Vec::new();
354        };
355        diff.deltas()
356            .map(|delta| file_change_from_delta(&delta))
357            .collect()
358    }
359
360    fn branch_diff(&self, branch: &BranchInfo) -> Diff {
361        self.build_branch_diff(branch, None)
362            .map(render_diff)
363            .unwrap_or_default()
364    }
365
366    fn branch_file_diff(&self, branch: &BranchInfo, path: &str) -> Diff {
367        self.build_branch_diff(branch, Some(path))
368            .map(render_diff)
369            .unwrap_or_default()
370    }
371
372    fn branch_file_blobs(&self, branch: &BranchInfo, path: &str) -> BlobPair {
373        let Some((base, tip)) = self.branch_trees(branch) else {
374            return BlobPair::default();
375        };
376        BlobPair {
377            old: base.as_ref().and_then(|t| self.blob_in_tree(t, path)),
378            new: self.blob_in_tree(&tip, path),
379        }
380    }
381
382    fn working_file_blobs(&self, path: &str, staged: bool, amend: bool) -> BlobPair {
383        if staged {
384            // Staged base (HEAD / HEAD^) vs the index copy.
385            let old = self
386                .staged_base_tree(amend)
387                .and_then(|t| self.blob_in_tree(&t, path));
388            BlobPair {
389                old,
390                new: self.blob_in_index(path),
391            }
392        } else {
393            // Index copy vs the working tree on disk.
394            BlobPair {
395                old: self.blob_in_index(path),
396                new: self.blob_in_workdir(path),
397            }
398        }
399    }
400
401    fn working_status(&self, amend: bool) -> WorkingStatus {
402        let base = self.staged_base_tree(amend);
403
404        // Staged side: the index against the base tree (HEAD, or HEAD's parent
405        // when amending). With no base (unborn / amending the root commit) the
406        // whole index reads as additions.
407        let mut staged_opts = DiffOptions::new();
408        let mut staged = WorkingStatus::default();
409        if let Ok(mut diff) =
410            self.repo
411                .diff_tree_to_index(base.as_ref(), None, Some(&mut staged_opts))
412        {
413            let _ = diff.find_similar(None);
414            for delta in diff.deltas() {
415                staged.staged.push(file_change_from_delta(&delta));
416            }
417        }
418
419        // Unstaged side: the working tree against the index (independent of
420        // the amend base), including untracked files.
421        let mut wd_opts = DiffOptions::new();
422        wd_opts.include_untracked(true).recurse_untracked_dirs(true);
423        if let Ok(diff) = self.repo.diff_index_to_workdir(None, Some(&mut wd_opts)) {
424            for delta in diff.deltas() {
425                staged.unstaged.push(file_change_from_delta(&delta));
426            }
427        }
428
429        staged
430    }
431
432    fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff {
433        let mut opts = DiffOptions::new();
434        opts.context_lines(3).pathspec(path);
435        let diff = if staged {
436            let base = self.staged_base_tree(amend);
437            self.repo
438                .diff_tree_to_index(base.as_ref(), None, Some(&mut opts))
439        } else {
440            opts.include_untracked(true)
441                .recurse_untracked_dirs(true)
442                .show_untracked_content(true);
443            self.repo.diff_index_to_workdir(None, Some(&mut opts))
444        };
445        diff.ok().map(render_diff).unwrap_or_default()
446    }
447
448    fn stage(&self, path: &str) -> Result<(), String> {
449        let mut index = self.repo.index().map_err(err_msg)?;
450        let p = Path::new(path);
451        let in_workdir = self
452            .repo
453            .workdir()
454            .map(|w| w.join(path).exists())
455            .unwrap_or(false);
456        if in_workdir {
457            index.add_path(p).map_err(err_msg)?;
458        } else {
459            // The file is gone from the working tree — stage its removal.
460            index.remove_path(p).map_err(err_msg)?;
461        }
462        index.write().map_err(err_msg)
463    }
464
465    fn unstage(&self, path: &str, amend: bool) -> Result<(), String> {
466        // Reset the index entry to the staged base: HEAD normally, HEAD's
467        // parent when amending (which drops the path from the amended commit).
468        let head = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
469        let target: Option<git2::Object> = match (amend, head) {
470            (false, Some(commit)) => Some(commit.into_object()),
471            (true, Some(commit)) => commit.parent(0).ok().map(|p| p.into_object()),
472            (_, None) => None,
473        };
474        match target {
475            Some(obj) => self.repo.reset_default(Some(&obj), [path]).map_err(err_msg),
476            // No base commit (unborn HEAD, or amending the root commit):
477            // unstaging just drops the path back out of the index.
478            None => {
479                let mut index = self.repo.index().map_err(err_msg)?;
480                index.remove_path(Path::new(path)).map_err(err_msg)?;
481                index.write().map_err(err_msg)
482            }
483        }
484    }
485
486    fn revert(&self, path: &str) -> Result<(), String> {
487        // Rewrite the working-tree file from the index, overwriting any
488        // unstaged edits. `update_index(false)` leaves the index untouched, so
489        // a partially-staged file keeps its staged changes — only the
490        // working-vs-index delta is discarded. An untracked path has no index
491        // entry, so the checkout simply skips it.
492        let mut opts = git2::build::CheckoutBuilder::new();
493        opts.force().update_index(false).path(path);
494        self.repo
495            .checkout_index(None, Some(&mut opts))
496            .map_err(err_msg)
497    }
498
499    fn delete_untracked(&self, path: &str) -> Result<(), String> {
500        let workdir = self
501            .repo
502            .workdir()
503            .ok_or_else(|| "bare repository has no working tree".to_string())?;
504        std::fs::remove_file(workdir.join(path)).map_err(|e| e.to_string())
505    }
506
507    fn apply_to_index(&self, patch: &str) -> Result<(), String> {
508        let diff = git2::Diff::from_buffer(patch.as_bytes()).map_err(err_msg)?;
509        self.repo
510            .apply(&diff, git2::ApplyLocation::Index, None)
511            .map_err(err_msg)
512    }
513
514    fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
515        if message.trim().is_empty() {
516            return Err("Please enter a commit message.".into());
517        }
518        let mut index = self.repo.index().map_err(err_msg)?;
519        let tree_oid = index.write_tree().map_err(err_msg)?;
520        let tree = self.repo.find_tree(tree_oid).map_err(err_msg)?;
521
522        if amend {
523            let head = self
524                .repo
525                .head()
526                .and_then(|h| h.peel_to_commit())
527                .map_err(err_msg)?;
528            // Keep the original author/committer; only the message and tree
529            // change. (`None` tells libgit2 to reuse the existing values.)
530            head.amend(Some("HEAD"), None, None, None, Some(message), Some(&tree))
531                .map_err(err_msg)?;
532        } else {
533            let sig = self.repo.signature().map_err(|_| {
534                "No git identity configured. Set user.name and user.email.".to_string()
535            })?;
536            let parent = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
537            let parents: Vec<&Commit> = parent.iter().collect();
538            self.repo
539                .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
540                .map_err(err_msg)?;
541        }
542        Ok(())
543    }
544
545    fn head_message(&self) -> Option<String> {
546        let commit = self.repo.head().ok()?.peel_to_commit().ok()?;
547        Some(commit.message().unwrap_or("").to_string())
548    }
549
550    fn signature(&self) -> Option<(String, String)> {
551        let sig = self.repo.signature().ok()?;
552        Some((sig.name()?.to_string(), sig.email()?.to_string()))
553    }
554}
555
556/// Map a libgit2 diff delta to our [`FileChange`], collapsing the old path for
557/// non-rename changes.
558fn file_change_from_delta(delta: &git2::DiffDelta) -> FileChange {
559    let new_path = delta.new_file().path().map(|p| p.display().to_string());
560    let old_path = delta.old_file().path().map(|p| p.display().to_string());
561    let status = status_from_delta(delta.status());
562    let path = new_path
563        .clone()
564        .or_else(|| old_path.clone())
565        .unwrap_or_default();
566    FileChange {
567        path,
568        old_path: old_path.filter(|o| Some(o) != new_path.as_ref()),
569        status,
570    }
571}
572
573/// Render a libgit2 error as a short message for the UI's dialog.
574fn err_msg(e: git2::Error) -> String {
575    e.message().to_string()
576}
577
578/// Walk all references once and group branch/tag labels by the commit they
579/// resolve to. The currently checked-out branch is tagged [`RefKind::Head`];
580/// a detached HEAD becomes a [`RefKind::DetachedHead`] label.
581fn collect_refs(repo: &Repository) -> Result<HashMap<Oid, Vec<RefLabel>>, git2::Error> {
582    let mut map: HashMap<Oid, Vec<RefLabel>> = HashMap::new();
583
584    let head = repo.head().ok();
585    let head_branch = head
586        .as_ref()
587        .filter(|h| h.is_branch())
588        .and_then(|h| h.shorthand())
589        .map(str::to_string);
590    let detached = repo.head_detached().unwrap_or(false);
591
592    if detached && let Some(oid) = head.as_ref().and_then(|h| h.target()) {
593        map.entry(oid).or_default().push(RefLabel {
594            name: "HEAD".into(),
595            kind: RefKind::DetachedHead,
596        });
597    }
598
599    if let Ok(references) = repo.references() {
600        for reference in references.flatten() {
601            let Ok(commit) = reference.peel_to_commit() else {
602                continue;
603            };
604            let oid = commit.id();
605            let Some(name) = reference.shorthand().map(str::to_string) else {
606                continue;
607            };
608            let kind = if reference.is_tag() {
609                RefKind::Tag
610            } else if reference.is_remote() {
611                // Skip the synthetic origin/HEAD pointer; it's noise.
612                if name.ends_with("/HEAD") {
613                    continue;
614                }
615                RefKind::RemoteBranch
616            } else if reference.is_branch() {
617                if head_branch.as_deref() == Some(name.as_str()) {
618                    RefKind::Head
619                } else {
620                    RefKind::LocalBranch
621                }
622            } else {
623                continue;
624            };
625            map.entry(oid).or_default().push(RefLabel { name, kind });
626        }
627    }
628
629    // Stable, readable ordering: HEAD/branch first, remotes, then tags.
630    for labels in map.values_mut() {
631        labels.sort_by_key(|l| match l.kind {
632            RefKind::Head | RefKind::DetachedHead => 0,
633            RefKind::LocalBranch => 1,
634            RefKind::RemoteBranch => 2,
635            RefKind::Tag => 3,
636        });
637    }
638
639    Ok(map)
640}
641
642/// Run a reverse-topological, newest-first revwalk from HEAD and build a
643/// [`CommitInfo`] per commit.
644fn load_commits(
645    repo: &Repository,
646    refs: &HashMap<Oid, Vec<RefLabel>>,
647) -> Result<Vec<CommitInfo>, git2::Error> {
648    let mut revwalk = repo.revwalk()?;
649    revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
650    // Show only history reachable from HEAD, like plain `gitk`. An unborn
651    // HEAD (empty repo) fails to push; tolerate it and yield no commits.
652    let _ = revwalk.push_head();
653
654    let mut commits = Vec::new();
655    for oid in revwalk {
656        let oid = oid?;
657        let commit = repo.find_commit(oid)?;
658        commits.push(commit_info(&commit, refs));
659    }
660    Ok(commits)
661}
662
663fn commit_info(commit: &Commit, refs: &HashMap<Oid, Vec<RefLabel>>) -> CommitInfo {
664    let id = commit.id().to_string();
665    let short_id = id.chars().take(8).collect();
666    let message = commit.message().unwrap_or("").to_string();
667    let summary = commit
668        .summary()
669        .map(str::to_string)
670        .unwrap_or_else(|| message.lines().next().unwrap_or("").to_string());
671    let author = commit.author();
672    let committer = commit.committer();
673    let time = author.when();
674
675    CommitInfo {
676        short_id,
677        summary,
678        message,
679        author_name: author.name().unwrap_or("").to_string(),
680        author_email: author.email().unwrap_or("").to_string(),
681        committer_name: committer.name().unwrap_or("").to_string(),
682        committer_email: committer.email().unwrap_or("").to_string(),
683        time_seconds: time.seconds(),
684        time_offset_minutes: time.offset_minutes(),
685        parents: commit.parent_ids().map(|p| p.to_string()).collect(),
686        refs: refs.get(&commit.id()).cloned().unwrap_or_default(),
687        id,
688    }
689}
690
691fn status_from_delta(delta: Delta) -> ChangeStatus {
692    match delta {
693        Delta::Added => ChangeStatus::Added,
694        Delta::Deleted => ChangeStatus::Deleted,
695        Delta::Modified => ChangeStatus::Modified,
696        Delta::Renamed => ChangeStatus::Renamed,
697        Delta::Copied => ChangeStatus::Copied,
698        Delta::Typechange => ChangeStatus::TypeChange,
699        Delta::Untracked => ChangeStatus::Untracked,
700        _ => ChangeStatus::Other,
701    }
702}
703
704/// Detect renames/copies so statuses and headers are accurate — but only when
705/// the similarity matrix is small enough to stay cheap. Its cost is
706/// ~O(added × deletes), so gate on that product rather than the total file
707/// count (see [`MAX_RENAME_PAIRS`]); a huge merge would otherwise hang the UI.
708/// Counts are taken before detection, so they're the raw add/delete candidate
709/// pool.
710fn detect_renames(diff: &mut git2::Diff) {
711    let (mut added, mut deleted) = (0usize, 0usize);
712    for delta in diff.deltas() {
713        match delta.status() {
714            Delta::Added => added += 1,
715            Delta::Deleted => deleted += 1,
716            _ => {}
717        }
718    }
719    if added.saturating_mul(deleted) <= MAX_RENAME_PAIRS {
720        let _ = diff.find_similar(None);
721    }
722}
723
724/// Drive libgit2's patch printer and translate each emitted line into a typed
725/// [`DiffLine`]. Content/hunk/file-header lines keep their text; +/-/context
726/// lines get their origin character prepended so the monospace view reads like
727/// a real unified diff even before color is applied.
728fn render_diff(diff: git2::Diff) -> Diff {
729    let mut lines = Vec::new();
730    let mut truncated = false;
731    let _ = diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
732        // Stop once the cap is hit: returning `false` aborts libgit2's patch
733        // generation, so the remaining (huge) diff is never materialized.
734        if lines.len() >= MAX_DIFF_LINES {
735            truncated = true;
736            return false;
737        }
738        let content = String::from_utf8_lossy(line.content());
739        let content = content.trim_end_matches('\n');
740        match line.origin_value() {
741            DiffLineType::FileHeader => {
742                push_multiline(&mut lines, DiffLineKind::FileHeader, content)
743            }
744            DiffLineType::HunkHeader => {
745                push_multiline(&mut lines, DiffLineKind::HunkHeader, content)
746            }
747            DiffLineType::Context => {
748                lines.push(DiffLine::new(DiffLineKind::Context, format!(" {content}")))
749            }
750            DiffLineType::Addition => {
751                lines.push(DiffLine::new(DiffLineKind::Addition, format!("+{content}")))
752            }
753            DiffLineType::Deletion => {
754                lines.push(DiffLine::new(DiffLineKind::Deletion, format!("-{content}")))
755            }
756            DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => {
757                lines.push(DiffLine::new(DiffLineKind::Meta, content.to_string()))
758            }
759            _ => push_multiline(&mut lines, DiffLineKind::Meta, content),
760        }
761        true
762    });
763    if truncated {
764        lines.push(DiffLine::new(
765            DiffLineKind::Meta,
766            format!(
767                "\u{2026} diff truncated at {MAX_DIFF_LINES} lines — too large to display in full"
768            ),
769        ));
770    }
771    Diff { lines }
772}
773
774fn push_multiline(out: &mut Vec<DiffLine>, kind: DiffLineKind, content: &str) {
775    for line in content.split('\n') {
776        out.push(DiffLine::new(kind, line.to_string()));
777    }
778}