Skip to main content

lattice_magit/
refresh.rs

1//! MG.2: magit-status buffer refresh.
2//!
3//! Runs `git status`, `git stash list`, and `git log` on
4//! `spawn_blocking`, formats the output through the section
5//! index, and applies it to the buffer via `apply_edit_batch`.
6//! No diff commands — diffs load on demand via `=`.
7
8use std::collections::{HashMap, HashSet};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use lattice_cells::StyledSpan;
13use lattice_core::BufferId;
14use lattice_mode::PendingSyntheticHighlightsHandle;
15use lattice_runtime::Document;
16use lattice_vcs::{PathStatus, Repository, Stash, WorkingTree};
17
18use crate::headerline::{self, Field};
19use crate::sections::{Section, SectionEntry, SectionIndex, SectionKind};
20
21/// Build the status buffer text (+ styled spans + MG.14 header
22/// fields) from live git data. Blocking — call on `spawn_blocking`.
23///
24/// The header comes out of the SAME [`SectionIndex`] the body is
25/// formatted from — branch, ahead/behind, and the per-section counts
26/// are all already in hand — so surfacing it costs no extra git call.
27/// (Before MG.14 the index's branch/ahead/behind were computed on
28/// every refresh and thrown away: `SectionIndex::branch_status_line`
29/// was written to render them and never called from anywhere.)
30pub fn build_and_format(
31    workdir: &PathBuf,
32    expanded: &HashSet<String>,
33    context: i64,
34    // DS-fix (2026-08-12): the grammar registry the reopened
35    // expansions highlight through. Without it a refresh rebuilt every
36    // open diff with the flat classifier and the syntax layer vanished.
37    lang_registry: Option<&std::sync::Arc<lattice_syntax::LangRegistry>>,
38) -> (
39    String,
40    Vec<Vec<StyledSpan>>,
41    Vec<Field>,
42    HashMap<String, usize>,
43    // DR.3: intra-line refinement, line-aligned with the spans above.
44    Vec<Vec<lattice_cells::RefineSpan>>,
45) {
46    let repo = match Repository::discover(workdir) {
47        Ok(r) => r,
48        Err(e) => {
49            tracing::debug!(target: "lattice_magit", "refresh: repo discover failed: {e}");
50            return (
51                "Not a git repository.\n".to_string(),
52                Vec::new(),
53                Vec::new(),
54                HashMap::new(),
55                Vec::new(),
56            );
57        }
58    };
59
60    let index = build_section_index(&repo);
61    let header = headerline::status_fields(&index, workdir);
62    // MG.18d: re-run each still-open entry's `git diff` so the rebuilt
63    // buffer carries its expansion. Blocking, like every other call in
64    // this function — it runs on `spawn_blocking`, never the actor.
65    // Cost is proportional to what the user had open, which is what an
66    // expansion already costs; nothing is fetched for a collapsed entry.
67    let (text, spans, reopened, refine) = index.format_buffer_styled_with(
68        |entry, kind| {
69            let line = entry_as_status_line(entry, kind)?;
70            let key = crate::actions::entry_key(&line);
71            if !expanded.contains(&key) {
72                return None;
73            }
74            let diff = crate::actions::run_show(workdir, &line, context)?;
75            (!diff.trim().is_empty()).then_some((key, diff))
76        },
77        lang_registry,
78    );
79    if text.is_empty() {
80        (
81            "No changes (working tree clean)\n".to_string(),
82            Vec::new(),
83            header,
84            HashMap::new(),
85            Vec::new(),
86        )
87    } else {
88        (text, spans, header, reopened.into_iter().collect(), refine)
89    }
90}
91
92/// The [`StatusLine`](crate::actions::StatusLine) a rendered entry
93/// classifies as — the same identity `classify_line` derives from the
94/// row, resolved here from the index instead of the text.
95///
96/// Both sides must agree or an expansion would be keyed one way when
97/// opened by `=` and another when rebuilt by a refresh, and the
98/// entry would silently fail to come back.
99fn entry_as_status_line(
100    entry: &SectionEntry,
101    kind: SectionKind,
102) -> Option<crate::actions::StatusLine> {
103    use crate::actions::StatusLine;
104    Some(match entry {
105        SectionEntry::File {
106            path,
107            status,
108            original_path,
109        } => StatusLine::File {
110            path: path.clone(),
111            staged: kind == SectionKind::Staged,
112            untracked: *status == lattice_vcs::PathStatus::Untracked,
113            original_path: original_path.clone(),
114        },
115        // An untracked file has no diff to show, but it classifies as a
116        // `File` row (`untracked` is one of the entry labels), so it
117        // keys the same way `=` would key it.
118        SectionEntry::UntrackedFile { path } => StatusLine::File {
119            original_path: None,
120            path: path.clone(),
121            staged: false,
122            untracked: true,
123        },
124        SectionEntry::Stash { index, .. } => StatusLine::Stash { index: *index },
125        SectionEntry::Commit { sha, .. } => StatusLine::Commit { sha: sha.clone() },
126    })
127}
128
129fn build_section_index(repo: &Repository) -> SectionIndex {
130    let mut index = SectionIndex {
131        branch: current_branch(repo),
132        ..Default::default()
133    };
134    populate_ahead_behind(repo, &mut index);
135    // MG.21f. Costs a stat in the overwhelmingly common case — the
136    // git calls behind the progress numbers run only once a bisect is
137    // actually in flight, which is why `in_progress` gates `state`.
138    // Which multi-commit operation is stopped mid-flight, if any.
139    // Marker files in the gitdir, so it cannot go stale behind a git
140    // command the user ran in a terminal.
141    index.in_flight = lattice_vcs::InFlightOp::detect(repo);
142    index.bisect = match lattice_vcs::Bisect::state(repo) {
143        Ok(state) => state,
144        Err(e) => {
145            tracing::debug!("magit-status: bisect state unreadable: {e}");
146            None
147        }
148    };
149
150    let statuses = match WorkingTree::statuses(repo) {
151        Ok(s) => s,
152        Err(_) => return index,
153    };
154
155    let mut staged: Vec<SectionEntry> = Vec::new();
156    let mut unstaged: Vec<SectionEntry> = Vec::new();
157    let mut untracked: Vec<SectionEntry> = Vec::new();
158
159    // Porcelain reports TWO independent axes per path (see
160    // `lattice_vcs::PathChange`): what the index has staged, and what
161    // the worktree has beyond it. Place each on its own axis, so a file
162    // carrying both appears in both sections with the correct label on
163    // each row — and a staged MODIFICATION stays "modified" instead of
164    // being reported as `Added` purely to make it land in the staged
165    // section, which is what rendered it as "new file".
166    for (path, change) in statuses {
167        if let Some(staged_status) = change.staged {
168            staged.push(SectionEntry::File {
169                path: path.clone(),
170                status: staged_status,
171                // Only the index axis carries rename/copy detection.
172                original_path: change.original_path.clone(),
173            });
174        }
175        match change.unstaged {
176            Some(PathStatus::Untracked) => {
177                untracked.push(SectionEntry::UntrackedFile { path });
178            }
179            // Ignored files never appear in the status view; `None` is
180            // a worktree that matches the index.
181            Some(PathStatus::Ignored) | None => {}
182            Some(unstaged_status) => {
183                unstaged.push(SectionEntry::File {
184                    path,
185                    status: unstaged_status,
186                    original_path: None,
187                });
188            }
189        }
190    }
191
192    let stashes: Vec<SectionEntry> = Stash::list(repo)
193        .unwrap_or_default()
194        .into_iter()
195        .map(|s| SectionEntry::Stash {
196            index: s.index,
197            message: s.message,
198        })
199        .collect();
200
201    // Commits this branch has that its upstream does not. Magit shows
202    // these INSTEAD of recent commits when there are any — the question
203    // "what have I not pushed yet" answers "what have I done lately"
204    // whenever the answer is non-empty, and showing both would list the
205    // same commits twice under two headings.
206    index.upstream = upstream_ref(repo);
207    let unmerged: Vec<SectionEntry> = unmerged_commits(repo)
208        .into_iter()
209        .map(|(sha, subject)| SectionEntry::Commit { sha, subject })
210        .collect();
211
212    // Only computed when it will be shown — `git log` is a process
213    // spawn, and the common case on a branch with work on it is that
214    // this list is never rendered.
215    let commits: Vec<SectionEntry> = if unmerged.is_empty() {
216        recent_commits(repo)
217            .into_iter()
218            .map(|(sha, subject)| SectionEntry::Commit { sha, subject })
219            .collect()
220    } else {
221        Vec::new()
222    };
223
224    let mut line = 0usize;
225
226    let push_section = |idx: &mut SectionIndex,
227                        entries: Vec<SectionEntry>,
228                        kind: SectionKind,
229                        line: &mut usize| {
230        if entries.is_empty() {
231            return;
232        }
233        let body_start = *line + 1;
234        let body_end = body_start + entries.len();
235        idx.sections.push(Section {
236            kind,
237            header_line: *line,
238            body_start,
239            body_end,
240            entries,
241        });
242        *line = body_end + 1; // +1 for blank separator
243    };
244
245    push_section(&mut index, staged, SectionKind::Staged, &mut line);
246    push_section(&mut index, unstaged, SectionKind::Unstaged, &mut line);
247    push_section(&mut index, untracked, SectionKind::Untracked, &mut line);
248    push_section(&mut index, stashes, SectionKind::Stashes, &mut line);
249    push_section(&mut index, unmerged, SectionKind::Unmerged, &mut line);
250    // `push_section` skips an empty list, so the either/or above needs
251    // no branch here: exactly one of these two ever has entries.
252    push_section(&mut index, commits, SectionKind::RecentCommits, &mut line);
253
254    index
255}
256
257fn current_branch(repo: &Repository) -> String {
258    repo.run_git_str(["rev-parse", "--abbrev-ref", "HEAD"])
259        .map(|s| s.trim().to_string())
260        .unwrap_or_else(|_| "(detached)".to_string())
261}
262
263fn populate_ahead_behind(repo: &Repository, index: &mut SectionIndex) {
264    if let Ok(output) =
265        repo.run_git_str(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
266    {
267        let parts: Vec<&str> = output.split_whitespace().collect();
268        if parts.len() == 2 {
269            // HEAD is left side → parts[0] = ahead (local commits not on upstream)
270            // @{upstream} is right → parts[1] = behind (upstream commits not local)
271            index.ahead = parts[0].parse().unwrap_or(0);
272            index.behind = parts[1].parse().unwrap_or(0);
273        }
274    }
275}
276
277/// The upstream ref this branch tracks, as `origin/main`.
278///
279/// `None` for a branch with no upstream, a detached HEAD, or a repo
280/// with no remote — all ordinary states, not errors, and each one
281/// simply means there is no "unmerged into" question to ask.
282fn upstream_ref(repo: &Repository) -> Option<String> {
283    let out = repo
284        .run_git_str(["rev-parse", "--abbrev-ref", "@{upstream}"])
285        .ok()?;
286    let name = out.trim();
287    (!name.is_empty()).then(|| name.to_string())
288}
289
290/// Commits on HEAD that `@{upstream}` does not have.
291///
292/// Empty when there is no upstream — `git rev-parse` fails and the
293/// whole section is skipped, which is the right rendering for a branch
294/// that tracks nothing.
295///
296/// No `-N` cap, unlike [`recent_commits`]: "how much have I not pushed"
297/// is a number the user wants exactly right, and truncating it to a
298/// screenful would make the header's count a lie.
299fn unmerged_commits(repo: &Repository) -> Vec<(String, String)> {
300    let output = repo
301        .run_git_str(["log", "--format=%h %s", "@{upstream}..HEAD"])
302        .unwrap_or_default();
303    parse_oneline_log(&output)
304}
305
306fn recent_commits(repo: &Repository) -> Vec<(String, String)> {
307    let output = repo
308        .run_git_str(["log", "--oneline", "-20", "--format=%h %s"])
309        .unwrap_or_default();
310    parse_oneline_log(&output)
311}
312
313/// `<sha> <subject>` per line. Shared by both commit sections so the
314/// two cannot drift into parsing the same `git log` output differently.
315fn parse_oneline_log(output: &str) -> Vec<(String, String)> {
316    output
317        .lines()
318        .filter(|l| !l.is_empty())
319        .map(|line| {
320            let mut parts = line.splitn(2, ' ');
321            let sha = parts.next().unwrap_or("").to_string();
322            let subject = parts.next().unwrap_or("").to_string();
323            (sha, subject)
324        })
325        .collect()
326}
327
328/// Apply a full buffer replacement, then store highlights and fire the
329/// waker so the Editor repaints immediately. Async — call from a tokio
330/// task (NOT spawn_blocking). The blocking I/O phase
331/// (`build_and_format`) must complete before calling this.
332pub async fn apply_and_highlight(
333    handle: Arc<dyn Document>,
334    text: String,
335    spans: Vec<Vec<StyledSpan>>,
336    pending_highlights: Option<PendingSyntheticHighlightsHandle>,
337    buffer_id: BufferId,
338) {
339    apply_and_highlight_refined(
340        handle,
341        text,
342        spans,
343        Vec::new(),
344        pending_highlights,
345        buffer_id,
346    )
347    .await
348}
349
350/// DR.3: as [`apply_and_highlight`], publishing intra-line refinement
351/// with the spans — one update, so the two cannot drift when an inline
352/// expansion shifts lines.
353pub async fn apply_and_highlight_refined(
354    handle: Arc<dyn Document>,
355    text: String,
356    spans: Vec<Vec<StyledSpan>>,
357    refine: Vec<Vec<lattice_cells::RefineSpan>>,
358    pending_highlights: Option<PendingSyntheticHighlightsHandle>,
359    buffer_id: BufferId,
360) {
361    crate::buffer_io::replace_buffer_text(&handle, text).await;
362    if let Some(ref ph) = pending_highlights {
363        ph.store_refined_and_wake(buffer_id, spans, refine);
364    }
365}
366
367/// MG.18d — a refresh no longer throws away what you had open.
368///
369/// Before this, every refresh replaced the buffer with a collapsed
370/// rebuild and cleared the expansion map to match. At file granularity
371/// that was tolerable; at hunk granularity it means the diff you were
372/// staging out of disappears on the first `s`.
373#[cfg(test)]
374mod expansion_survives_refresh {
375    use super::*;
376    use std::process::Command;
377
378    fn git_ok(dir: &std::path::Path, args: &[&str]) {
379        let st = Command::new("git")
380            .args(args)
381            .current_dir(dir)
382            .status()
383            .expect("git");
384        assert!(st.success(), "git {args:?} failed");
385    }
386
387    /// A repo with one modified, unstaged file.
388    fn repo_with_an_unstaged_change() -> tempfile::TempDir {
389        let dir = tempfile::tempdir().expect("tempdir");
390        let p = dir.path();
391        git_ok(p, &["init"]);
392        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
393        git_ok(p, &["config", "user.name", "lattice-test"]);
394        let base: String = (1..=20).map(|i| format!("line {i}\n")).collect();
395        std::fs::write(p.join("a.txt"), &base).unwrap();
396        git_ok(p, &["add", "a.txt"]);
397        git_ok(p, &["commit", "-m", "base"]);
398        let edited: String = (1..=20)
399            .map(|i| match i {
400                2 => "line 2 EDITED\n".to_string(),
401                19 => "line 19 EDITED\n".to_string(),
402                _ => format!("line {i}\n"),
403            })
404            .collect();
405        std::fs::write(p.join("a.txt"), &edited).unwrap();
406        dir
407    }
408
409    fn open_key() -> String {
410        crate::actions::entry_key(&crate::actions::StatusLine::File {
411            path: PathBuf::from("a.txt"),
412            staged: false,
413            untracked: false,
414            original_path: None,
415        })
416    }
417
418    /// A repo whose modified file is Rust, so the diff has something
419    /// for a grammar to colour.
420    fn repo_with_an_unstaged_rust_change() -> tempfile::TempDir {
421        let dir = tempfile::tempdir().expect("tempdir");
422        let p = dir.path();
423        git_ok(p, &["init"]);
424        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
425        git_ok(p, &["config", "user.name", "lattice-test"]);
426        std::fs::write(p.join("a.rs"), "fn main() {\n    let old = 1;\n}\n").unwrap();
427        git_ok(p, &["add", "a.rs"]);
428        git_ok(p, &["commit", "-m", "base"]);
429        std::fs::write(p.join("a.rs"), "fn main() {\n    let new = 2;\n}\n").unwrap();
430        dir
431    }
432
433    fn rust_open_key() -> String {
434        crate::actions::entry_key(&crate::actions::StatusLine::File {
435            path: PathBuf::from("a.rs"),
436            staged: false,
437            untracked: false,
438            original_path: None,
439        })
440    }
441
442    /// DS-fix (2026-08-12): THE regression. A refresh rebuilt every open
443    /// expansion with the FLAT classifier, so the syntax layer DS.1–DS.5
444    /// added silently vanished on `gr` — diff colouring stayed, token
445    /// colour did not.
446    ///
447    /// The absent assertion is what let it through: `sections.rs` called
448    /// `highlight::diff_styled_spans` directly while the `=` toggle went
449    /// through `hunk_syntax::diff_spans`, and nothing compared the two
450    /// routes. This does.
451    #[test]
452    fn a_refreshed_expansion_keeps_its_syntax_highlighting() {
453        let dir = repo_with_an_unstaged_rust_change();
454        let wd = dir.path().to_path_buf();
455        let open: HashSet<String> = [rust_open_key()].into_iter().collect();
456        let registry = lattice_syntax::LangRegistry::standard().expect("standard registry");
457
458        let (_text, with_syntax, _h, _r, _) = build_and_format(&wd, &open, 3, Some(&registry));
459        let (_text, flat, _h, _r, _) = build_and_format(&wd, &open, 3, None);
460
461        // The flat route is what the bug shipped; it must still be
462        // reachable (a harness without grammars), just not the default.
463        let count = |rows: &Vec<Vec<StyledSpan>>| rows.iter().map(|r| r.len()).sum::<usize>();
464        assert!(
465            count(&with_syntax) > count(&flat),
466            "a registry must add the syntax layer: {} spans with, {} without",
467            count(&with_syntax),
468            count(&flat),
469        );
470    }
471
472    /// The invariant `sections.rs`'s own comment claimed and the code
473    /// stopped honouring: an expansion looks the same however it got
474    /// there. Compares the refresh route against the `=`-toggle route
475    /// on the same diff text.
476    #[test]
477    fn refresh_and_toggle_produce_the_same_spans_for_one_diff() {
478        let dir = repo_with_an_unstaged_rust_change();
479        let wd = dir.path().to_path_buf();
480        let registry = lattice_syntax::LangRegistry::standard().expect("standard registry");
481        let line = crate::actions::StatusLine::File {
482            path: PathBuf::from("a.rs"),
483            staged: false,
484            untracked: false,
485            original_path: None,
486        };
487        let diff = crate::actions::run_show(&wd, &line, 3).expect("diff");
488        let diff = diff.trim_end();
489
490        // The `=` toggle's route.
491        let toggle = crate::hunk_syntax::diff_spans(diff, Some(&registry));
492
493        // The refresh route, sliced back out of the rebuilt buffer.
494        let open: HashSet<String> = [rust_open_key()].into_iter().collect();
495        let (text, refreshed, _h, _r, _) = build_and_format(&wd, &open, 3, Some(&registry));
496        let start = text
497            .lines()
498            .position(|l| l.starts_with("diff --git"))
499            .expect("the inlined diff is in the buffer");
500        let slice: Vec<Vec<StyledSpan>> = refreshed
501            .into_iter()
502            .skip(start)
503            .take(toggle.len())
504            .collect();
505
506        assert_eq!(
507            slice, toggle,
508            "an expansion must look identical however it got there"
509        );
510    }
511
512    #[test]
513    fn an_open_entrys_diff_comes_back_in_the_rebuilt_text() {
514        let dir = repo_with_an_unstaged_change();
515        let wd = dir.path().to_path_buf();
516
517        let collapsed = build_and_format(&wd, &HashSet::new(), 3, None);
518        assert!(
519            !collapsed.0.contains("@@"),
520            "nothing was open, so no diff is inlined:\n{}",
521            collapsed.0
522        );
523        assert!(collapsed.3.is_empty());
524
525        let open: HashSet<String> = [open_key()].into_iter().collect();
526        let (text, spans, _, reopened, _) = build_and_format(&wd, &open, 3, None);
527        assert!(
528            text.contains("line 2 EDITED"),
529            "the open entry's diff is inlined:\n{text}"
530        );
531        // The recorded count is what a later collapse deletes, so it
532        // must equal exactly the rows the rebuild added. Anything else
533        // eats a neighbouring entry or leaves orphaned diff rows —
534        // the failure `collapse_range`'s own regression test covers
535        // from the other side.
536        assert_eq!(
537            reopened.get(&open_key()).copied(),
538            Some(text.lines().count() - collapsed.0.lines().count()),
539            "the recomputed count is exactly the rows the expansion added"
540        );
541        assert_eq!(
542            spans.len(),
543            text.lines().count(),
544            "one span row per text row — a mismatch shifts every highlight below the diff"
545        );
546    }
547
548    /// Reported from real use (2026-08-03): `<Tab>` on a file with no
549    /// diff hid that file AND every file below it in the section.
550    ///
551    /// `fold_to_close_at` picks by **containment**, so a row with no
552    /// fold of its own closes whichever fold currently spans it. That is
553    /// correct behaviour given a correct fold list — the bug was that
554    /// the list was stale: `=` edits the buffer through the document
555    /// handle, which never goes through the Editor's edit path, so
556    /// nothing recomputed folds and the ranges stayed pinned to the text
557    /// as it was before the diff was spliced in. A leftover range then
558    /// spanned half the buffer.
559    ///
560    /// This pins the half that must hold for the fix to mean anything:
561    /// the ranges the source emits describe the text as it is NOW, and
562    /// an entry with nothing expanded emits NO fold — so once the list
563    /// is fresh there is nothing for `<Tab>` on such a row to close, and
564    /// it says "No fold found" instead of eating the section.
565    ///
566    /// (The trigger half — recomputing when the buffer changes out of
567    /// band — is `Editor::refresh_overlay_folds`, wired into the tick
568    /// beside `refresh_diff_folds`.)
569    #[test]
570    fn an_entry_with_nothing_expanded_contributes_no_fold_range() {
571        let dir = repo_with_an_unstaged_change();
572        let wd = dir.path().to_path_buf();
573
574        // Nothing expanded: the source has no ranges to offer at all,
575        // which is what leaves `<Tab>` on such a row with nothing to
576        // close.
577        let (collapsed_text, _, _, collapsed_expanded, _) =
578            build_and_format(&wd, &HashSet::new(), 3, None);
579        assert!(collapsed_expanded.is_empty(), "nothing is expanded");
580
581        // Expanded: exactly one entry gains a range, and it must stop at
582        // that entry's own diff rather than running on into the rows
583        // below — the stale-range shape the report describes.
584        let open: HashSet<String> = [open_key()].into_iter().collect();
585        let (text, _, _, expanded, _) = build_and_format(&wd, &open, 3, None);
586        let count = expanded
587            .get(&open_key())
588            .copied()
589            .expect("the open entry records its row count");
590        let added = text.lines().count() - collapsed_text.lines().count();
591        assert_eq!(
592            count, added,
593            "the fold body is exactly the rows the expansion added — a \
594             count larger than that is precisely the range that swallows \
595             the entries below it"
596        );
597
598        // And the body really is this file's diff, not the next entry.
599        let header = text
600            .lines()
601            .position(|l| l.contains("a.txt"))
602            .expect("the entry row");
603        let body: Vec<&str> = text.lines().skip(header + 1).take(count).collect();
604        assert!(
605            body.iter().any(|l| l.starts_with("@@")),
606            "the folded body is the diff:\n{body:#?}"
607        );
608        assert!(
609            !body
610                .iter()
611                .any(|l| l.trim_start().starts_with("Untracked") || l.contains("Recent commits")),
612            "and it stops before the next section:\n{body:#?}"
613        );
614    }
615
616    /// The entry key the refresh matches on must be the one `=` writes,
617    /// or an expansion would silently fail to come back.
618    #[test]
619    fn the_rebuilt_key_is_the_one_the_toggle_uses() {
620        let dir = repo_with_an_unstaged_change();
621        let open: HashSet<String> = [open_key()].into_iter().collect();
622        let (_, _, _, reopened, _) = build_and_format(&dir.path().to_path_buf(), &open, 3, None);
623        assert!(
624            reopened.contains_key(&open_key()),
625            "keyed as `f:false:a.txt`, the same as `classify_line` derives from the row"
626        );
627    }
628
629    /// A key for a file that is no longer in the status output must not
630    /// resurrect anything — and must not survive into the new map.
631    #[test]
632    fn a_stale_key_expands_nothing_and_does_not_survive() {
633        let dir = repo_with_an_unstaged_change();
634        let stale: HashSet<String> = ["f:false:gone.txt".to_string()].into_iter().collect();
635        let (text, _, _, reopened, _) =
636            build_and_format(&dir.path().to_path_buf(), &stale, 3, None);
637        assert!(!text.contains("@@"), "nothing inlined:\n{text}");
638        assert!(reopened.is_empty(), "the stale key is dropped, not carried");
639    }
640}
641
642#[cfg(test)]
643mod unmerged_into_upstream {
644    use super::*;
645    use std::process::Command;
646
647    fn git_ok(dir: &std::path::Path, args: &[&str]) {
648        let st = Command::new("git")
649            .args(args)
650            .current_dir(dir)
651            .status()
652            .expect("git");
653        assert!(st.success(), "git {args:?} failed");
654    }
655
656    /// A repo whose branch tracks a bare "remote", with `ahead`
657    /// commits made after the last push.
658    ///
659    /// A real bare remote rather than a stub: `@{upstream}` is resolved
660    /// by git itself, and a fake would prove only that our own fake
661    /// works.
662    fn repo_tracking_upstream(ahead: usize) -> (tempfile::TempDir, tempfile::TempDir) {
663        let remote = tempfile::tempdir().expect("tempdir");
664        git_ok(remote.path(), &["init", "--bare"]);
665
666        let dir = tempfile::tempdir().expect("tempdir");
667        let p = dir.path();
668        git_ok(p, &["init"]);
669        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
670        git_ok(p, &["config", "user.name", "lattice-test"]);
671        std::fs::write(p.join("a.txt"), "base\n").unwrap();
672        git_ok(p, &["add", "a.txt"]);
673        git_ok(p, &["commit", "-m", "base"]);
674        git_ok(p, &["branch", "-M", "main"]);
675        let remote_url = remote.path().to_string_lossy().into_owned();
676        git_ok(p, &["remote", "add", "origin", &remote_url]);
677        git_ok(p, &["push", "-u", "origin", "main"]);
678
679        for i in 0..ahead {
680            std::fs::write(p.join("a.txt"), format!("change {i}\n")).unwrap();
681            git_ok(p, &["add", "a.txt"]);
682            git_ok(p, &["commit", "-m", &format!("local change {i}")]);
683        }
684        (dir, remote)
685    }
686
687    fn kinds(index: &SectionIndex) -> Vec<SectionKind> {
688        index.sections.iter().map(|s| s.kind).collect()
689    }
690
691    /// The reported behaviour: commits the upstream does not have get
692    /// their own section, named for the upstream.
693    #[test]
694    fn unpushed_commits_appear_under_the_upstream_name() {
695        let (dir, _remote) = repo_tracking_upstream(3);
696        let repo = Repository::discover(dir.path()).expect("repo");
697        let index = build_section_index(&repo);
698
699        assert_eq!(index.upstream.as_deref(), Some("origin/main"));
700        let unmerged = index
701            .sections
702            .iter()
703            .find(|s| s.kind == SectionKind::Unmerged)
704            .expect("an unmerged section");
705        assert_eq!(unmerged.entries.len(), 3, "one row per unpushed commit");
706
707        let rendered = index.format_buffer();
708        assert!(
709            rendered.contains("Unmerged into origin/main (3)"),
710            "header names the upstream and counts: {rendered}"
711        );
712    }
713
714    /// The either/or rule: recent commits are shown ONLY when there is
715    /// nothing unpushed. Listing both would repeat the same commits
716    /// under two headings.
717    #[test]
718    fn recent_commits_are_hidden_while_anything_is_unmerged() {
719        let (dir, _remote) = repo_tracking_upstream(2);
720        let repo = Repository::discover(dir.path()).expect("repo");
721        let k = kinds(&build_section_index(&repo));
722        assert!(k.contains(&SectionKind::Unmerged), "got {k:?}");
723        assert!(
724            !k.contains(&SectionKind::RecentCommits),
725            "recent commits must yield to unmerged: {k:?}"
726        );
727    }
728
729    /// And they come back once everything is pushed — the transition
730    /// the user described, and the half that a one-way test would miss.
731    #[test]
732    fn recent_commits_return_once_everything_is_pushed() {
733        let (dir, _remote) = repo_tracking_upstream(2);
734        git_ok(dir.path(), &["push", "origin", "main"]);
735        let repo = Repository::discover(dir.path()).expect("repo");
736        let k = kinds(&build_section_index(&repo));
737        assert!(
738            !k.contains(&SectionKind::Unmerged),
739            "nothing is unpushed now: {k:?}"
740        );
741        assert!(
742            k.contains(&SectionKind::RecentCommits),
743            "recent commits come back: {k:?}"
744        );
745    }
746
747    /// A branch with no upstream is an ordinary state, not an error:
748    /// no unmerged section, and recent commits render as they always
749    /// did.
750    #[test]
751    fn a_branch_without_an_upstream_shows_recent_commits() {
752        let dir = tempfile::tempdir().expect("tempdir");
753        let p = dir.path();
754        git_ok(p, &["init"]);
755        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
756        git_ok(p, &["config", "user.name", "lattice-test"]);
757        std::fs::write(p.join("a.txt"), "base\n").unwrap();
758        git_ok(p, &["add", "a.txt"]);
759        git_ok(p, &["commit", "-m", "base"]);
760
761        let repo = Repository::discover(p).expect("repo");
762        let index = build_section_index(&repo);
763        assert_eq!(index.upstream, None);
764        let k = kinds(&index);
765        assert!(!k.contains(&SectionKind::Unmerged), "got {k:?}");
766        assert!(k.contains(&SectionKind::RecentCommits), "got {k:?}");
767    }
768
769    #[test]
770    fn the_log_parser_splits_sha_from_subject() {
771        let parsed = parse_oneline_log("abc1234 fix the thing\ndef5678 another\n");
772        assert_eq!(
773            parsed,
774            vec![
775                ("abc1234".to_string(), "fix the thing".to_string()),
776                ("def5678".to_string(), "another".to_string()),
777            ]
778        );
779    }
780}