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}