Skip to main content

journey/backend/
mod.rs

1//! The repository abstraction journey's UI talks to.
2//!
3//! The UI never touches `git2` directly — it goes through [`RepoBackend`].
4//! That keeps the widget code testable: snapshot tests render the real UI
5//! against a deterministic [`fixture::FixtureBackend`] instead of needing a
6//! live repository with machine-dependent SHAs and timestamps.
7
8pub mod fixture;
9mod git2_backend;
10pub mod patch;
11
12pub use fixture::FixtureBackend;
13pub use git2_backend::Git2Backend;
14pub use patch::{PartialMode, build_partial_patch, is_change_line};
15
16/// What a ref pointing at a commit is.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum RefKind {
19    /// The currently checked-out branch (drawn specially).
20    Head,
21    /// A detached `HEAD` sitting directly on the commit.
22    DetachedHead,
23    /// A local branch.
24    LocalBranch,
25    /// A remote-tracking branch (`origin/main`).
26    RemoteBranch,
27    /// A tag.
28    Tag,
29}
30
31/// A branch / tag / HEAD label attached to a commit row.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct RefLabel {
34    pub name: String,
35    pub kind: RefKind,
36}
37
38/// How a file changed in a commit.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum ChangeStatus {
41    Added,
42    Modified,
43    Deleted,
44    Renamed,
45    Copied,
46    TypeChange,
47    /// A file present in the working tree but not tracked by git.
48    Untracked,
49    Other,
50}
51
52impl ChangeStatus {
53    /// Single-letter status badge as gitk / `git status --short` show it.
54    pub fn badge(self) -> char {
55        match self {
56            ChangeStatus::Added => 'A',
57            ChangeStatus::Modified => 'M',
58            ChangeStatus::Deleted => 'D',
59            ChangeStatus::Renamed => 'R',
60            ChangeStatus::Copied => 'C',
61            ChangeStatus::TypeChange => 'T',
62            ChangeStatus::Untracked => '?',
63            ChangeStatus::Other => '?',
64        }
65    }
66}
67
68/// One changed path in a commit's diff against its first parent.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct FileChange {
71    pub path: String,
72    /// Set for renames/copies: the path the file had before.
73    pub old_path: Option<String>,
74    pub status: ChangeStatus,
75}
76
77impl FileChange {
78    /// Display form: `old -> new` for renames, otherwise just the path.
79    pub fn display(&self) -> String {
80        match (&self.old_path, self.status) {
81            (Some(old), ChangeStatus::Renamed | ChangeStatus::Copied) if old != &self.path => {
82                format!("{old} -> {}", self.path)
83            }
84            _ => self.path.clone(),
85        }
86    }
87}
88
89/// The semantic class of a single line in a unified diff. Drives coloring in
90/// the [`DiffView`](crate::widgets::DiffView) widget.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum DiffLineKind {
93    /// `commit <sha>` / `Author:` / `Date:` metadata, as `git show` prints
94    /// above the diff. Produced by the UI's commit-detail builder, never by
95    /// the raw diff renderer.
96    CommitHeader,
97    /// `diff --git …`, `index …`, `--- a/…`, `+++ b/…` — file framing.
98    FileHeader,
99    /// `@@ -a,b +c,d @@` hunk header.
100    HunkHeader,
101    /// Unchanged context line.
102    Context,
103    /// `+` added line.
104    Addition,
105    /// `-` removed line.
106    Deletion,
107    /// Anything else git emits ("\ No newline at end of file", binary notes).
108    Meta,
109}
110
111/// One rendered line of a unified diff.
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct DiffLine {
114    pub kind: DiffLineKind,
115    pub text: String,
116}
117
118impl DiffLine {
119    pub fn new(kind: DiffLineKind, text: impl Into<String>) -> Self {
120        Self {
121            kind,
122            text: text.into(),
123        }
124    }
125}
126
127/// A whole diff, ready to render line-by-line.
128#[derive(Clone, Debug, Default, PartialEq, Eq)]
129pub struct Diff {
130    pub lines: Vec<DiffLine>,
131}
132
133impl Diff {
134    pub fn is_empty(&self) -> bool {
135        self.lines.is_empty()
136    }
137}
138
139/// The raw byte contents of a file's two sides, for a binary / image diff where
140/// a text [`Diff`] is meaningless. `old` is the version before the change, `new`
141/// after — the same `a`/`b` orientation `git diff` uses. Either side is `None`
142/// when it doesn't exist: a freshly added file has no `old`, a deleted file no
143/// `new`. The graphical diff decodes these into images to compare.
144#[derive(Clone, Debug, Default, PartialEq, Eq)]
145pub struct BlobPair {
146    pub old: Option<Vec<u8>>,
147    pub new: Option<Vec<u8>>,
148}
149
150impl BlobPair {
151    /// Neither side exists — there is nothing to compare.
152    pub fn is_empty(&self) -> bool {
153        self.old.is_none() && self.new.is_none()
154    }
155}
156
157/// A snapshot of the working tree for commit mode (à la `git gui`).
158///
159/// A file can appear in *both* lists when it is partially staged: its
160/// index differs from `HEAD` (staged) *and* its working copy differs from
161/// the index (unstaged).
162#[derive(Clone, Debug, Default, PartialEq, Eq)]
163pub struct WorkingStatus {
164    /// Files whose working-tree copy differs from the index (not yet staged),
165    /// plus untracked files. Maps to `git diff` / the upper "Unstaged Changes"
166    /// list.
167    pub unstaged: Vec<FileChange>,
168    /// Files whose index differs from `HEAD` (staged for the next commit).
169    /// Maps to `git diff --cached` / the "Staged Changes" list.
170    pub staged: Vec<FileChange>,
171}
172
173impl WorkingStatus {
174    /// Nothing changed in the working tree or the index.
175    pub fn is_clean(&self) -> bool {
176        self.unstaged.is_empty() && self.staged.is_empty()
177    }
178}
179
180/// Everything the UI needs to show about a single commit.
181#[derive(Clone, Debug, PartialEq, Eq)]
182pub struct CommitInfo {
183    /// Full 40-char hex SHA.
184    pub id: String,
185    /// Abbreviated SHA (first 8 hex chars) for compact display.
186    pub short_id: String,
187    /// First line of the message.
188    pub summary: String,
189    /// Full commit message.
190    pub message: String,
191    pub author_name: String,
192    pub author_email: String,
193    pub committer_name: String,
194    pub committer_email: String,
195    /// Author time, seconds since the Unix epoch.
196    pub time_seconds: i64,
197    /// Author timezone offset in minutes east of UTC.
198    pub time_offset_minutes: i32,
199    /// Parent SHAs (more than one for merge commits).
200    pub parents: Vec<String>,
201    /// Branch / tag / HEAD labels that point at this commit.
202    pub refs: Vec<RefLabel>,
203}
204
205impl CommitInfo {
206    /// `2026-05-29 23:10:42` in the commit's own timezone.
207    pub fn date_string(&self) -> String {
208        format_git_time(self.time_seconds, self.time_offset_minutes)
209    }
210
211    /// Short author date `2026-05-29 23:10` for the list row.
212    pub fn short_date_string(&self) -> String {
213        let full = self.date_string();
214        full.get(..16).unwrap_or(&full).to_string()
215    }
216
217    pub fn is_merge(&self) -> bool {
218        self.parents.len() > 1
219    }
220}
221
222/// A local or remote branch, as listed by review mode.
223///
224/// A branch is reviewed as the aggregated diff of everything it contains:
225/// from its merge base with the repository's default branch (`base_name`,
226/// the same base a pull request would diff against) up to its tip. The tip
227/// commit's summary/author/date fill the branch list's columns.
228#[derive(Clone, Debug, PartialEq, Eq)]
229pub struct BranchInfo {
230    /// Short name: `main`, `feature/x`, `origin/main`.
231    pub name: String,
232    /// [`RefKind::Head`] for the checked-out branch, otherwise
233    /// [`RefKind::LocalBranch`] / [`RefKind::RemoteBranch`].
234    pub kind: RefKind,
235    /// Full SHA of the branch's tip commit.
236    pub tip_id: String,
237    /// Summary line of the tip commit.
238    pub summary: String,
239    /// Author of the tip commit.
240    pub author: String,
241    /// Tip commit author time, seconds since the Unix epoch.
242    pub time_seconds: i64,
243    /// Tip commit timezone offset in minutes east of UTC.
244    pub time_offset_minutes: i32,
245    /// The branch's remote-tracking upstream (e.g. `origin/main`) when it
246    /// sits at the same tip: the two are one line in the review list, this
247    /// row carrying both names, instead of the remote being listed
248    /// separately. `None` for remote branches, untracked locals, and locals
249    /// that have diverged from their upstream (the remote then keeps its own
250    /// row, since it reviews differently).
251    pub upstream: Option<String>,
252    /// Name of the branch the review diff is measured against — the
253    /// repository's default branch (e.g. `main`).
254    pub base_name: String,
255    /// The merge base with `base_name`, i.e. the diff base. `None` when the
256    /// histories are unrelated; the branch then diffs against the empty tree,
257    /// so everything it contains reads as added.
258    pub base_id: Option<String>,
259}
260
261impl BranchInfo {
262    /// Short tip date `2026-05-29 23:10` for the branch list row.
263    pub fn short_date_string(&self) -> String {
264        let full = format_git_time(self.time_seconds, self.time_offset_minutes);
265        full.get(..16).unwrap_or(&full).to_string()
266    }
267}
268
269/// The interface the UI layer depends on. Implemented by the live
270/// [`Git2Backend`] and the in-memory [`FixtureBackend`].
271pub trait RepoBackend {
272    /// Human-readable path to the repository (shown in the title bar).
273    fn path(&self) -> &str;
274
275    /// All commits, newest first (reverse-topological, like `git log`).
276    fn commits(&self) -> &[CommitInfo];
277
278    /// Files changed by the commit at `index`, against its first parent.
279    fn changed_files(&self, index: usize) -> Vec<FileChange>;
280
281    /// Unified diff of the whole commit against its first parent.
282    fn commit_diff(&self, index: usize) -> Diff;
283
284    /// Unified diff for a single file within the commit.
285    fn file_diff(&self, index: usize, path: &str) -> Diff;
286
287    /// Raw bytes of a file's two sides in the commit at `index` versus its
288    /// first parent — the binary/image analogue of [`file_diff`](Self::file_diff).
289    /// The graphical diff uses this to compare images instead of rendering a
290    /// useless "Binary files differ" text line. The default returns an empty
291    /// pair, so backends without blob access simply never offer an image diff.
292    fn commit_file_blobs(&self, index: usize, path: &str) -> BlobPair {
293        let _ = (index, path);
294        BlobPair::default()
295    }
296
297    // ---- review mode (branches) ------------------------------------------
298
299    /// All local and remote branches for review mode's branch list: the
300    /// checked-out branch first, then the remaining local branches, then the
301    /// remote-tracking ones, each group sorted by name. A remote-tracking
302    /// branch whose local tracks it *and* sits at the same tip is folded into
303    /// the local's row (see [`BranchInfo::upstream`]) rather than listed
304    /// twice. The default is empty, for backends without branch access.
305    fn branches(&self) -> Vec<BranchInfo> {
306        Vec::new()
307    }
308
309    /// Files changed by everything `branch` contains: the aggregated diff
310    /// from the branch's review base (see [`BranchInfo::base_id`]) to its tip.
311    fn branch_files(&self, branch: &BranchInfo) -> Vec<FileChange> {
312        let _ = branch;
313        Vec::new()
314    }
315
316    /// Unified diff of everything `branch` contains, against its review base.
317    fn branch_diff(&self, branch: &BranchInfo) -> Diff {
318        let _ = branch;
319        Diff::default()
320    }
321
322    /// Unified diff for a single file within `branch`'s aggregated diff.
323    fn branch_file_diff(&self, branch: &BranchInfo, path: &str) -> Diff {
324        let _ = (branch, path);
325        Diff::default()
326    }
327
328    /// Raw bytes of a file's two sides in `branch`'s aggregated diff — the
329    /// review base's copy (`old`) versus the tip's (`new`); the binary/image
330    /// analogue of [`branch_file_diff`](Self::branch_file_diff).
331    fn branch_file_blobs(&self, branch: &BranchInfo, path: &str) -> BlobPair {
332        let _ = (branch, path);
333        BlobPair::default()
334    }
335
336    // ---- commit mode (working tree) -------------------------------------
337
338    /// The current working-tree status: staged and unstaged changes.
339    ///
340    /// When `amend` is set the staged side is computed against `HEAD`'s
341    /// *parent* instead of `HEAD`, so the changes already in the last commit
342    /// show up as staged (they will be part of the amended commit) and can be
343    /// unstaged to drop them — exactly how `git gui`'s amend mode behaves.
344    fn working_status(&self, amend: bool) -> WorkingStatus;
345
346    /// Diff for a single working-tree path. With `staged` false this is the
347    /// working copy against the index (`git diff`); with `staged` true it is
348    /// the index against the staged base — `HEAD` normally, `HEAD`'s parent
349    /// when `amend` is set.
350    fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff;
351
352    /// Raw bytes of a working-tree file's two sides, mirroring
353    /// [`working_diff`](Self::working_diff): with `staged` false it is the index
354    /// copy (`old`) versus the working copy on disk (`new`); with `staged` true
355    /// it is the staged base — `HEAD`, or `HEAD`'s parent when `amend` is set —
356    /// (`old`) versus the index copy (`new`). The binary/image analogue of
357    /// `working_diff`; the default returns an empty pair.
358    fn working_file_blobs(&self, path: &str, staged: bool, amend: bool) -> BlobPair {
359        let _ = (path, staged, amend);
360        BlobPair::default()
361    }
362
363    /// Stage a path (`git add <path>`), staging a deletion if the file is
364    /// gone from the working tree. Returns a human-readable error on failure.
365    fn stage(&self, path: &str) -> Result<(), String>;
366
367    /// Unstage a path. Normally resets the index entry to `HEAD`
368    /// (`git reset HEAD -- <path>`); when `amend` is set it resets to `HEAD`'s
369    /// parent, removing the path's change from the commit being amended.
370    fn unstage(&self, path: &str, amend: bool) -> Result<(), String>;
371
372    /// Revert (discard) the unstaged working-tree changes to `path`, restoring
373    /// the working copy from the index — `git checkout -- <path>`, the
374    /// destructive half of `git gui`'s "Revert Changes". Only the
375    /// working-vs-index delta is dropped; any *staged* changes to the same path
376    /// are preserved. Untracked files have no index entry to restore from; use
377    /// [`delete_untracked`](Self::delete_untracked) for those.
378    fn revert(&self, path: &str) -> Result<(), String>;
379
380    /// Delete an untracked file from the working tree. This is the "revert" a
381    /// brand-new file gets: it isn't in the index or `HEAD`, so the only way to
382    /// undo its appearance is to remove it. The content is gone for good — git
383    /// never had a copy. The caller is responsible for only passing untracked
384    /// paths.
385    fn delete_untracked(&self, path: &str) -> Result<(), String>;
386
387    /// Apply a unified-diff patch directly to the index (`git apply --cached`),
388    /// the mechanism behind partial (line-range) staging and unstaging. The
389    /// patch is always oriented to apply *forward* to the current index — the
390    /// stage-vs-unstage direction is baked in by
391    /// [`build_partial_patch`](patch::build_partial_patch). The default
392    /// implementation rejects it; backends that can model partial application
393    /// override it.
394    fn apply_to_index(&self, patch: &str) -> Result<(), String> {
395        let _ = patch;
396        Err("Partial staging is not supported by this backend.".into())
397    }
398
399    /// Commit the staged changes with `message`. When `amend` is set, replace
400    /// the current `HEAD` commit instead of adding a new one.
401    fn commit(&self, message: &str, amend: bool) -> Result<(), String>;
402
403    /// The full message of the current `HEAD` commit, used to pre-fill the
404    /// editor when amending. `None` if there is no commit yet.
405    fn head_message(&self) -> Option<String>;
406
407    /// The configured commit identity as `(name, email)`, used by the commit
408    /// screen's "Sign Off" shortcut to append a `Signed-off-by` trailer.
409    /// `None` when no identity is configured.
410    fn signature(&self) -> Option<(String, String)> {
411        None
412    }
413}
414
415/// Format a Unix timestamp (+ minute offset) as `YYYY-MM-DD HH:MM:SS ±HHMM`
416/// in the given timezone, with no external date crate. Uses Howard Hinnant's
417/// civil-from-days algorithm so it is correct for the full proleptic
418/// Gregorian range.
419pub fn format_git_time(seconds: i64, offset_minutes: i32) -> String {
420    let local = seconds + offset_minutes as i64 * 60;
421    let days = local.div_euclid(86_400);
422    let secs_of_day = local.rem_euclid(86_400);
423    let (y, m, d) = civil_from_days(days);
424    let hh = secs_of_day / 3600;
425    let mm = (secs_of_day % 3600) / 60;
426    let ss = secs_of_day % 60;
427    let sign = if offset_minutes < 0 { '-' } else { '+' };
428    let off = offset_minutes.abs();
429    format!(
430        "{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02} {sign}{:02}{:02}",
431        off / 60,
432        off % 60,
433    )
434}
435
436/// Convert a count of days since 1970-01-01 to a (year, month, day) triple.
437fn civil_from_days(z: i64) -> (i64, u32, u32) {
438    let z = z + 719_468;
439    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
440    let doe = z - era * 146_097; // [0, 146096]
441    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
442    let y = yoe + era * 400;
443    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
444    let mp = (5 * doy + 2) / 153; // [0, 11]
445    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
446    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
447    (if m <= 2 { y + 1 } else { y }, m, d)
448}
449
450#[cfg(test)]
451mod time_tests {
452    use super::format_git_time;
453
454    #[test]
455    fn formats_known_timestamps() {
456        // 2021-01-01 00:00:00 UTC
457        assert_eq!(
458            format_git_time(1_609_459_200, 0),
459            "2021-01-01 00:00:00 +0000"
460        );
461        // The Unix epoch itself.
462        assert_eq!(format_git_time(0, 0), "1970-01-01 00:00:00 +0000");
463        // With a +02:00 offset the wall clock advances two hours.
464        assert_eq!(
465            format_git_time(1_609_459_200, 120),
466            "2021-01-01 02:00:00 +0200"
467        );
468        // Negative offset rolls the date back across midnight.
469        assert_eq!(
470            format_git_time(1_609_459_200, -120),
471            "2020-12-31 22:00:00 -0200"
472        );
473    }
474}