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/// A snapshot of the working tree for commit mode (à la `git gui`).
140///
141/// A file can appear in *both* lists when it is partially staged: its
142/// index differs from `HEAD` (staged) *and* its working copy differs from
143/// the index (unstaged).
144#[derive(Clone, Debug, Default, PartialEq, Eq)]
145pub struct WorkingStatus {
146 /// Files whose working-tree copy differs from the index (not yet staged),
147 /// plus untracked files. Maps to `git diff` / the upper "Unstaged Changes"
148 /// list.
149 pub unstaged: Vec<FileChange>,
150 /// Files whose index differs from `HEAD` (staged for the next commit).
151 /// Maps to `git diff --cached` / the "Staged Changes" list.
152 pub staged: Vec<FileChange>,
153}
154
155impl WorkingStatus {
156 /// Nothing changed in the working tree or the index.
157 pub fn is_clean(&self) -> bool {
158 self.unstaged.is_empty() && self.staged.is_empty()
159 }
160}
161
162/// Everything the UI needs to show about a single commit.
163#[derive(Clone, Debug, PartialEq, Eq)]
164pub struct CommitInfo {
165 /// Full 40-char hex SHA.
166 pub id: String,
167 /// Abbreviated SHA (first 8 hex chars) for compact display.
168 pub short_id: String,
169 /// First line of the message.
170 pub summary: String,
171 /// Full commit message.
172 pub message: String,
173 pub author_name: String,
174 pub author_email: String,
175 pub committer_name: String,
176 pub committer_email: String,
177 /// Author time, seconds since the Unix epoch.
178 pub time_seconds: i64,
179 /// Author timezone offset in minutes east of UTC.
180 pub time_offset_minutes: i32,
181 /// Parent SHAs (more than one for merge commits).
182 pub parents: Vec<String>,
183 /// Branch / tag / HEAD labels that point at this commit.
184 pub refs: Vec<RefLabel>,
185}
186
187impl CommitInfo {
188 /// `2026-05-29 23:10:42` in the commit's own timezone.
189 pub fn date_string(&self) -> String {
190 format_git_time(self.time_seconds, self.time_offset_minutes)
191 }
192
193 /// Short author date `2026-05-29 23:10` for the list row.
194 pub fn short_date_string(&self) -> String {
195 let full = self.date_string();
196 full.get(..16).unwrap_or(&full).to_string()
197 }
198
199 pub fn is_merge(&self) -> bool {
200 self.parents.len() > 1
201 }
202}
203
204/// The interface the UI layer depends on. Implemented by the live
205/// [`Git2Backend`] and the in-memory [`FixtureBackend`].
206pub trait RepoBackend {
207 /// Human-readable path to the repository (shown in the title bar).
208 fn path(&self) -> &str;
209
210 /// All commits, newest first (reverse-topological, like `git log`).
211 fn commits(&self) -> &[CommitInfo];
212
213 /// Files changed by the commit at `index`, against its first parent.
214 fn changed_files(&self, index: usize) -> Vec<FileChange>;
215
216 /// Unified diff of the whole commit against its first parent.
217 fn commit_diff(&self, index: usize) -> Diff;
218
219 /// Unified diff for a single file within the commit.
220 fn file_diff(&self, index: usize, path: &str) -> Diff;
221
222 // ---- commit mode (working tree) -------------------------------------
223
224 /// The current working-tree status: staged and unstaged changes.
225 ///
226 /// When `amend` is set the staged side is computed against `HEAD`'s
227 /// *parent* instead of `HEAD`, so the changes already in the last commit
228 /// show up as staged (they will be part of the amended commit) and can be
229 /// unstaged to drop them — exactly how `git gui`'s amend mode behaves.
230 fn working_status(&self, amend: bool) -> WorkingStatus;
231
232 /// Diff for a single working-tree path. With `staged` false this is the
233 /// working copy against the index (`git diff`); with `staged` true it is
234 /// the index against the staged base — `HEAD` normally, `HEAD`'s parent
235 /// when `amend` is set.
236 fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff;
237
238 /// Stage a path (`git add <path>`), staging a deletion if the file is
239 /// gone from the working tree. Returns a human-readable error on failure.
240 fn stage(&self, path: &str) -> Result<(), String>;
241
242 /// Unstage a path. Normally resets the index entry to `HEAD`
243 /// (`git reset HEAD -- <path>`); when `amend` is set it resets to `HEAD`'s
244 /// parent, removing the path's change from the commit being amended.
245 fn unstage(&self, path: &str, amend: bool) -> Result<(), String>;
246
247 /// Revert (discard) the unstaged working-tree changes to `path`, restoring
248 /// the working copy from the index — `git checkout -- <path>`, the
249 /// destructive half of `git gui`'s "Revert Changes". Only the
250 /// working-vs-index delta is dropped; any *staged* changes to the same path
251 /// are preserved. Untracked files have no index entry to restore from; use
252 /// [`delete_untracked`](Self::delete_untracked) for those.
253 fn revert(&self, path: &str) -> Result<(), String>;
254
255 /// Delete an untracked file from the working tree. This is the "revert" a
256 /// brand-new file gets: it isn't in the index or `HEAD`, so the only way to
257 /// undo its appearance is to remove it. The content is gone for good — git
258 /// never had a copy. The caller is responsible for only passing untracked
259 /// paths.
260 fn delete_untracked(&self, path: &str) -> Result<(), String>;
261
262 /// Apply a unified-diff patch directly to the index (`git apply --cached`),
263 /// the mechanism behind partial (line-range) staging and unstaging. The
264 /// patch is always oriented to apply *forward* to the current index — the
265 /// stage-vs-unstage direction is baked in by
266 /// [`build_partial_patch`](patch::build_partial_patch). The default
267 /// implementation rejects it; backends that can model partial application
268 /// override it.
269 fn apply_to_index(&self, patch: &str) -> Result<(), String> {
270 let _ = patch;
271 Err("Partial staging is not supported by this backend.".into())
272 }
273
274 /// Commit the staged changes with `message`. When `amend` is set, replace
275 /// the current `HEAD` commit instead of adding a new one.
276 fn commit(&self, message: &str, amend: bool) -> Result<(), String>;
277
278 /// The full message of the current `HEAD` commit, used to pre-fill the
279 /// editor when amending. `None` if there is no commit yet.
280 fn head_message(&self) -> Option<String>;
281
282 /// The configured commit identity as `(name, email)`, used by the commit
283 /// screen's "Sign Off" shortcut to append a `Signed-off-by` trailer.
284 /// `None` when no identity is configured.
285 fn signature(&self) -> Option<(String, String)> {
286 None
287 }
288}
289
290/// Format a Unix timestamp (+ minute offset) as `YYYY-MM-DD HH:MM:SS ±HHMM`
291/// in the given timezone, with no external date crate. Uses Howard Hinnant's
292/// civil-from-days algorithm so it is correct for the full proleptic
293/// Gregorian range.
294pub fn format_git_time(seconds: i64, offset_minutes: i32) -> String {
295 let local = seconds + offset_minutes as i64 * 60;
296 let days = local.div_euclid(86_400);
297 let secs_of_day = local.rem_euclid(86_400);
298 let (y, m, d) = civil_from_days(days);
299 let hh = secs_of_day / 3600;
300 let mm = (secs_of_day % 3600) / 60;
301 let ss = secs_of_day % 60;
302 let sign = if offset_minutes < 0 { '-' } else { '+' };
303 let off = offset_minutes.abs();
304 format!(
305 "{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02} {sign}{:02}{:02}",
306 off / 60,
307 off % 60,
308 )
309}
310
311/// Convert a count of days since 1970-01-01 to a (year, month, day) triple.
312fn civil_from_days(z: i64) -> (i64, u32, u32) {
313 let z = z + 719_468;
314 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
315 let doe = z - era * 146_097; // [0, 146096]
316 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
317 let y = yoe + era * 400;
318 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
319 let mp = (5 * doy + 2) / 153; // [0, 11]
320 let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
321 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
322 (if m <= 2 { y + 1 } else { y }, m, d)
323}
324
325#[cfg(test)]
326mod time_tests {
327 use super::format_git_time;
328
329 #[test]
330 fn formats_known_timestamps() {
331 // 2021-01-01 00:00:00 UTC
332 assert_eq!(
333 format_git_time(1_609_459_200, 0),
334 "2021-01-01 00:00:00 +0000"
335 );
336 // The Unix epoch itself.
337 assert_eq!(format_git_time(0, 0), "1970-01-01 00:00:00 +0000");
338 // With a +02:00 offset the wall clock advances two hours.
339 assert_eq!(
340 format_git_time(1_609_459_200, 120),
341 "2021-01-01 02:00:00 +0200"
342 );
343 // Negative offset rolls the date back across midnight.
344 assert_eq!(
345 format_git_time(1_609_459_200, -120),
346 "2020-12-31 22:00:00 -0200"
347 );
348 }
349}