Skip to main content

journey/backend/
fixture.rs

1//! An in-memory [`RepoBackend`] for tests and demos.
2//!
3//! [`FixtureBackend`] holds a hand-built commit history with deterministic
4//! SHAs, timestamps, refs, file lists and diffs, so snapshot tests render
5//! identical pixels on every machine without touching a real repository.
6
7use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9
10use super::{
11    BlobPair, BranchInfo, ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange,
12    RefKind, RefLabel, RepoBackend, WorkingStatus,
13};
14
15/// A file changed by a commit, paired with the diff that produced it.
16pub struct FileEntry {
17    pub change: FileChange,
18    pub diff: Diff,
19}
20
21/// One path in the simulated working tree. Real git tracks separate
22/// working-vs-index and index-vs-HEAD diffs; the fixture keeps a single diff
23/// per file and a `staged` flag, which is enough to drive and snapshot the
24/// commit UI deterministically.
25struct WorkingEntry {
26    change: FileChange,
27    diff: Diff,
28    staged: bool,
29    /// Raw image bytes for the two sides, when this entry is an image whose
30    /// graphical diff a test wants to drive. Empty for ordinary text entries.
31    blobs: BlobPair,
32}
33
34pub struct FixtureBackend {
35    path: String,
36    commits: Vec<CommitInfo>,
37    files: HashMap<usize, Vec<FileEntry>>,
38    /// Branches for review mode, each carrying the aggregated files its
39    /// review shows, in the order they were added.
40    branches: Vec<(BranchInfo, Vec<FileEntry>)>,
41    /// The simulated working tree, mutated by stage/unstage/commit.
42    working: RefCell<Vec<WorkingEntry>>,
43    /// Paths from the HEAD commit the user has pulled out of an in-progress
44    /// amend (so they show as unstaged instead of staged while amending).
45    amend_removed: RefCell<HashSet<String>>,
46    /// The last commit performed, recorded for test assertions: (message, amend).
47    last_commit: RefCell<Option<(String, bool)>>,
48    /// The simulated commit identity returned by [`RepoBackend::signature`].
49    signature: Option<(String, String)>,
50}
51
52impl FixtureBackend {
53    pub fn new(path: impl Into<String>) -> Self {
54        Self {
55            path: path.into(),
56            commits: Vec::new(),
57            files: HashMap::new(),
58            branches: Vec::new(),
59            working: RefCell::new(Vec::new()),
60            amend_removed: RefCell::new(HashSet::new()),
61            last_commit: RefCell::new(None),
62            signature: Some(("Robert Lillack".to_string(), "rob@example.com".to_string())),
63        }
64    }
65
66    /// Override the simulated commit identity (or clear it with `None`).
67    pub fn with_signature(mut self, signature: Option<(String, String)>) -> Self {
68        self.signature = signature;
69        self
70    }
71
72    /// The files belonging to the `HEAD` commit (index 0, newest first) — the
73    /// changes that an amend would re-commit.
74    fn head_files(&self) -> &[FileEntry] {
75        self.files.get(&0).map(Vec::as_slice).unwrap_or(&[])
76    }
77
78    /// The aggregated file entries of the branch named like `branch`.
79    fn branch_entries(&self, branch: &BranchInfo) -> Option<&[FileEntry]> {
80        self.branches
81            .iter()
82            .find(|(info, _)| info.name == branch.name)
83            .map(|(_, entries)| entries.as_slice())
84    }
85
86    /// Append a commit and the files it touched.
87    pub fn add_commit(&mut self, info: CommitInfo, files: Vec<FileEntry>) -> &mut Self {
88        let idx = self.commits.len();
89        self.commits.push(info);
90        self.files.insert(idx, files);
91        self
92    }
93
94    /// Add a path to the simulated working tree (for commit-mode tests/demos).
95    pub fn add_working(
96        &mut self,
97        path: &str,
98        status: ChangeStatus,
99        staged: bool,
100        diff_lines: &[(DiffLineKind, &str)],
101    ) -> &mut Self {
102        self.working.borrow_mut().push(WorkingEntry {
103            change: FileChange {
104                path: path.to_string(),
105                old_path: None,
106                status,
107            },
108            diff: diff(diff_lines),
109            staged,
110            blobs: BlobPair::default(),
111        });
112        self
113    }
114
115    /// Add an *image* path to the simulated working tree. The `old`/`new` bytes
116    /// are what [`RepoBackend::working_file_blobs`] returns for it, so the
117    /// graphical diff can be snapshot-tested deterministically. The text diff is
118    /// the usual "binary files differ" stub the image view replaces.
119    pub fn add_working_image(
120        &mut self,
121        path: &str,
122        status: ChangeStatus,
123        staged: bool,
124        old: Option<Vec<u8>>,
125        new: Option<Vec<u8>>,
126    ) -> &mut Self {
127        self.working.borrow_mut().push(WorkingEntry {
128            change: FileChange {
129                path: path.to_string(),
130                old_path: None,
131                status,
132            },
133            diff: Diff {
134                lines: vec![DiffLine::new(
135                    DiffLineKind::Meta,
136                    format!("Binary files a/{path} and b/{path} differ"),
137                )],
138            },
139            staged,
140            blobs: BlobPair { old, new },
141        });
142        self
143    }
144
145    /// Add a branch and the aggregated files its review-mode diff shows
146    /// (for review-mode tests/demos).
147    pub fn add_branch(&mut self, info: BranchInfo, files: Vec<FileEntry>) -> &mut Self {
148        self.branches.push((info, files));
149        self
150    }
151
152    /// The most recent commit recorded via [`RepoBackend::commit`], as
153    /// `(message, amend)` — exposed for tests.
154    pub fn last_commit(&self) -> Option<(String, bool)> {
155        self.last_commit.borrow().clone()
156    }
157
158    /// A small, realistic history used by snapshot tests and as a demo when no
159    /// real repository is available. Five commits, two refs, varied statuses.
160    pub fn sample() -> Self {
161        let mut be = FixtureBackend::new("/home/rob/dev/journey");
162
163        be.add_commit(
164            commit(
165                "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
166                "Add commit DAG graph view",
167                "Robert Lillack",
168                "rob@example.com",
169                1_716_500_000,
170                120,
171                &["b2c3d4e5f60718293a4b5c6d7e8f90123456789a"],
172                &[("main", RefKind::Head)],
173            ),
174            vec![file_entry(
175                "src/widgets/graph.rs",
176                None,
177                ChangeStatus::Added,
178                &[
179                    (
180                        DiffLineKind::FileHeader,
181                        "diff --git a/src/widgets/graph.rs b/src/widgets/graph.rs",
182                    ),
183                    (DiffLineKind::FileHeader, "new file mode 100644"),
184                    (DiffLineKind::HunkHeader, "@@ -0,0 +1,3 @@"),
185                    (DiffLineKind::Addition, "+pub struct Graph {"),
186                    (DiffLineKind::Addition, "+    lanes: Vec<Lane>,"),
187                    (DiffLineKind::Addition, "+}"),
188                ],
189            )],
190        );
191
192        be.add_commit(
193            commit(
194                "b2c3d4e5f60718293a4b5c6d7e8f90123456789a",
195                "Build basic file list per commit",
196                "Robert Lillack",
197                "rob@example.com",
198                1_716_400_000,
199                120,
200                &["c3d4e5f60718293a4b5c6d7e8f90123456789ab2"],
201                &[("v0.2", RefKind::Tag)],
202            ),
203            vec![
204                file_entry(
205                    "src/backend.rs",
206                    None,
207                    ChangeStatus::Modified,
208                    &[
209                        (
210                            DiffLineKind::FileHeader,
211                            "diff --git a/src/backend.rs b/src/backend.rs",
212                        ),
213                        (
214                            DiffLineKind::HunkHeader,
215                            "@@ -10,6 +10,10 @@ impl Backend {",
216                        ),
217                        (
218                            DiffLineKind::Context,
219                            "     pub fn log(&self) -> Vec<Commit> {",
220                        ),
221                        (
222                            DiffLineKind::Addition,
223                            "+        // collect changed files too",
224                        ),
225                        (DiffLineKind::Addition, "+        self.changed_files();"),
226                        (DiffLineKind::Context, "         self.commits.clone()"),
227                        (DiffLineKind::Context, "     }"),
228                    ],
229                ),
230                file_entry(
231                    "src/main.rs",
232                    None,
233                    ChangeStatus::Modified,
234                    &[
235                        (
236                            DiffLineKind::FileHeader,
237                            "diff --git a/src/main.rs b/src/main.rs",
238                        ),
239                        (DiffLineKind::HunkHeader, "@@ -42,7 +42,7 @@"),
240                        (DiffLineKind::Deletion, "-    let files = vec![];"),
241                        (
242                            DiffLineKind::Addition,
243                            "+    let files = backend.changed_files(idx);",
244                        ),
245                    ],
246                ),
247            ],
248        );
249
250        be.add_commit(
251            commit(
252                "c3d4e5f60718293a4b5c6d7e8f90123456789ab2",
253                "Show path in title",
254                "Robert Lillack",
255                "rob@example.com",
256                1_716_300_000,
257                120,
258                &["d4e5f60718293a4b5c6d7e8f90123456789ab2c3"],
259                &[],
260            ),
261            vec![file_entry(
262                "src/main.rs",
263                None,
264                ChangeStatus::Modified,
265                &[
266                    (
267                        DiffLineKind::FileHeader,
268                        "diff --git a/src/main.rs b/src/main.rs",
269                    ),
270                    (DiffLineKind::HunkHeader, "@@ -20,1 +20,1 @@ fn title()"),
271                    (DiffLineKind::Deletion, "-        String::from(\"Journey\")"),
272                    (
273                        DiffLineKind::Addition,
274                        "+        format!(\"Journey: {}\", path)",
275                    ),
276                ],
277            )],
278        );
279
280        be.add_commit(
281            commit(
282                "d4e5f60718293a4b5c6d7e8f90123456789ab2c3",
283                "Rename boldFont() -> bold_font()",
284                "Robert Lillack",
285                "rob@example.com",
286                1_716_200_000,
287                120,
288                &["e5f60718293a4b5c6d7e8f90123456789ab2c3d4"],
289                &[("origin/main", RefKind::RemoteBranch)],
290            ),
291            vec![file_entry(
292                "src/style.rs",
293                None,
294                ChangeStatus::Modified,
295                &[
296                    (
297                        DiffLineKind::FileHeader,
298                        "diff --git a/src/style.rs b/src/style.rs",
299                    ),
300                    (DiffLineKind::HunkHeader, "@@ -80,4 +80,4 @@"),
301                    (DiffLineKind::Deletion, "-pub fn boldFont() -> Font {"),
302                    (DiffLineKind::Addition, "+pub fn bold_font() -> Font {"),
303                ],
304            )],
305        );
306
307        be.add_commit(
308            commit(
309                "e5f60718293a4b5c6d7e8f90123456789ab2c3d4",
310                "Initial import",
311                "Robert Lillack",
312                "rob@example.com",
313                1_716_100_000,
314                120,
315                &[],
316                &[],
317            ),
318            vec![
319                file_entry(
320                    "Cargo.toml",
321                    None,
322                    ChangeStatus::Added,
323                    &[
324                        (
325                            DiffLineKind::FileHeader,
326                            "diff --git a/Cargo.toml b/Cargo.toml",
327                        ),
328                        (DiffLineKind::FileHeader, "new file mode 100644"),
329                        (DiffLineKind::HunkHeader, "@@ -0,0 +1,2 @@"),
330                        (DiffLineKind::Addition, "+[package]"),
331                        (DiffLineKind::Addition, "+name = \"journey\""),
332                    ],
333                ),
334                file_entry(
335                    "src/main.rs",
336                    None,
337                    ChangeStatus::Added,
338                    &[
339                        (
340                            DiffLineKind::FileHeader,
341                            "diff --git a/src/main.rs b/src/main.rs",
342                        ),
343                        (DiffLineKind::FileHeader, "new file mode 100644"),
344                        (DiffLineKind::HunkHeader, "@@ -0,0 +1,1 @@"),
345                        (DiffLineKind::Addition, "+fn main() {}"),
346                    ],
347                ),
348            ],
349        );
350
351        // A working tree with a realistic mix of staged and unstaged changes,
352        // so commit mode has something to show.
353        be.add_working(
354            "src/ui.rs",
355            ChangeStatus::Modified,
356            false,
357            &[
358                (
359                    DiffLineKind::FileHeader,
360                    "diff --git a/src/ui.rs b/src/ui.rs",
361                ),
362                (
363                    DiffLineKind::HunkHeader,
364                    "@@ -40,6 +40,9 @@ impl GitClient {",
365                ),
366                (DiffLineKind::Context, "     fn sync(&mut self) {"),
367                (
368                    DiffLineKind::Addition,
369                    "+        // refresh the working-tree panes",
370                ),
371                (DiffLineKind::Addition, "+        self.rescan();"),
372                (DiffLineKind::Context, "         self.repaint();"),
373                (DiffLineKind::Context, "     }"),
374            ],
375        );
376        be.add_working(
377            "notes.md",
378            ChangeStatus::Untracked,
379            false,
380            &[
381                (DiffLineKind::FileHeader, "diff --git a/notes.md b/notes.md"),
382                (DiffLineKind::FileHeader, "new file mode 100644"),
383                (DiffLineKind::HunkHeader, "@@ -0,0 +1,2 @@"),
384                (DiffLineKind::Addition, "+# Notes"),
385                (DiffLineKind::Addition, "+- wire up commit mode"),
386            ],
387        );
388        be.add_working(
389            "src/widgets/commit_panel.rs",
390            ChangeStatus::Added,
391            true,
392            &[
393                (
394                    DiffLineKind::FileHeader,
395                    "diff --git a/src/widgets/commit_panel.rs b/src/widgets/commit_panel.rs",
396                ),
397                (DiffLineKind::FileHeader, "new file mode 100644"),
398                (DiffLineKind::HunkHeader, "@@ -0,0 +1,3 @@"),
399                (DiffLineKind::Addition, "+pub struct CommitPanel {"),
400                (DiffLineKind::Addition, "+    message: String,"),
401                (DiffLineKind::Addition, "+}"),
402            ],
403        );
404        be.add_working(
405            "Cargo.toml",
406            ChangeStatus::Modified,
407            true,
408            &[
409                (
410                    DiffLineKind::FileHeader,
411                    "diff --git a/Cargo.toml b/Cargo.toml",
412                ),
413                (
414                    DiffLineKind::HunkHeader,
415                    "@@ -8,3 +8,4 @@ edition = \"2024\"",
416                ),
417                (DiffLineKind::Context, " [dependencies]"),
418                (
419                    DiffLineKind::Addition,
420                    "+git2 = { version = \"0.18\", default-features = false }",
421                ),
422                (
423                    DiffLineKind::Context,
424                    " saudade = { path = \"../saudade\" }",
425                ),
426            ],
427        );
428
429        // Branches for review mode. `main` is in sync with its tracked
430        // `origin/main`, so the two fold into one row; the feature branch
431        // carries an aggregated diff of everything it contains; the
432        // remote-only branch (an already-merged ancestor of main) reviews as
433        // empty.
434        be.add_branch(
435            BranchInfo {
436                name: "main".into(),
437                kind: RefKind::Head,
438                tip_id: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".into(),
439                summary: "Add commit DAG graph view".into(),
440                author: "Robert Lillack".into(),
441                time_seconds: 1_716_500_000,
442                time_offset_minutes: 120,
443                upstream: Some("origin/main".into()),
444                base_name: "main".into(),
445                base_id: Some("a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".into()),
446            },
447            vec![],
448        );
449        be.add_branch(
450            BranchInfo {
451                name: "feature/list-icons".into(),
452                kind: RefKind::LocalBranch,
453                tip_id: "f60718293a4b5c6d7e8f90123456789ab2c3d4e5".into(),
454                summary: "Bake SVG status markers".into(),
455                author: "Robert Lillack".into(),
456                time_seconds: 1_716_450_000,
457                time_offset_minutes: 120,
458                upstream: None,
459                base_name: "main".into(),
460                base_id: Some("c3d4e5f60718293a4b5c6d7e8f90123456789ab2".into()),
461            },
462            vec![
463                file_entry(
464                    "assets/status/added.svg",
465                    None,
466                    ChangeStatus::Added,
467                    &[
468                        (
469                            DiffLineKind::FileHeader,
470                            "diff --git a/assets/status/added.svg b/assets/status/added.svg",
471                        ),
472                        (DiffLineKind::FileHeader, "new file mode 100644"),
473                        (DiffLineKind::HunkHeader, "@@ -0,0 +1,2 @@"),
474                        (DiffLineKind::Addition, "+<svg viewBox=\"0 0 12 12\">"),
475                        (DiffLineKind::Addition, "+  <rect rx=\"2\" fill=\"#2A2\"/>"),
476                    ],
477                ),
478                file_entry(
479                    "src/widgets/list.rs",
480                    None,
481                    ChangeStatus::Modified,
482                    &[
483                        (
484                            DiffLineKind::FileHeader,
485                            "diff --git a/src/widgets/list.rs b/src/widgets/list.rs",
486                        ),
487                        (
488                            DiffLineKind::HunkHeader,
489                            "@@ -12,5 +12,6 @@ impl ListItem {",
490                        ),
491                        (
492                            DiffLineKind::Context,
493                            "     pub fn new(text: &str) -> Self {",
494                        ),
495                        (
496                            DiffLineKind::Deletion,
497                            "-        Self { text: text.into() }",
498                        ),
499                        (
500                            DiffLineKind::Addition,
501                            "+        Self { text: text.into(), icon: None }",
502                        ),
503                        (DiffLineKind::Context, "     }"),
504                        (DiffLineKind::Context, " }"),
505                    ],
506                ),
507            ],
508        );
509        be.add_branch(
510            BranchInfo {
511                name: "origin/font-rename".into(),
512                kind: RefKind::RemoteBranch,
513                tip_id: "d4e5f60718293a4b5c6d7e8f90123456789ab2c3".into(),
514                summary: "Rename boldFont() -> bold_font()".into(),
515                author: "Robert Lillack".into(),
516                time_seconds: 1_716_200_000,
517                time_offset_minutes: 120,
518                upstream: None,
519                base_name: "main".into(),
520                base_id: Some("d4e5f60718293a4b5c6d7e8f90123456789ab2c3".into()),
521            },
522            vec![],
523        );
524
525        be
526    }
527}
528
529impl RepoBackend for FixtureBackend {
530    fn path(&self) -> &str {
531        &self.path
532    }
533
534    fn commits(&self) -> &[CommitInfo] {
535        &self.commits
536    }
537
538    fn changed_files(&self, index: usize) -> Vec<FileChange> {
539        self.files
540            .get(&index)
541            .map(|entries| entries.iter().map(|e| e.change.clone()).collect())
542            .unwrap_or_default()
543    }
544
545    fn commit_diff(&self, index: usize) -> Diff {
546        let mut lines = Vec::new();
547        if let Some(entries) = self.files.get(&index) {
548            for entry in entries {
549                lines.extend(entry.diff.lines.iter().cloned());
550            }
551        }
552        Diff { lines }
553    }
554
555    fn file_diff(&self, index: usize, path: &str) -> Diff {
556        self.files
557            .get(&index)
558            .and_then(|entries| entries.iter().find(|e| e.change.path == path))
559            .map(|e| e.diff.clone())
560            .unwrap_or_default()
561    }
562
563    fn branches(&self) -> Vec<BranchInfo> {
564        self.branches.iter().map(|(info, _)| info.clone()).collect()
565    }
566
567    fn branch_files(&self, branch: &BranchInfo) -> Vec<FileChange> {
568        self.branch_entries(branch)
569            .map(|entries| entries.iter().map(|e| e.change.clone()).collect())
570            .unwrap_or_default()
571    }
572
573    fn branch_diff(&self, branch: &BranchInfo) -> Diff {
574        let mut lines = Vec::new();
575        if let Some(entries) = self.branch_entries(branch) {
576            for entry in entries {
577                lines.extend(entry.diff.lines.iter().cloned());
578            }
579        }
580        Diff { lines }
581    }
582
583    fn branch_file_diff(&self, branch: &BranchInfo, path: &str) -> Diff {
584        self.branch_entries(branch)
585            .and_then(|entries| entries.iter().find(|e| e.change.path == path))
586            .map(|e| e.diff.clone())
587            .unwrap_or_default()
588    }
589
590    fn working_status(&self, amend: bool) -> WorkingStatus {
591        let mut status = WorkingStatus::default();
592        for entry in self.working.borrow().iter() {
593            if entry.staged {
594                status.staged.push(entry.change.clone());
595            } else {
596                status.unstaged.push(entry.change.clone());
597            }
598        }
599        // When amending, the HEAD commit's files join the staged side (they'll
600        // be re-committed) unless the user has pulled them back out.
601        if amend {
602            let removed = self.amend_removed.borrow();
603            for fe in self.head_files() {
604                if removed.contains(&fe.change.path) {
605                    status.unstaged.push(fe.change.clone());
606                } else {
607                    status.staged.push(fe.change.clone());
608                }
609            }
610        }
611        status
612    }
613
614    fn working_diff(&self, path: &str, _staged: bool, amend: bool) -> Diff {
615        // The simulation keeps a single diff per path, so the staged/unstaged
616        // side doesn't change which diff we show.
617        if let Some(diff) = self
618            .working
619            .borrow()
620            .iter()
621            .find(|e| e.change.path == path)
622            .map(|e| e.diff.clone())
623        {
624            return diff;
625        }
626        if amend
627            && let Some(diff) = self
628                .head_files()
629                .iter()
630                .find(|fe| fe.change.path == path)
631                .map(|fe| fe.diff.clone())
632        {
633            return diff;
634        }
635        Diff::default()
636    }
637
638    fn working_file_blobs(&self, path: &str, _staged: bool, _amend: bool) -> BlobPair {
639        // Like `working_diff`, the simulation keeps one set of bytes per path
640        // regardless of the staged/unstaged side.
641        self.working
642            .borrow()
643            .iter()
644            .find(|e| e.change.path == path)
645            .map(|e| e.blobs.clone())
646            .unwrap_or_default()
647    }
648
649    fn stage(&self, path: &str) -> Result<(), String> {
650        let mut found = false;
651        for entry in self.working.borrow_mut().iter_mut() {
652            if entry.change.path == path {
653                entry.staged = true;
654                found = true;
655            }
656        }
657        // Re-staging a HEAD file that had been pulled out of an amend.
658        if !found {
659            self.amend_removed.borrow_mut().remove(path);
660        }
661        Ok(())
662    }
663
664    fn unstage(&self, path: &str, amend: bool) -> Result<(), String> {
665        let mut found = false;
666        for entry in self.working.borrow_mut().iter_mut() {
667            if entry.change.path == path {
668                entry.staged = false;
669                found = true;
670            }
671        }
672        // Dropping a HEAD file from the commit being amended.
673        if !found && amend {
674            self.amend_removed.borrow_mut().insert(path.to_string());
675        }
676        Ok(())
677    }
678
679    fn revert(&self, path: &str) -> Result<(), String> {
680        // Drop the unstaged (working-vs-index) entry for this path, the
681        // simulation's stand-in for restoring it from the index. Staged entries
682        // and untracked files are left alone, matching the live backend.
683        self.working.borrow_mut().retain(|e| {
684            !(e.change.path == path && !e.staged && e.change.status != ChangeStatus::Untracked)
685        });
686        Ok(())
687    }
688
689    fn delete_untracked(&self, path: &str) -> Result<(), String> {
690        // Removing the untracked file takes it out of the simulated working
691        // tree entirely.
692        self.working.borrow_mut().retain(|e| {
693            !(e.change.path == path && !e.staged && e.change.status == ChangeStatus::Untracked)
694        });
695        Ok(())
696    }
697
698    fn apply_to_index(&self, _patch: &str) -> Result<(), String> {
699        // The simulation keeps only a whole-file `staged` flag and no file
700        // contents, so it can't model staging a subset of lines. Accept the
701        // patch as a no-op so commit-mode UI tests can exercise the Stage/
702        // Unstage button without a spurious error dialog; partial-staging
703        // correctness is covered against the live backend instead.
704        Ok(())
705    }
706
707    fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
708        if message.trim().is_empty() {
709            return Err("Please enter a commit message.".into());
710        }
711        // The staged changes are now part of HEAD; drop them from the
712        // working set so the panes clear after committing.
713        self.working.borrow_mut().retain(|e| !e.staged);
714        self.amend_removed.borrow_mut().clear();
715        *self.last_commit.borrow_mut() = Some((message.to_string(), amend));
716        Ok(())
717    }
718
719    fn head_message(&self) -> Option<String> {
720        self.commits.first().map(|c| c.message.clone())
721    }
722
723    fn signature(&self) -> Option<(String, String)> {
724        self.signature.clone()
725    }
726}
727
728/// Build a [`CommitInfo`] without the ceremony of naming every field.
729#[allow(clippy::too_many_arguments)]
730pub fn commit(
731    id: &str,
732    summary: &str,
733    author: &str,
734    email: &str,
735    time_seconds: i64,
736    time_offset_minutes: i32,
737    parents: &[&str],
738    refs: &[(&str, RefKind)],
739) -> CommitInfo {
740    CommitInfo {
741        id: id.to_string(),
742        short_id: id.chars().take(8).collect(),
743        summary: summary.to_string(),
744        message: format!("{summary}\n"),
745        author_name: author.to_string(),
746        author_email: email.to_string(),
747        committer_name: author.to_string(),
748        committer_email: email.to_string(),
749        time_seconds,
750        time_offset_minutes,
751        parents: parents.iter().map(|p| p.to_string()).collect(),
752        refs: refs
753            .iter()
754            .map(|(name, kind)| RefLabel {
755                name: name.to_string(),
756                kind: *kind,
757            })
758            .collect(),
759    }
760}
761
762fn file_entry(
763    path: &str,
764    old_path: Option<&str>,
765    status: ChangeStatus,
766    diff_lines: &[(DiffLineKind, &str)],
767) -> FileEntry {
768    FileEntry {
769        change: FileChange {
770            path: path.to_string(),
771            old_path: old_path.map(str::to_string),
772            status,
773        },
774        diff: diff(diff_lines),
775    }
776}
777
778/// Build a [`Diff`] from `(kind, text)` pairs.
779fn diff(lines: &[(DiffLineKind, &str)]) -> Diff {
780    Diff {
781        lines: lines
782            .iter()
783            .map(|(kind, text)| DiffLine::new(*kind, text.to_string()))
784            .collect(),
785    }
786}