Skip to main content

journey/backend/
git2_backend.rs

1//! The live [`RepoBackend`] backed by `git2` / libgit2.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use git2::{Commit, Delta, DiffFormat, DiffLineType, DiffOptions, Oid, Repository, Sort};
7
8use super::{
9    ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange, RefKind, RefLabel,
10    RepoBackend, WorkingStatus,
11};
12
13/// Opens a repository and reads commits/diffs through libgit2.
14pub struct Git2Backend {
15    path: String,
16    repo: Repository,
17    commits: Vec<CommitInfo>,
18}
19
20impl Git2Backend {
21    /// Open the repository at (or above) `path` and load its commit history.
22    pub fn open(path: impl AsRef<str>) -> Result<Self, git2::Error> {
23        let path = path.as_ref().to_string();
24        let repo = Repository::discover(&path)?;
25        // Prefer the repository's working-directory path for display; fall
26        // back to the .git dir for bare repos.
27        let display_path = repo
28            .workdir()
29            .map(|p| p.display().to_string())
30            .unwrap_or_else(|| repo.path().display().to_string());
31
32        let refs = collect_refs(&repo)?;
33        let commits = load_commits(&repo, &refs)?;
34
35        Ok(Self {
36            path: display_path,
37            repo,
38            commits,
39        })
40    }
41
42    fn commit_at(&self, index: usize) -> Option<Commit<'_>> {
43        let info = self.commits.get(index)?;
44        let oid = Oid::from_str(&info.id).ok()?;
45        self.repo.find_commit(oid).ok()
46    }
47
48    /// Build a libgit2 diff for a commit against its first parent, optionally
49    /// restricted to a single path. Renames are detected so the file list can
50    /// show `old -> new`.
51    fn build_diff(&self, index: usize, path: Option<&str>) -> Option<git2::Diff<'_>> {
52        let commit = self.commit_at(index)?;
53        let new_tree = commit.tree().ok()?;
54        let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
55
56        let mut opts = DiffOptions::new();
57        opts.context_lines(3);
58        if let Some(path) = path {
59            opts.pathspec(path);
60        }
61
62        let mut diff = self
63            .repo
64            .diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))
65            .ok()?;
66        // Detect renames/copies so statuses and headers are accurate.
67        let _ = diff.find_similar(None);
68        Some(diff)
69    }
70
71    /// The tree the staged side is diffed against: `HEAD`'s tree normally, or
72    /// `HEAD`'s parent's tree when amending. `None` means "no base" (unborn
73    /// `HEAD`, or amending the root commit) — the index is then diffed against
74    /// the empty tree, so everything reads as additions.
75    fn staged_base_tree(&self, amend: bool) -> Option<git2::Tree<'_>> {
76        let head = self.repo.head().ok()?.peel_to_commit().ok()?;
77        if amend {
78            head.parent(0).ok().and_then(|p| p.tree().ok())
79        } else {
80            head.tree().ok()
81        }
82    }
83}
84
85impl RepoBackend for Git2Backend {
86    fn path(&self) -> &str {
87        &self.path
88    }
89
90    fn commits(&self) -> &[CommitInfo] {
91        &self.commits
92    }
93
94    fn changed_files(&self, index: usize) -> Vec<FileChange> {
95        let Some(diff) = self.build_diff(index, None) else {
96            return Vec::new();
97        };
98        diff.deltas()
99            .map(|delta| file_change_from_delta(&delta))
100            .collect()
101    }
102
103    fn commit_diff(&self, index: usize) -> Diff {
104        self.build_diff(index, None)
105            .map(render_diff)
106            .unwrap_or_default()
107    }
108
109    fn file_diff(&self, index: usize, path: &str) -> Diff {
110        self.build_diff(index, Some(path))
111            .map(render_diff)
112            .unwrap_or_default()
113    }
114
115    fn working_status(&self, amend: bool) -> WorkingStatus {
116        let base = self.staged_base_tree(amend);
117
118        // Staged side: the index against the base tree (HEAD, or HEAD's parent
119        // when amending). With no base (unborn / amending the root commit) the
120        // whole index reads as additions.
121        let mut staged_opts = DiffOptions::new();
122        let mut staged = WorkingStatus::default();
123        if let Ok(mut diff) =
124            self.repo
125                .diff_tree_to_index(base.as_ref(), None, Some(&mut staged_opts))
126        {
127            let _ = diff.find_similar(None);
128            for delta in diff.deltas() {
129                staged.staged.push(file_change_from_delta(&delta));
130            }
131        }
132
133        // Unstaged side: the working tree against the index (independent of
134        // the amend base), including untracked files.
135        let mut wd_opts = DiffOptions::new();
136        wd_opts.include_untracked(true).recurse_untracked_dirs(true);
137        if let Ok(diff) = self.repo.diff_index_to_workdir(None, Some(&mut wd_opts)) {
138            for delta in diff.deltas() {
139                staged.unstaged.push(file_change_from_delta(&delta));
140            }
141        }
142
143        staged
144    }
145
146    fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff {
147        let mut opts = DiffOptions::new();
148        opts.context_lines(3).pathspec(path);
149        let diff = if staged {
150            let base = self.staged_base_tree(amend);
151            self.repo
152                .diff_tree_to_index(base.as_ref(), None, Some(&mut opts))
153        } else {
154            opts.include_untracked(true)
155                .recurse_untracked_dirs(true)
156                .show_untracked_content(true);
157            self.repo.diff_index_to_workdir(None, Some(&mut opts))
158        };
159        diff.ok().map(render_diff).unwrap_or_default()
160    }
161
162    fn stage(&self, path: &str) -> Result<(), String> {
163        let mut index = self.repo.index().map_err(err_msg)?;
164        let p = Path::new(path);
165        let in_workdir = self
166            .repo
167            .workdir()
168            .map(|w| w.join(path).exists())
169            .unwrap_or(false);
170        if in_workdir {
171            index.add_path(p).map_err(err_msg)?;
172        } else {
173            // The file is gone from the working tree — stage its removal.
174            index.remove_path(p).map_err(err_msg)?;
175        }
176        index.write().map_err(err_msg)
177    }
178
179    fn unstage(&self, path: &str, amend: bool) -> Result<(), String> {
180        // Reset the index entry to the staged base: HEAD normally, HEAD's
181        // parent when amending (which drops the path from the amended commit).
182        let head = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
183        let target: Option<git2::Object> = match (amend, head) {
184            (false, Some(commit)) => Some(commit.into_object()),
185            (true, Some(commit)) => commit.parent(0).ok().map(|p| p.into_object()),
186            (_, None) => None,
187        };
188        match target {
189            Some(obj) => self.repo.reset_default(Some(&obj), [path]).map_err(err_msg),
190            // No base commit (unborn HEAD, or amending the root commit):
191            // unstaging just drops the path back out of the index.
192            None => {
193                let mut index = self.repo.index().map_err(err_msg)?;
194                index.remove_path(Path::new(path)).map_err(err_msg)?;
195                index.write().map_err(err_msg)
196            }
197        }
198    }
199
200    fn revert(&self, path: &str) -> Result<(), String> {
201        // Rewrite the working-tree file from the index, overwriting any
202        // unstaged edits. `update_index(false)` leaves the index untouched, so
203        // a partially-staged file keeps its staged changes — only the
204        // working-vs-index delta is discarded. An untracked path has no index
205        // entry, so the checkout simply skips it.
206        let mut opts = git2::build::CheckoutBuilder::new();
207        opts.force().update_index(false).path(path);
208        self.repo
209            .checkout_index(None, Some(&mut opts))
210            .map_err(err_msg)
211    }
212
213    fn delete_untracked(&self, path: &str) -> Result<(), String> {
214        let workdir = self
215            .repo
216            .workdir()
217            .ok_or_else(|| "bare repository has no working tree".to_string())?;
218        std::fs::remove_file(workdir.join(path)).map_err(|e| e.to_string())
219    }
220
221    fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
222        if message.trim().is_empty() {
223            return Err("Please enter a commit message.".into());
224        }
225        let mut index = self.repo.index().map_err(err_msg)?;
226        let tree_oid = index.write_tree().map_err(err_msg)?;
227        let tree = self.repo.find_tree(tree_oid).map_err(err_msg)?;
228
229        if amend {
230            let head = self
231                .repo
232                .head()
233                .and_then(|h| h.peel_to_commit())
234                .map_err(err_msg)?;
235            // Keep the original author/committer; only the message and tree
236            // change. (`None` tells libgit2 to reuse the existing values.)
237            head.amend(Some("HEAD"), None, None, None, Some(message), Some(&tree))
238                .map_err(err_msg)?;
239        } else {
240            let sig = self.repo.signature().map_err(|_| {
241                "No git identity configured. Set user.name and user.email.".to_string()
242            })?;
243            let parent = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
244            let parents: Vec<&Commit> = parent.iter().collect();
245            self.repo
246                .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
247                .map_err(err_msg)?;
248        }
249        Ok(())
250    }
251
252    fn head_message(&self) -> Option<String> {
253        let commit = self.repo.head().ok()?.peel_to_commit().ok()?;
254        Some(commit.message().unwrap_or("").to_string())
255    }
256
257    fn signature(&self) -> Option<(String, String)> {
258        let sig = self.repo.signature().ok()?;
259        Some((sig.name()?.to_string(), sig.email()?.to_string()))
260    }
261}
262
263/// Map a libgit2 diff delta to our [`FileChange`], collapsing the old path for
264/// non-rename changes.
265fn file_change_from_delta(delta: &git2::DiffDelta) -> FileChange {
266    let new_path = delta.new_file().path().map(|p| p.display().to_string());
267    let old_path = delta.old_file().path().map(|p| p.display().to_string());
268    let status = status_from_delta(delta.status());
269    let path = new_path
270        .clone()
271        .or_else(|| old_path.clone())
272        .unwrap_or_default();
273    FileChange {
274        path,
275        old_path: old_path.filter(|o| Some(o) != new_path.as_ref()),
276        status,
277    }
278}
279
280/// Render a libgit2 error as a short message for the UI's dialog.
281fn err_msg(e: git2::Error) -> String {
282    e.message().to_string()
283}
284
285/// Walk all references once and group branch/tag labels by the commit they
286/// resolve to. The currently checked-out branch is tagged [`RefKind::Head`];
287/// a detached HEAD becomes a [`RefKind::DetachedHead`] label.
288fn collect_refs(repo: &Repository) -> Result<HashMap<Oid, Vec<RefLabel>>, git2::Error> {
289    let mut map: HashMap<Oid, Vec<RefLabel>> = HashMap::new();
290
291    let head = repo.head().ok();
292    let head_branch = head
293        .as_ref()
294        .filter(|h| h.is_branch())
295        .and_then(|h| h.shorthand())
296        .map(str::to_string);
297    let detached = repo.head_detached().unwrap_or(false);
298
299    if detached && let Some(oid) = head.as_ref().and_then(|h| h.target()) {
300        map.entry(oid).or_default().push(RefLabel {
301            name: "HEAD".into(),
302            kind: RefKind::DetachedHead,
303        });
304    }
305
306    if let Ok(references) = repo.references() {
307        for reference in references.flatten() {
308            let Ok(commit) = reference.peel_to_commit() else {
309                continue;
310            };
311            let oid = commit.id();
312            let Some(name) = reference.shorthand().map(str::to_string) else {
313                continue;
314            };
315            let kind = if reference.is_tag() {
316                RefKind::Tag
317            } else if reference.is_remote() {
318                // Skip the synthetic origin/HEAD pointer; it's noise.
319                if name.ends_with("/HEAD") {
320                    continue;
321                }
322                RefKind::RemoteBranch
323            } else if reference.is_branch() {
324                if head_branch.as_deref() == Some(name.as_str()) {
325                    RefKind::Head
326                } else {
327                    RefKind::LocalBranch
328                }
329            } else {
330                continue;
331            };
332            map.entry(oid).or_default().push(RefLabel { name, kind });
333        }
334    }
335
336    // Stable, readable ordering: HEAD/branch first, remotes, then tags.
337    for labels in map.values_mut() {
338        labels.sort_by_key(|l| match l.kind {
339            RefKind::Head | RefKind::DetachedHead => 0,
340            RefKind::LocalBranch => 1,
341            RefKind::RemoteBranch => 2,
342            RefKind::Tag => 3,
343        });
344    }
345
346    Ok(map)
347}
348
349/// Run a reverse-topological, newest-first revwalk from every ref tip and
350/// build a [`CommitInfo`] per commit.
351fn load_commits(
352    repo: &Repository,
353    refs: &HashMap<Oid, Vec<RefLabel>>,
354) -> Result<Vec<CommitInfo>, git2::Error> {
355    let mut revwalk = repo.revwalk()?;
356    revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
357    // Show history reachable from all branches/tags, not just HEAD, so the
358    // browser behaves like `gitk --all`.
359    if revwalk.push_glob("refs/heads/*").is_err() {
360        let _ = revwalk.push_head();
361    }
362    let _ = revwalk.push_glob("refs/remotes/*");
363    let _ = revwalk.push_glob("refs/tags/*");
364    let _ = revwalk.push_head();
365
366    let mut commits = Vec::new();
367    for oid in revwalk {
368        let oid = oid?;
369        let commit = repo.find_commit(oid)?;
370        commits.push(commit_info(&commit, refs));
371    }
372    Ok(commits)
373}
374
375fn commit_info(commit: &Commit, refs: &HashMap<Oid, Vec<RefLabel>>) -> CommitInfo {
376    let id = commit.id().to_string();
377    let short_id = id.chars().take(8).collect();
378    let message = commit.message().unwrap_or("").to_string();
379    let summary = commit
380        .summary()
381        .map(str::to_string)
382        .unwrap_or_else(|| message.lines().next().unwrap_or("").to_string());
383    let author = commit.author();
384    let committer = commit.committer();
385    let time = author.when();
386
387    CommitInfo {
388        short_id,
389        summary,
390        message,
391        author_name: author.name().unwrap_or("").to_string(),
392        author_email: author.email().unwrap_or("").to_string(),
393        committer_name: committer.name().unwrap_or("").to_string(),
394        committer_email: committer.email().unwrap_or("").to_string(),
395        time_seconds: time.seconds(),
396        time_offset_minutes: time.offset_minutes(),
397        parents: commit.parent_ids().map(|p| p.to_string()).collect(),
398        refs: refs.get(&commit.id()).cloned().unwrap_or_default(),
399        id,
400    }
401}
402
403fn status_from_delta(delta: Delta) -> ChangeStatus {
404    match delta {
405        Delta::Added => ChangeStatus::Added,
406        Delta::Deleted => ChangeStatus::Deleted,
407        Delta::Modified => ChangeStatus::Modified,
408        Delta::Renamed => ChangeStatus::Renamed,
409        Delta::Copied => ChangeStatus::Copied,
410        Delta::Typechange => ChangeStatus::TypeChange,
411        Delta::Untracked => ChangeStatus::Untracked,
412        _ => ChangeStatus::Other,
413    }
414}
415
416/// Drive libgit2's patch printer and translate each emitted line into a typed
417/// [`DiffLine`]. Content/hunk/file-header lines keep their text; +/-/context
418/// lines get their origin character prepended so the monospace view reads like
419/// a real unified diff even before color is applied.
420fn render_diff(diff: git2::Diff) -> Diff {
421    let mut lines = Vec::new();
422    let _ = diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
423        let content = String::from_utf8_lossy(line.content());
424        let content = content.trim_end_matches('\n');
425        match line.origin_value() {
426            DiffLineType::FileHeader => {
427                push_multiline(&mut lines, DiffLineKind::FileHeader, content)
428            }
429            DiffLineType::HunkHeader => {
430                push_multiline(&mut lines, DiffLineKind::HunkHeader, content)
431            }
432            DiffLineType::Context => {
433                lines.push(DiffLine::new(DiffLineKind::Context, format!(" {content}")))
434            }
435            DiffLineType::Addition => {
436                lines.push(DiffLine::new(DiffLineKind::Addition, format!("+{content}")))
437            }
438            DiffLineType::Deletion => {
439                lines.push(DiffLine::new(DiffLineKind::Deletion, format!("-{content}")))
440            }
441            DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => {
442                lines.push(DiffLine::new(DiffLineKind::Meta, content.to_string()))
443            }
444            _ => push_multiline(&mut lines, DiffLineKind::Meta, content),
445        }
446        true
447    });
448    Diff { lines }
449}
450
451fn push_multiline(out: &mut Vec<DiffLine>, kind: DiffLineKind, content: &str) {
452    for line in content.split('\n') {
453        out.push(DiffLine::new(kind, line.to_string()));
454    }
455}