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;
10
11pub use fixture::FixtureBackend;
12pub use git2_backend::Git2Backend;
13
14/// What a ref pointing at a commit is.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum RefKind {
17    /// The currently checked-out branch (drawn specially).
18    Head,
19    /// A detached `HEAD` sitting directly on the commit.
20    DetachedHead,
21    /// A local branch.
22    LocalBranch,
23    /// A remote-tracking branch (`origin/main`).
24    RemoteBranch,
25    /// A tag.
26    Tag,
27}
28
29/// A branch / tag / HEAD label attached to a commit row.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct RefLabel {
32    pub name: String,
33    pub kind: RefKind,
34}
35
36/// How a file changed in a commit.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum ChangeStatus {
39    Added,
40    Modified,
41    Deleted,
42    Renamed,
43    Copied,
44    TypeChange,
45    /// A file present in the working tree but not tracked by git.
46    Untracked,
47    Other,
48}
49
50impl ChangeStatus {
51    /// Single-letter status badge as gitk / `git status --short` show it.
52    pub fn badge(self) -> char {
53        match self {
54            ChangeStatus::Added => 'A',
55            ChangeStatus::Modified => 'M',
56            ChangeStatus::Deleted => 'D',
57            ChangeStatus::Renamed => 'R',
58            ChangeStatus::Copied => 'C',
59            ChangeStatus::TypeChange => 'T',
60            ChangeStatus::Untracked => '?',
61            ChangeStatus::Other => '?',
62        }
63    }
64}
65
66/// One changed path in a commit's diff against its first parent.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct FileChange {
69    pub path: String,
70    /// Set for renames/copies: the path the file had before.
71    pub old_path: Option<String>,
72    pub status: ChangeStatus,
73}
74
75impl FileChange {
76    /// Display form: `old -> new` for renames, otherwise just the path.
77    pub fn display(&self) -> String {
78        match (&self.old_path, self.status) {
79            (Some(old), ChangeStatus::Renamed | ChangeStatus::Copied) if old != &self.path => {
80                format!("{old} -> {}", self.path)
81            }
82            _ => self.path.clone(),
83        }
84    }
85}
86
87/// The semantic class of a single line in a unified diff. Drives coloring in
88/// the [`DiffView`](crate::widgets::DiffView) widget.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum DiffLineKind {
91    /// `commit <sha>` / `Author:` / `Date:` metadata, as `git show` prints
92    /// above the diff. Produced by the UI's commit-detail builder, never by
93    /// the raw diff renderer.
94    CommitHeader,
95    /// `diff --git …`, `index …`, `--- a/…`, `+++ b/…` — file framing.
96    FileHeader,
97    /// `@@ -a,b +c,d @@` hunk header.
98    HunkHeader,
99    /// Unchanged context line.
100    Context,
101    /// `+` added line.
102    Addition,
103    /// `-` removed line.
104    Deletion,
105    /// Anything else git emits ("\ No newline at end of file", binary notes).
106    Meta,
107}
108
109/// One rendered line of a unified diff.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct DiffLine {
112    pub kind: DiffLineKind,
113    pub text: String,
114}
115
116impl DiffLine {
117    pub fn new(kind: DiffLineKind, text: impl Into<String>) -> Self {
118        Self {
119            kind,
120            text: text.into(),
121        }
122    }
123}
124
125/// A whole diff, ready to render line-by-line.
126#[derive(Clone, Debug, Default, PartialEq, Eq)]
127pub struct Diff {
128    pub lines: Vec<DiffLine>,
129}
130
131impl Diff {
132    pub fn is_empty(&self) -> bool {
133        self.lines.is_empty()
134    }
135}
136
137/// A snapshot of the working tree for commit mode (à la `git gui`).
138///
139/// A file can appear in *both* lists when it is partially staged: its
140/// index differs from `HEAD` (staged) *and* its working copy differs from
141/// the index (unstaged).
142#[derive(Clone, Debug, Default, PartialEq, Eq)]
143pub struct WorkingStatus {
144    /// Files whose working-tree copy differs from the index (not yet staged),
145    /// plus untracked files. Maps to `git diff` / the upper "Unstaged Changes"
146    /// list.
147    pub unstaged: Vec<FileChange>,
148    /// Files whose index differs from `HEAD` (staged for the next commit).
149    /// Maps to `git diff --cached` / the "Staged Changes" list.
150    pub staged: Vec<FileChange>,
151}
152
153impl WorkingStatus {
154    /// Nothing changed in the working tree or the index.
155    pub fn is_clean(&self) -> bool {
156        self.unstaged.is_empty() && self.staged.is_empty()
157    }
158}
159
160/// Everything the UI needs to show about a single commit.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub struct CommitInfo {
163    /// Full 40-char hex SHA.
164    pub id: String,
165    /// Abbreviated SHA (first 8 hex chars) for compact display.
166    pub short_id: String,
167    /// First line of the message.
168    pub summary: String,
169    /// Full commit message.
170    pub message: String,
171    pub author_name: String,
172    pub author_email: String,
173    pub committer_name: String,
174    pub committer_email: String,
175    /// Author time, seconds since the Unix epoch.
176    pub time_seconds: i64,
177    /// Author timezone offset in minutes east of UTC.
178    pub time_offset_minutes: i32,
179    /// Parent SHAs (more than one for merge commits).
180    pub parents: Vec<String>,
181    /// Branch / tag / HEAD labels that point at this commit.
182    pub refs: Vec<RefLabel>,
183}
184
185impl CommitInfo {
186    /// `2026-05-29 23:10:42` in the commit's own timezone.
187    pub fn date_string(&self) -> String {
188        format_git_time(self.time_seconds, self.time_offset_minutes)
189    }
190
191    /// Short author date `2026-05-29 23:10` for the list row.
192    pub fn short_date_string(&self) -> String {
193        let full = self.date_string();
194        full.get(..16).unwrap_or(&full).to_string()
195    }
196
197    pub fn is_merge(&self) -> bool {
198        self.parents.len() > 1
199    }
200}
201
202/// The interface the UI layer depends on. Implemented by the live
203/// [`Git2Backend`] and the in-memory [`FixtureBackend`].
204pub trait RepoBackend {
205    /// Human-readable path to the repository (shown in the title bar).
206    fn path(&self) -> &str;
207
208    /// All commits, newest first (reverse-topological, like `git log`).
209    fn commits(&self) -> &[CommitInfo];
210
211    /// Files changed by the commit at `index`, against its first parent.
212    fn changed_files(&self, index: usize) -> Vec<FileChange>;
213
214    /// Unified diff of the whole commit against its first parent.
215    fn commit_diff(&self, index: usize) -> Diff;
216
217    /// Unified diff for a single file within the commit.
218    fn file_diff(&self, index: usize, path: &str) -> Diff;
219
220    // ---- commit mode (working tree) -------------------------------------
221
222    /// The current working-tree status: staged and unstaged changes.
223    ///
224    /// When `amend` is set the staged side is computed against `HEAD`'s
225    /// *parent* instead of `HEAD`, so the changes already in the last commit
226    /// show up as staged (they will be part of the amended commit) and can be
227    /// unstaged to drop them — exactly how `git gui`'s amend mode behaves.
228    fn working_status(&self, amend: bool) -> WorkingStatus;
229
230    /// Diff for a single working-tree path. With `staged` false this is the
231    /// working copy against the index (`git diff`); with `staged` true it is
232    /// the index against the staged base — `HEAD` normally, `HEAD`'s parent
233    /// when `amend` is set.
234    fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff;
235
236    /// Stage a path (`git add <path>`), staging a deletion if the file is
237    /// gone from the working tree. Returns a human-readable error on failure.
238    fn stage(&self, path: &str) -> Result<(), String>;
239
240    /// Unstage a path. Normally resets the index entry to `HEAD`
241    /// (`git reset HEAD -- <path>`); when `amend` is set it resets to `HEAD`'s
242    /// parent, removing the path's change from the commit being amended.
243    fn unstage(&self, path: &str, amend: bool) -> Result<(), String>;
244
245    /// Revert (discard) the unstaged working-tree changes to `path`, restoring
246    /// the working copy from the index — `git checkout -- <path>`, the
247    /// destructive half of `git gui`'s "Revert Changes". Only the
248    /// working-vs-index delta is dropped; any *staged* changes to the same path
249    /// are preserved. Untracked files have no index entry to restore from; use
250    /// [`delete_untracked`](Self::delete_untracked) for those.
251    fn revert(&self, path: &str) -> Result<(), String>;
252
253    /// Delete an untracked file from the working tree. This is the "revert" a
254    /// brand-new file gets: it isn't in the index or `HEAD`, so the only way to
255    /// undo its appearance is to remove it. The content is gone for good — git
256    /// never had a copy. The caller is responsible for only passing untracked
257    /// paths.
258    fn delete_untracked(&self, path: &str) -> Result<(), String>;
259
260    /// Commit the staged changes with `message`. When `amend` is set, replace
261    /// the current `HEAD` commit instead of adding a new one.
262    fn commit(&self, message: &str, amend: bool) -> Result<(), String>;
263
264    /// The full message of the current `HEAD` commit, used to pre-fill the
265    /// editor when amending. `None` if there is no commit yet.
266    fn head_message(&self) -> Option<String>;
267
268    /// The configured commit identity as `(name, email)`, used by the commit
269    /// screen's "Sign Off" shortcut to append a `Signed-off-by` trailer.
270    /// `None` when no identity is configured.
271    fn signature(&self) -> Option<(String, String)> {
272        None
273    }
274}
275
276/// Format a Unix timestamp (+ minute offset) as `YYYY-MM-DD HH:MM:SS ±HHMM`
277/// in the given timezone, with no external date crate. Uses Howard Hinnant's
278/// civil-from-days algorithm so it is correct for the full proleptic
279/// Gregorian range.
280pub fn format_git_time(seconds: i64, offset_minutes: i32) -> String {
281    let local = seconds + offset_minutes as i64 * 60;
282    let days = local.div_euclid(86_400);
283    let secs_of_day = local.rem_euclid(86_400);
284    let (y, m, d) = civil_from_days(days);
285    let hh = secs_of_day / 3600;
286    let mm = (secs_of_day % 3600) / 60;
287    let ss = secs_of_day % 60;
288    let sign = if offset_minutes < 0 { '-' } else { '+' };
289    let off = offset_minutes.abs();
290    format!(
291        "{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02} {sign}{:02}{:02}",
292        off / 60,
293        off % 60,
294    )
295}
296
297/// Convert a count of days since 1970-01-01 to a (year, month, day) triple.
298fn civil_from_days(z: i64) -> (i64, u32, u32) {
299    let z = z + 719_468;
300    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
301    let doe = z - era * 146_097; // [0, 146096]
302    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
303    let y = yoe + era * 400;
304    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
305    let mp = (5 * doy + 2) / 153; // [0, 11]
306    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
307    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
308    (if m <= 2 { y + 1 } else { y }, m, d)
309}
310
311#[cfg(test)]
312mod time_tests {
313    use super::format_git_time;
314
315    #[test]
316    fn formats_known_timestamps() {
317        // 2021-01-01 00:00:00 UTC
318        assert_eq!(
319            format_git_time(1_609_459_200, 0),
320            "2021-01-01 00:00:00 +0000"
321        );
322        // The Unix epoch itself.
323        assert_eq!(format_git_time(0, 0), "1970-01-01 00:00:00 +0000");
324        // With a +02:00 offset the wall clock advances two hours.
325        assert_eq!(
326            format_git_time(1_609_459_200, 120),
327            "2021-01-01 02:00:00 +0200"
328        );
329        // Negative offset rolls the date back across midnight.
330        assert_eq!(
331            format_git_time(1_609_459_200, -120),
332            "2020-12-31 22:00:00 -0200"
333        );
334    }
335}