Skip to main content

lattice_magit/
magit_commit_mode.rs

1//! MG.4: magit-commit major mode.
2//!
3//! Shows the staged diff (read-only top region) and an editable
4//! message region below. C-c C-c commits, C-c C-k aborts.
5
6use std::sync::{Arc, Mutex, OnceLock};
7
8use lattice_config;
9use lattice_grammar::Effect;
10use lattice_mode::{
11    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
12    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
13    keymap_entry,
14};
15use lattice_vcs::{Commit, Repository};
16
17use crate::buffer_state::{BufferStateGuard, BufferStates};
18use crate::headerline;
19
20pub struct MagitCommitMode;
21
22impl MagitCommitMode {
23    pub fn mode_id() -> ModeId {
24        ModeId::new("magit-commit-mode")
25    }
26}
27
28/// Separates the editable message (above) from the read-only staged
29/// diff (below).
30///
31/// The message is on TOP, matching `git commit --verbose` and Emacs
32/// magit: you open this buffer to write a message, so the cursor
33/// should land where you type without scrolling past a diff that may
34/// be hundreds of lines long. The diff is reference material for
35/// while you write, which is what "below" means.
36const DIFF_MARKER: &str = "--- Staged diff (review only — not part of the message) ---";
37
38fn magit_commit_keymap_entries() -> &'static [KeymapEntry] {
39    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
40    ENTRIES.get_or_init(|| {
41        vec![
42            keymap_entry! { mode: Insert, chord: "<C-c><C-c>", doc: "Confirm commit", cmd: "action:magit-commit-confirm" },
43            keymap_entry! { mode: Insert, chord: "<C-c><C-k>", doc: "Abort commit", cmd: "action:magit-commit-abort" },
44            keymap_entry! { mode: Normal, chord: "<C-c><C-c>", doc: "Confirm commit", cmd: "action:magit-commit-confirm" },
45            keymap_entry! { mode: Normal, chord: "<C-c><C-k>", doc: "Abort commit", cmd: "action:magit-commit-abort" },
46        ]
47    })
48}
49
50/// MG.42-E1: what the compose buffer is FOR.
51///
52/// Replaces an `amend: bool` that was derived by sniffing the buffer
53/// name. A bool cannot express a third intent, and the sniff coupled a
54/// buffer's *name* to its *behaviour* — rename the buffer and the
55/// operation silently changes. The name still selects the intent, but
56/// it does so once, explicitly, at open.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum CommitIntent {
59    /// A new commit from what is staged.
60    Create,
61    /// Replace the last commit, sweeping in anything staged.
62    Amend,
63    /// Replace the last commit's MESSAGE only, leaving the index
64    /// alone. Distinct from `Amend` precisely because a reword that
65    /// quietly absorbed staged changes would be a content change the
66    /// user never asked for.
67    Reword,
68    /// Record a `squash!` marker for `target`, carrying the note the
69    /// user writes in the buffer. Magit's `A`.
70    Augment { target: String },
71    /// Complete a merge of `branch` with an authored message. Magit's
72    /// merge `e`.
73    MergeEdit { branch: String },
74    /// MG.43c: reword a commit that is NOT HEAD, via an interactive
75    /// rebase. Magit's rebase `w`.
76    ///
77    /// Distinct from [`Self::Reword`], which amends HEAD directly. A
78    /// commit further back cannot be amended, so this replays history
79    /// with that commit's todo verb set to `reword` and hands git the
80    /// message through `GIT_EDITOR`.
81    RewordCommit { target: String },
82}
83
84impl CommitIntent {
85    /// Map a compose buffer's name to its intent. Kept in ONE place so
86    /// the mapping is auditable.
87    ///
88    /// MR.3: read through the shared name grammar
89    /// (`*magit:<view>[:<repo>[:<rest>]]*`) rather than by substring.
90    ///
91    /// The substring form it replaces (`name.contains("reword")`) was
92    /// fine while names held only a view word; with a repository in the
93    /// name, a checkout called `amend` or `reword` would have selected
94    /// the wrong operation on every commit buffer in it. Structured
95    /// parsing makes that unrepresentable rather than unlikely.
96    ///
97    /// A name that does not parse at all falls to `Create`, which is
98    /// what an unrecognised compose buffer did before.
99    pub fn from_buffer_name(name: &str) -> Self {
100        let Some(parsed) = crate::workdir::parse_magit_name(name) else {
101            return Self::Create;
102        };
103        // Targeted intents carry their target IN the name
104        // (`*magit:augment:<repo>:<sha>*`), so the compose buffer needs
105        // no side-channel and `:ls` shows what it is about to act on.
106        match (parsed.view, parsed.rest) {
107            ("augment", Some(target)) => Self::Augment {
108                target: target.to_string(),
109            },
110            ("merge-edit", Some(branch)) => Self::MergeEdit {
111                branch: branch.to_string(),
112            },
113            ("reword-commit", Some(target)) => Self::RewordCommit {
114                target: target.to_string(),
115            },
116            ("reword", _) => Self::Reword,
117            ("amend", _) => Self::Amend,
118            _ => Self::Create,
119        }
120    }
121
122    /// The buffer name that selects this intent, for the repository
123    /// labelled `repo` (empty outside one).
124    pub fn augment_buffer_name(repo: &str, target: &str) -> String {
125        crate::workdir::magit_buffer_name_with("augment", repo, target)
126    }
127
128    pub fn merge_edit_buffer_name(repo: &str, branch: &str) -> String {
129        crate::workdir::magit_buffer_name_with("merge-edit", repo, branch)
130    }
131
132    pub fn reword_commit_buffer_name(repo: &str, target: &str) -> String {
133        crate::workdir::magit_buffer_name_with("reword-commit", repo, target)
134    }
135
136    /// Does the buffer open pre-filled with the previous message?
137    pub fn seeds_prior_message(&self) -> bool {
138        // `RewordCommit` seeds too — a reword starts from the message
139        // being replaced, whichever commit it is on.
140        matches!(self, Self::Amend | Self::Reword | Self::RewordCommit { .. })
141    }
142
143    /// The commit whose message seeds the buffer, when it is not HEAD.
144    pub fn seed_source(&self) -> Option<&str> {
145        match self {
146            Self::RewordCommit { target } => Some(target.as_str()),
147            _ => None,
148        }
149    }
150}
151
152pub struct CommitState {
153    buffer_id: lattice_core::BufferId,
154    store: Arc<BufferStoreHandle>,
155    workdir: std::path::PathBuf,
156    intent: CommitIntent,
157    /// Line of `DIFF_MARKER` — the boundary between the editable
158    /// message above and the read-only staged diff below. `<CR>`'s
159    /// file-visit handler only fires BELOW it, so pressing it while
160    /// writing the message does nothing rather than jumping away.
161    diff_start_line: u32,
162}
163
164/// MG.22: this buffer's [`MagitView`].
165///
166/// The commit buffer had none, which cost it twice: `<CR>` needed its
167/// own handler and its own diff-path parser, and hunk staging was
168/// **refused outright** in the staged region below the message,
169/// because `diff_source` fell through to the trait default (`None` =
170/// "not classifiable here").
171///
172/// Its diff is `git diff --cached` — the index, by construction. So
173/// `u` unstages a hunk from the commit you are composing, and `s` is
174/// correctly refused with "already staged".
175struct CommitView(Arc<Mutex<CommitState>>);
176
177impl crate::buffer_state::MagitView for CommitView {
178    /// This buffer's content is a unified diff, so "a file" is a
179    /// `diff --git` header — not the generic indented-row scan, which
180    /// here matches every indented CONTEXT line and would walk `]f`
181    /// through arbitrary code.
182    fn file_lines(
183        &self,
184        store: &lattice_mode::BufferStoreHandle,
185        buffer: lattice_core::BufferId,
186    ) -> Option<Vec<u32>> {
187        Some(crate::magit_core_mode::diff_file_lines(store, buffer))
188    }
189
190    /// The staged diff is rebuilt by the mode's own lifecycle, not by
191    /// `gr` — re-running it here would race the message the user is
192    /// typing above it.
193    fn refresh(&self) -> Option<Effect> {
194        None
195    }
196
197    /// Only *below* the marker. Above it is the message being written,
198    /// which is not diff content at all — the same boundary `<CR>`
199    /// respected before this view existed.
200    fn diff_source(
201        &self,
202        cursor: lattice_protocol::position::Position,
203    ) -> Option<crate::buffer_state::DiffSource> {
204        let g = self.0.lock().ok()?;
205        (cursor.line > g.diff_start_line).then_some(crate::buffer_state::DiffSource::Staged)
206    }
207
208    /// The index's blob — this buffer's diff is the index.
209    fn diff_target(
210        &self,
211        path: &std::path::Path,
212        _cursor: lattice_protocol::position::Position,
213    ) -> Option<Effect> {
214        let label = {
215            let g = self.0.lock().ok()?;
216            crate::repo_scope::label_of_buffer(&g.store, g.buffer_id)
217        };
218        Some(Effect::OpenSyntheticBuffer {
219            name: crate::magit_file_revision_mode::blob_buffer_name(&label, "staged", path),
220            mode_id: "magit-file-revision-mode".to_string(),
221            content: None,
222            cursor: None,
223            activate_minor: None,
224        })
225    }
226
227    fn workdir(&self) -> Option<std::path::PathBuf> {
228        Some(self.0.lock().ok()?.workdir.clone())
229    }
230}
231
232/// MG.13: service alias for this mode's per-buffer state
233/// (`feedback_servicesregistry_arc_typeid`).
234pub type CommitStatesHandle = Arc<BufferStates<CommitState>>;
235
236fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<CommitState>>> {
237    crate::buffer_state::state_for::<CommitState>(ctx)
238}
239
240impl Mode for MagitCommitMode {
241    type Guard = BufferStateGuard<CommitState>;
242
243    fn id(&self) -> ModeId {
244        Self::mode_id()
245    }
246    fn kind(&self) -> ModeKind {
247        ModeKind::Major
248    }
249    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
250        None
251    }
252
253    fn options(&self) -> OptionOverrideSet {
254        lattice_config::overrides! {
255            lattice_config::NoFile = true,
256            lattice_config::Number = false,
257            // RF.4: a commit message is prose, and the 72-column
258            // convention for the body is the most widely followed
259            // wrapping rule in software. `git commit` itself does not
260            // wrap; every editor integration is expected to.
261            lattice_config::AutoWrapOption = lattice_core::AutoWrap::All,
262            lattice_config::TextWidth = 72,
263        }
264    }
265
266    fn required_capabilities(&self) -> CapabilitySet {
267        CapabilitySet::empty()
268    }
269    fn keymap(&self) -> Keymap {
270        Keymap::from_entries(magit_commit_keymap_entries())
271    }
272
273    /// MG.13: boot-registered — see `buffer_state`'s module docs.
274    ///
275    /// `diff_end_line` is the one field this mode cannot know before
276    /// its `.await` (it is derived from the generated buffer text). It
277    /// is published as `0` and filled in afterwards; a `<CR>` landing
278    /// in that window sees `cursor.line >= 0` and declines, which is
279    /// the correct answer for a buffer whose diff region does not exist
280    /// yet.
281    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
282        vec![
283            // ── confirm (C-c C-c) ──────────────────────
284            ActionHandlerContribution {
285                action_name: "action:magit-commit-confirm",
286                handler: Arc::new(|ctx: &ActionContext<'_>| {
287                    let s = state(ctx)?;
288                    let (message, workdir, intent) = {
289                        let g = s.lock().ok()?;
290                        let handle = g.store.handle_for(g.buffer_id)?;
291                        let snap = handle.snapshot();
292                        // The message is everything ABOVE the diff
293                        // marker. Stopping at the marker (rather than
294                        // skipping past it) means a diff line can never
295                        // leak into a commit message, even if the user
296                        // edited or deleted the marker line itself —
297                        // absent marker ⇒ the whole buffer is message,
298                        // which is the safe direction for a buffer
299                        // whose entire purpose is the message.
300                        let mut message = String::new();
301                        for l in 0..snap.buffer.content_line_count() {
302                            let text = snap.buffer.line(l).unwrap_or_default();
303                            if text.contains(DIFF_MARKER) {
304                                break;
305                            }
306                            if !text.trim().is_empty() {
307                                message.push_str(&text);
308                                message.push('\n');
309                            }
310                        }
311                        (message, g.workdir.clone(), g.intent.clone())
312                    };
313                    if message.trim().is_empty() {
314                        // Fail loud instead of silently doing nothing —
315                        // an empty subject used to just no-op the chord
316                        // with no feedback.
317                        return Some(Effect::Echo {
318                            level: lattice_grammar::EchoLevel::Error,
319                            text: "magit: commit message is empty".to_string(),
320                        });
321                    }
322                    // Commit is a bounded, single-object git write
323                    // (unlike `git status`/`git diff`, it never scans
324                    // the working tree) — but it's still disk I/O, so it
325                    // stays off the actor thread like every other
326                    // mutation. The buffer is KILLED, not buried: a
327                    // buried `*magit:commit*` is reused by the next
328                    // commit without being re-seeded, which brought the
329                    // previous message back. It closes optimistically; a
330                    // failure surfaces via `tracing::error!` (no
331                    // synchronous path back to the echo area from a
332                    // detached task) rather than leaving the compose
333                    // buffer open forever on a rare `gix` failure.
334                    tokio::task::spawn(tokio::task::spawn_blocking(move || {
335                        let Ok(repo) = Repository::discover(&workdir) else {
336                            tracing::error!(target: "lattice_magit", "commit: repo discover failed");
337                            return;
338                        };
339                        let result = match &intent {
340                            CommitIntent::Amend => Commit::amend(&repo, message.trim()),
341                            CommitIntent::Reword => Commit::reword(&repo, message.trim()),
342                            CommitIntent::Augment { target } => {
343                                Commit::augment(&repo, target, message.trim())
344                            }
345                            CommitIntent::MergeEdit { branch } => {
346                                Commit::merge_with_message(&repo, branch, message.trim())
347                            }
348                            // MG.43c: not an amend — a commit further
349                            // back cannot be amended, so this replays
350                            // history with that commit's todo verb set
351                            // to `reword` and hands git the message
352                            // through `GIT_EDITOR`.
353                            CommitIntent::RewordCommit { target } => {
354                                crate::magit_rebase_mode::rebase_one_commit(
355                                    &workdir,
356                                    target,
357                                    "reword",
358                                    Some(message.trim()),
359                                )
360                                .map_err(lattice_vcs::VcsError::Index)
361                            }
362                            CommitIntent::Create => Commit::create(&repo, message.trim()),
363                        };
364                        if let Err(e) = result {
365                            tracing::error!(target: "lattice_magit", "commit failed: {e}");
366                        }
367                    }));
368                    Some(Effect::KillBuffer)
369                }),
370            },
371            // ── abort (C-c C-k) ─────────────────────────
372            ActionHandlerContribution {
373                action_name: "action:magit-commit-abort",
374                handler: Arc::new(|ctx: &ActionContext<'_>| {
375                    let _ = state(ctx)?;
376                    Some(Effect::KillBuffer)
377                }),
378            },
379            // <CR> — visit the file at cursor AS STAGED (the index
380            // blob), not the live working-tree file: this buffer shows
381            // the STAGED diff specifically, which may already differ
382            // from a since-edited working copy. Same target
383            // magit-diff-mode's Staged-scoped `<CR>` opens.
384        ]
385    }
386
387    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
388        Box::pin(async move {
389            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
390            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
391            let Some(store) = ctx.service::<BufferStoreHandle>() else {
392                return Ok(orphan());
393            };
394            let Some(handle) = store.handle_for(buffer_id) else {
395                return Ok(orphan());
396            };
397
398            // MR.3: the repository the trigger resolved for THIS
399            // buffer, not the one the editor was started in.
400            let workdir =
401                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
402
403            // MG.42-E1: the name selects the intent ONCE, here.
404            let intent = store
405                .name_for(buffer_id)
406                .map(|n| CommitIntent::from_buffer_name(&n))
407                .unwrap_or(CommitIntent::Create);
408            let amend = intent.seeds_prior_message();
409            // MG.43c: a reword-a-commit buffer seeds from the commit it
410            // names, not from HEAD. Seeding from HEAD would put the
411            // WRONG message in front of the user and, since the buffer
412            // is what gets written back, silently overwrite the target's
413            // message with a different commit's.
414            let seed_rev = intent.seed_source().unwrap_or("HEAD").to_string();
415
416            // MG.14: what is staged is not knowable until the diff
417            // below lands, so the header fills in with it. `AMEND` is
418            // known now but is published together with the rest — a
419            // half-row that gains fields a beat later reads as a
420            // glitch.
421            let (hl, hl_registration) =
422                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
423                    Some((h, reg)) => (Some(h), Some(reg)),
424                    None => (None, None),
425                };
426
427            // MG.13: publish BEFORE the first `.await`. `diff_end_line`
428            // is not knowable yet (it comes out of the generated text);
429            // it starts at 0 — which makes `<CR>` decline rather than
430            // act on a diff region that does not exist — and is filled
431            // in below once the buffer is populated.
432            let Some(states) = ctx.service::<CommitStatesHandle>() else {
433                return Ok(orphan());
434            };
435            let state = states.publish(
436                buffer_id,
437                CommitState {
438                    buffer_id,
439                    store: store.clone(),
440                    workdir: workdir.clone(),
441                    intent: intent.clone(),
442                    diff_start_line: u32::MAX,
443                },
444            );
445            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
446                .with_headerline(hl_registration);
447            // MG.22: publish the view. Without it `<CR>` has no target
448            // to resolve here, and hunk staging in the staged region
449            // stays refused for want of a `diff_source`.
450            if let Some(views) = ctx.service::<crate::buffer_state::MagitViewsHandle>() {
451                views.publish(buffer_id, Arc::new(CommitView(state.clone())));
452                guard = guard.with_views((*views).clone());
453            }
454
455            // Populate the buffer: staged diff + message area. Amend
456            // pre-populates the previous commit's message instead of a
457            // blank region, matching what it's about to replace.
458            let wd = workdir.clone();
459            let (staged, prior_message, branch) = tokio::task::spawn_blocking(move || {
460                let staged = run_staged_diff(&wd);
461                let prior = if amend {
462                    run_commit_message(&wd, &seed_rev)
463                } else {
464                    String::new()
465                };
466                // MG.14: the branch this commit lands on. One
467                // `rev-parse` inside the SAME blocking call that
468                // already runs two git commands.
469                let branch = Repository::discover(&wd)
470                    .ok()
471                    .and_then(|r| r.run_git_str(["rev-parse", "--abbrev-ref", "HEAD"]).ok())
472                    .map(|s| s.trim().to_string())
473                    .unwrap_or_default();
474                (staged, prior, branch)
475            })
476            .await
477            .unwrap_or_default();
478            headerline::publish(
479                &hl,
480                headerline::commit_fields(&branch, &staged, intent != CommitIntent::Create),
481            );
482            // Message first (line 0 for a fresh commit, so the cursor
483            // opens where you type), then the marker, then the diff.
484            let initial = format!(
485                "{}\n\
486                 \n\
487                 {DIFF_MARKER}\n\
488                 {}\n",
489                prior_message.trim(),
490                if staged.is_empty() {
491                    "(nothing staged)"
492                } else {
493                    &staged
494                },
495            );
496            // Everything at/below the marker is the diff. Scoping the
497            // styler to that range keeps the marker's own leading
498            // `---` from being misclassified as a diff file marker
499            // (see `highlight::commit_buffer_styled_spans`).
500            let diff_start_line = initial
501                .lines()
502                .position(|l| l.contains(DIFF_MARKER))
503                .unwrap_or(0);
504            let line_count = initial.lines().count();
505            let spans = crate::hunk_syntax::windowed_diff_spans(
506                &initial,
507                diff_start_line + 1,
508                line_count,
509                crate::hunk_syntax::syntax_registry(
510                    ctx.service::<std::sync::Arc<lattice_syntax::LangRegistry>>()
511                        .map(|outer| (*outer).clone()),
512                    ctx.service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
513                        .map(|outer| (*outer).clone())
514                        .as_ref(),
515                )
516                .as_ref(),
517            );
518            crate::buffer_io::replace_buffer_text(&handle, initial).await;
519            if let Some(ph) = ctx.service::<lattice_mode::PendingSyntheticHighlights>() {
520                ph.store_and_wake(buffer_id, spans);
521            }
522
523            // Late-resolved field, now that the text exists.
524            if let Ok(mut g) = state.lock() {
525                g.diff_start_line = diff_start_line as u32;
526            }
527
528            Ok(guard)
529        })
530    }
531}
532
533fn run_staged_diff(workdir: &std::path::Path) -> String {
534    std::process::Command::new("git")
535        .args(["diff", "--cached"])
536        .current_dir(workdir)
537        .output()
538        .ok()
539        .and_then(|o| String::from_utf8(o.stdout).ok())
540        .unwrap_or_default()
541}
542
543/// `git log -1 --format=%B <rev>` — that commit's full message, used
544/// to pre-populate a replacing buffer instead of leaving it blank.
545///
546/// MG.43c made the revision a parameter. Amend and reword act on HEAD;
547/// rebase `w` acts on a commit further back, and seeding it from HEAD
548/// would show the wrong message and then write it onto the target.
549fn run_commit_message(workdir: &std::path::Path, rev: &str) -> String {
550    std::process::Command::new("git")
551        .args(["log", "-1", "--format=%B", rev])
552        .current_dir(workdir)
553        .output()
554        .ok()
555        .filter(|o| o.status.success())
556        .and_then(|o| String::from_utf8(o.stdout).ok())
557        .unwrap_or_default()
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    /// The message is above the diff. Reported directly: you open this
565    /// buffer to write a message, so the cursor must land where you
566    /// type rather than after a diff that can be hundreds of lines.
567    /// Matches `git commit --verbose` and Emacs magit.
568    /// RF.4: a commit message is prose at 72 columns — the most widely
569    /// followed wrapping convention in software, and one `git commit`
570    /// itself does not apply, so the editor is expected to.
571    #[test]
572    fn commit_messages_wrap_at_seventy_two() {
573        use std::any::TypeId;
574        let opts = MagitCommitMode.options();
575        for want in [
576            TypeId::of::<lattice_config::AutoWrapOption>(),
577            TypeId::of::<lattice_config::TextWidth>(),
578        ] {
579            assert!(
580                opts.iter().any(|o| o.option_type_id == want),
581                "magit-commit-mode must set both autowrap and textwidth"
582            );
583        }
584    }
585
586    #[test]
587    fn the_message_area_comes_before_the_diff() {
588        let buffer = format!("subject line\n\n{DIFF_MARKER}\ndiff --git a/x b/x\n+added\n");
589        let marker = buffer
590            .lines()
591            .position(|l| l.contains(DIFF_MARKER))
592            .expect("marker present");
593        assert_eq!(marker, 2, "the diff marker must sit below the message");
594        assert!(
595            buffer.lines().next().unwrap().contains("subject"),
596            "line 0 is the subject, so a fresh commit opens with the cursor \
597             already in the message"
598        );
599    }
600
601    /// Extraction stops AT the marker rather than skipping past it, so
602    /// a diff line cannot end up in a commit message even if the user
603    /// edited or deleted the marker.
604    #[test]
605    fn a_diff_line_never_leaks_into_the_extracted_message() {
606        let buffer =
607            format!("subject\n\nbody line\n{DIFF_MARKER}\ndiff --git a/x b/x\n+added\n-removed\n");
608        let mut message = String::new();
609        for line in buffer.lines() {
610            if line.contains(DIFF_MARKER) {
611                break;
612            }
613            if !line.trim().is_empty() {
614                message.push_str(line);
615                message.push('\n');
616            }
617        }
618        assert_eq!(message, "subject\nbody line\n");
619        assert!(!message.contains("diff --git"));
620        assert!(!message.contains("+added"));
621    }
622
623    /// With the marker gone, the whole buffer is message. That is the
624    /// safe direction to fail for a buffer whose entire purpose is the
625    /// message — better than silently committing nothing.
626    #[test]
627    fn a_missing_marker_treats_everything_as_message() {
628        let buffer = "just a subject\n";
629        let mut message = String::new();
630        for line in buffer.lines() {
631            if line.contains(DIFF_MARKER) {
632                break;
633            }
634            if !line.trim().is_empty() {
635                message.push_str(line);
636                message.push('\n');
637            }
638        }
639        assert_eq!(message, "just a subject\n");
640    }
641
642    /// `<CR>` visits a file only BELOW the marker. Pressing it while
643    /// writing the message must do nothing rather than jump away
644    /// mid-sentence.
645    #[test]
646    fn enter_visits_a_file_only_below_the_diff_marker() {
647        let diff_start: u32 = 2;
648        for line in [0u32, 1, 2] {
649            assert!(line <= diff_start, "line {line} is message or marker");
650        }
651        assert!(3 > diff_start, "line 3 is inside the diff");
652    }
653
654    /// Before the diff lands, `diff_start_line` is `u32::MAX` so the
655    /// gate refuses everything — a `<CR>` in the window between the
656    /// buffer opening and git answering must not visit a file chosen
657    /// from a half-built buffer.
658    #[test]
659    fn the_gate_refuses_until_the_diff_boundary_is_known() {
660        let unset = u32::MAX;
661        for line in [0u32, 5, 1000] {
662            assert!(line <= unset, "every line is refused while unset");
663        }
664    }
665}