gitj 0.3.0

A gitk-style, Windows 3.1-flavored git repository browser and commit helper built on the Saudade toolkit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! The repository abstraction journey's UI talks to.
//!
//! The UI never touches `git2` directly — it goes through [`RepoBackend`].
//! That keeps the widget code testable: snapshot tests render the real UI
//! against a deterministic [`fixture::FixtureBackend`] instead of needing a
//! live repository with machine-dependent SHAs and timestamps.

pub mod fixture;
mod git2_backend;
pub mod patch;

pub use fixture::FixtureBackend;
pub use git2_backend::Git2Backend;
pub use patch::{PartialMode, build_partial_patch, is_change_line};

/// What a ref pointing at a commit is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RefKind {
    /// The currently checked-out branch (drawn specially).
    Head,
    /// A detached `HEAD` sitting directly on the commit.
    DetachedHead,
    /// A local branch.
    LocalBranch,
    /// A remote-tracking branch (`origin/main`).
    RemoteBranch,
    /// A tag.
    Tag,
}

/// A branch / tag / HEAD label attached to a commit row.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefLabel {
    pub name: String,
    pub kind: RefKind,
}

/// How a file changed in a commit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChangeStatus {
    Added,
    Modified,
    Deleted,
    Renamed,
    Copied,
    TypeChange,
    /// A file present in the working tree but not tracked by git.
    Untracked,
    Other,
}

impl ChangeStatus {
    /// Single-letter status badge as gitk / `git status --short` show it.
    pub fn badge(self) -> char {
        match self {
            ChangeStatus::Added => 'A',
            ChangeStatus::Modified => 'M',
            ChangeStatus::Deleted => 'D',
            ChangeStatus::Renamed => 'R',
            ChangeStatus::Copied => 'C',
            ChangeStatus::TypeChange => 'T',
            ChangeStatus::Untracked => '?',
            ChangeStatus::Other => '?',
        }
    }
}

/// One changed path in a commit's diff against its first parent.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileChange {
    pub path: String,
    /// Set for renames/copies: the path the file had before.
    pub old_path: Option<String>,
    pub status: ChangeStatus,
}

impl FileChange {
    /// Display form: `old -> new` for renames, otherwise just the path.
    pub fn display(&self) -> String {
        match (&self.old_path, self.status) {
            (Some(old), ChangeStatus::Renamed | ChangeStatus::Copied) if old != &self.path => {
                format!("{old} -> {}", self.path)
            }
            _ => self.path.clone(),
        }
    }
}

/// The semantic class of a single line in a unified diff. Drives coloring in
/// the [`DiffView`](crate::widgets::DiffView) widget.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DiffLineKind {
    /// `commit <sha>` / `Author:` / `Date:` metadata, as `git show` prints
    /// above the diff. Produced by the UI's commit-detail builder, never by
    /// the raw diff renderer.
    CommitHeader,
    /// `diff --git …`, `index …`, `--- a/…`, `+++ b/…` — file framing.
    FileHeader,
    /// `@@ -a,b +c,d @@` hunk header.
    HunkHeader,
    /// Unchanged context line.
    Context,
    /// `+` added line.
    Addition,
    /// `-` removed line.
    Deletion,
    /// Anything else git emits ("\ No newline at end of file", binary notes).
    Meta,
}

/// One rendered line of a unified diff.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiffLine {
    pub kind: DiffLineKind,
    pub text: String,
}

impl DiffLine {
    pub fn new(kind: DiffLineKind, text: impl Into<String>) -> Self {
        Self {
            kind,
            text: text.into(),
        }
    }
}

/// A whole diff, ready to render line-by-line.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Diff {
    pub lines: Vec<DiffLine>,
}

impl Diff {
    pub fn is_empty(&self) -> bool {
        self.lines.is_empty()
    }
}

/// The raw byte contents of a file's two sides, for a binary / image diff where
/// a text [`Diff`] is meaningless. `old` is the version before the change, `new`
/// after — the same `a`/`b` orientation `git diff` uses. Either side is `None`
/// when it doesn't exist: a freshly added file has no `old`, a deleted file no
/// `new`. The graphical diff decodes these into images to compare.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BlobPair {
    pub old: Option<Vec<u8>>,
    pub new: Option<Vec<u8>>,
}

impl BlobPair {
    /// Neither side exists — there is nothing to compare.
    pub fn is_empty(&self) -> bool {
        self.old.is_none() && self.new.is_none()
    }
}

/// A snapshot of the working tree for commit mode (à la `git gui`).
///
/// A file can appear in *both* lists when it is partially staged: its
/// index differs from `HEAD` (staged) *and* its working copy differs from
/// the index (unstaged).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WorkingStatus {
    /// Files whose working-tree copy differs from the index (not yet staged),
    /// plus untracked files. Maps to `git diff` / the upper "Unstaged Changes"
    /// list.
    pub unstaged: Vec<FileChange>,
    /// Files whose index differs from `HEAD` (staged for the next commit).
    /// Maps to `git diff --cached` / the "Staged Changes" list.
    pub staged: Vec<FileChange>,
}

impl WorkingStatus {
    /// Nothing changed in the working tree or the index.
    pub fn is_clean(&self) -> bool {
        self.unstaged.is_empty() && self.staged.is_empty()
    }
}

/// Everything the UI needs to show about a single commit.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommitInfo {
    /// Full 40-char hex SHA.
    pub id: String,
    /// Abbreviated SHA (first 8 hex chars) for compact display.
    pub short_id: String,
    /// First line of the message.
    pub summary: String,
    /// Full commit message.
    pub message: String,
    pub author_name: String,
    pub author_email: String,
    pub committer_name: String,
    pub committer_email: String,
    /// Author time, seconds since the Unix epoch.
    pub time_seconds: i64,
    /// Author timezone offset in minutes east of UTC.
    pub time_offset_minutes: i32,
    /// Parent SHAs (more than one for merge commits).
    pub parents: Vec<String>,
    /// Branch / tag / HEAD labels that point at this commit.
    pub refs: Vec<RefLabel>,
}

impl CommitInfo {
    /// `2026-05-29 23:10:42` in the commit's own timezone.
    pub fn date_string(&self) -> String {
        format_git_time(self.time_seconds, self.time_offset_minutes)
    }

    /// Short author date `2026-05-29 23:10` for the list row.
    pub fn short_date_string(&self) -> String {
        let full = self.date_string();
        full.get(..16).unwrap_or(&full).to_string()
    }

    pub fn is_merge(&self) -> bool {
        self.parents.len() > 1
    }
}

/// A local or remote branch, as listed by review mode.
///
/// A branch is reviewed as the aggregated diff of everything it contains:
/// from its merge base with the repository's default branch (`base_name`,
/// the same base a pull request would diff against) up to its tip. The tip
/// commit's summary/author/date fill the branch list's columns.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BranchInfo {
    /// Short name: `main`, `feature/x`, `origin/main`.
    pub name: String,
    /// [`RefKind::Head`] for the checked-out branch, otherwise
    /// [`RefKind::LocalBranch`] / [`RefKind::RemoteBranch`].
    pub kind: RefKind,
    /// Full SHA of the branch's tip commit.
    pub tip_id: String,
    /// Summary line of the tip commit.
    pub summary: String,
    /// Author of the tip commit.
    pub author: String,
    /// Tip commit author time, seconds since the Unix epoch.
    pub time_seconds: i64,
    /// Tip commit timezone offset in minutes east of UTC.
    pub time_offset_minutes: i32,
    /// The branch's remote-tracking upstream (e.g. `origin/main`) when it
    /// sits at the same tip: the two are one line in the review list, this
    /// row carrying both names, instead of the remote being listed
    /// separately. `None` for remote branches, untracked locals, and locals
    /// that have diverged from their upstream (the remote then keeps its own
    /// row, since it reviews differently).
    pub upstream: Option<String>,
    /// Name of the branch the review diff is measured against — the
    /// repository's default branch (e.g. `main`).
    pub base_name: String,
    /// The merge base with `base_name`, i.e. the diff base. `None` when the
    /// histories are unrelated; the branch then diffs against the empty tree,
    /// so everything it contains reads as added.
    pub base_id: Option<String>,
}

impl BranchInfo {
    /// Short tip date `2026-05-29 23:10` for the branch list row.
    pub fn short_date_string(&self) -> String {
        let full = format_git_time(self.time_seconds, self.time_offset_minutes);
        full.get(..16).unwrap_or(&full).to_string()
    }
}

/// The interface the UI layer depends on. Implemented by the live
/// [`Git2Backend`] and the in-memory [`FixtureBackend`].
pub trait RepoBackend {
    /// Human-readable path to the repository (shown in the title bar).
    fn path(&self) -> &str;

    /// All commits, newest first (reverse-topological, like `git log`).
    fn commits(&self) -> &[CommitInfo];

    /// Files changed by the commit at `index`, against its first parent.
    fn changed_files(&self, index: usize) -> Vec<FileChange>;

    /// Unified diff of the whole commit against its first parent.
    fn commit_diff(&self, index: usize) -> Diff;

    /// Unified diff for a single file within the commit.
    fn file_diff(&self, index: usize, path: &str) -> Diff;

    /// Raw bytes of a file's two sides in the commit at `index` versus its
    /// first parent — the binary/image analogue of [`file_diff`](Self::file_diff).
    /// The graphical diff uses this to compare images instead of rendering a
    /// useless "Binary files differ" text line. The default returns an empty
    /// pair, so backends without blob access simply never offer an image diff.
    fn commit_file_blobs(&self, index: usize, path: &str) -> BlobPair {
        let _ = (index, path);
        BlobPair::default()
    }

    // ---- review mode (branches) ------------------------------------------

    /// All local and remote branches for review mode's branch list: the
    /// checked-out branch first, then the remaining local branches, then the
    /// remote-tracking ones, each group sorted by name. A remote-tracking
    /// branch whose local tracks it *and* sits at the same tip is folded into
    /// the local's row (see [`BranchInfo::upstream`]) rather than listed
    /// twice. The default is empty, for backends without branch access.
    fn branches(&self) -> Vec<BranchInfo> {
        Vec::new()
    }

    /// Files changed by everything `branch` contains: the aggregated diff
    /// from the branch's review base (see [`BranchInfo::base_id`]) to its tip.
    fn branch_files(&self, branch: &BranchInfo) -> Vec<FileChange> {
        let _ = branch;
        Vec::new()
    }

    /// Unified diff of everything `branch` contains, against its review base.
    fn branch_diff(&self, branch: &BranchInfo) -> Diff {
        let _ = branch;
        Diff::default()
    }

    /// Unified diff for a single file within `branch`'s aggregated diff.
    fn branch_file_diff(&self, branch: &BranchInfo, path: &str) -> Diff {
        let _ = (branch, path);
        Diff::default()
    }

    /// Raw bytes of a file's two sides in `branch`'s aggregated diff — the
    /// review base's copy (`old`) versus the tip's (`new`); the binary/image
    /// analogue of [`branch_file_diff`](Self::branch_file_diff).
    fn branch_file_blobs(&self, branch: &BranchInfo, path: &str) -> BlobPair {
        let _ = (branch, path);
        BlobPair::default()
    }

    // ---- commit mode (working tree) -------------------------------------

    /// The current working-tree status: staged and unstaged changes.
    ///
    /// When `amend` is set the staged side is computed against `HEAD`'s
    /// *parent* instead of `HEAD`, so the changes already in the last commit
    /// show up as staged (they will be part of the amended commit) and can be
    /// unstaged to drop them — exactly how `git gui`'s amend mode behaves.
    fn working_status(&self, amend: bool) -> WorkingStatus;

    /// Diff for a single working-tree path. With `staged` false this is the
    /// working copy against the index (`git diff`); with `staged` true it is
    /// the index against the staged base — `HEAD` normally, `HEAD`'s parent
    /// when `amend` is set.
    fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff;

    /// Raw bytes of a working-tree file's two sides, mirroring
    /// [`working_diff`](Self::working_diff): with `staged` false it is the index
    /// copy (`old`) versus the working copy on disk (`new`); with `staged` true
    /// it is the staged base — `HEAD`, or `HEAD`'s parent when `amend` is set —
    /// (`old`) versus the index copy (`new`). The binary/image analogue of
    /// `working_diff`; the default returns an empty pair.
    fn working_file_blobs(&self, path: &str, staged: bool, amend: bool) -> BlobPair {
        let _ = (path, staged, amend);
        BlobPair::default()
    }

    /// Stage a path (`git add <path>`), staging a deletion if the file is
    /// gone from the working tree. Returns a human-readable error on failure.
    fn stage(&self, path: &str) -> Result<(), String>;

    /// Unstage a path. Normally resets the index entry to `HEAD`
    /// (`git reset HEAD -- <path>`); when `amend` is set it resets to `HEAD`'s
    /// parent, removing the path's change from the commit being amended.
    fn unstage(&self, path: &str, amend: bool) -> Result<(), String>;

    /// Revert (discard) the unstaged working-tree changes to `path`, restoring
    /// the working copy from the index — `git checkout -- <path>`, the
    /// destructive half of `git gui`'s "Revert Changes". Only the
    /// working-vs-index delta is dropped; any *staged* changes to the same path
    /// are preserved. Untracked files have no index entry to restore from; use
    /// [`delete_untracked`](Self::delete_untracked) for those.
    fn revert(&self, path: &str) -> Result<(), String>;

    /// Delete an untracked file from the working tree. This is the "revert" a
    /// brand-new file gets: it isn't in the index or `HEAD`, so the only way to
    /// undo its appearance is to remove it. The content is gone for good — git
    /// never had a copy. The caller is responsible for only passing untracked
    /// paths.
    fn delete_untracked(&self, path: &str) -> Result<(), String>;

    /// Apply a unified-diff patch directly to the index (`git apply --cached`),
    /// the mechanism behind partial (line-range) staging and unstaging. The
    /// patch is always oriented to apply *forward* to the current index — the
    /// stage-vs-unstage direction is baked in by
    /// [`build_partial_patch`](patch::build_partial_patch). The default
    /// implementation rejects it; backends that can model partial application
    /// override it.
    fn apply_to_index(&self, patch: &str) -> Result<(), String> {
        let _ = patch;
        Err("Partial staging is not supported by this backend.".into())
    }

    /// Commit the staged changes with `message`. When `amend` is set, replace
    /// the current `HEAD` commit instead of adding a new one.
    fn commit(&self, message: &str, amend: bool) -> Result<(), String>;

    /// The full message of the current `HEAD` commit, used to pre-fill the
    /// editor when amending. `None` if there is no commit yet.
    fn head_message(&self) -> Option<String>;

    /// The configured commit identity as `(name, email)`, used by the commit
    /// screen's "Sign Off" shortcut to append a `Signed-off-by` trailer.
    /// `None` when no identity is configured.
    fn signature(&self) -> Option<(String, String)> {
        None
    }
}

/// Format a Unix timestamp (+ minute offset) as `YYYY-MM-DD HH:MM:SS ±HHMM`
/// in the given timezone, with no external date crate. Uses Howard Hinnant's
/// civil-from-days algorithm so it is correct for the full proleptic
/// Gregorian range.
pub fn format_git_time(seconds: i64, offset_minutes: i32) -> String {
    let local = seconds + offset_minutes as i64 * 60;
    let days = local.div_euclid(86_400);
    let secs_of_day = local.rem_euclid(86_400);
    let (y, m, d) = civil_from_days(days);
    let hh = secs_of_day / 3600;
    let mm = (secs_of_day % 3600) / 60;
    let ss = secs_of_day % 60;
    let sign = if offset_minutes < 0 { '-' } else { '+' };
    let off = offset_minutes.abs();
    format!(
        "{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02} {sign}{:02}{:02}",
        off / 60,
        off % 60,
    )
}

/// Convert a count of days since 1970-01-01 to a (year, month, day) triple.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
    (if m <= 2 { y + 1 } else { y }, m, d)
}

#[cfg(test)]
mod time_tests {
    use super::format_git_time;

    #[test]
    fn formats_known_timestamps() {
        // 2021-01-01 00:00:00 UTC
        assert_eq!(
            format_git_time(1_609_459_200, 0),
            "2021-01-01 00:00:00 +0000"
        );
        // The Unix epoch itself.
        assert_eq!(format_git_time(0, 0), "1970-01-01 00:00:00 +0000");
        // With a +02:00 offset the wall clock advances two hours.
        assert_eq!(
            format_git_time(1_609_459_200, 120),
            "2021-01-01 02:00:00 +0200"
        );
        // Negative offset rolls the date back across midnight.
        assert_eq!(
            format_git_time(1_609_459_200, -120),
            "2020-12-31 22:00:00 -0200"
        );
    }
}