grit_lib/porcelain/rebase.rs
1//! `git rebase` todo-list model and squash/fixup message assembly.
2//!
3//! The full rebase command in the `grit` binary is a ~12k-line stateful
4//! sequencer: it parses args, computes onto/upstream, opens the sequence and
5//! commit-message editors, runs hooks, prints progress, and mutates the working
6//! tree. Those responsibilities — argv parsing, terminal output,
7//! editor/hook subprocess dispatch, revision resolution against a live
8//! [`Repository`](crate::repo::Repository), and worktree writes — stay in the
9//! CLI.
10//!
11//! What lives here is the presentation-free, repository-free core of that
12//! sequencer: the **todo command vocabulary** and the **pure string transforms**
13//! that build a rebase's squash/fixup message buffer from commit messages.
14//! These compute their results from text alone, so the CLI calls them while
15//! keeping every side effect on its own side of the boundary.
16//!
17//! # What this module owns
18//!
19//! - [`RebaseTodoCmd`] — the pick/reword/fixup/squash command keyword, with the
20//! keyword<->variant mapping ([`RebaseTodoCmd::as_str`],
21//! [`RebaseTodoCmd::parse_word`]) used both when generating a todo list and
22//! when parsing the user-edited one.
23//! - [`FixupMessageMode`] — whether a `fixup -C`/`fixup -c` step uses or edits
24//! the replaced commit message.
25//! - [`commit_subject_single_line`] / [`skip_fixupish_prefix`] /
26//! [`strip_fixupish_chain`] / [`format_autosquash_subject_for_match`] — the
27//! autosquash subject-matching helpers (Git's `skip_fixupish` /
28//! `format_subject`).
29//! - [`rebase_todo_command_for_display`] / [`rebase_todo_command_for_display_abbrev`]
30//! — how a command keyword is rendered in a generated todo list (honouring
31//! amend-fixup and `rebase.abbreviateCommands`).
32//! - [`fixup_replacement_message`] plus the squash-message buffer builders
33//! [`update_squash_message_for_fixup`], [`append_nth_squash_message`],
34//! [`append_skipped_squash_message`], [`squash_comment_subject_prefix`],
35//! [`append_commented`], [`copy_section`] — implementing Git's
36//! `sequencer.c` squash-message assembly behavior.
37
38use crate::interpret_trailers::complete_line;
39use crate::objects::CommitData;
40
41/// A linear interactive-rebase todo command keyword.
42///
43/// These are the four commands that operate on a single commit message in the
44/// sequencer's message-building path; `exec`/`label`/`reset`/`merge`/`break`
45/// and friends are parsed separately in the CLI.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum RebaseTodoCmd {
48 Pick,
49 Reword,
50 Fixup,
51 Squash,
52}
53
54impl RebaseTodoCmd {
55 /// The canonical (long) keyword for this command.
56 pub fn as_str(self) -> &'static str {
57 match self {
58 RebaseTodoCmd::Pick => "pick",
59 RebaseTodoCmd::Reword => "reword",
60 RebaseTodoCmd::Fixup => "fixup",
61 RebaseTodoCmd::Squash => "squash",
62 }
63 }
64
65 /// Parse a todo command word (long or single-letter form), returning `None`
66 /// for any word that is not one of the four pick-like commands.
67 pub fn parse_word(word: &str) -> Option<Self> {
68 match word {
69 "pick" | "p" => Some(RebaseTodoCmd::Pick),
70 "reword" | "r" => Some(RebaseTodoCmd::Reword),
71 "fixup" | "f" => Some(RebaseTodoCmd::Fixup),
72 "squash" | "s" => Some(RebaseTodoCmd::Squash),
73 _ => None,
74 }
75 }
76}
77
78/// Whether a `fixup -C`/`fixup -c` step uses the replaced commit message
79/// verbatim or opens an editor to amend it.
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum FixupMessageMode {
82 UseCommit,
83 EditCommit,
84}
85
86/// First line of a commit message with continuation lines folded like `git format_subject(..., " ")`.
87pub fn commit_subject_single_line(message: &str) -> String {
88 let mut lines = message.lines();
89 let Some(first) = lines.next() else {
90 return String::new();
91 };
92 let mut out = first.trim_end().to_string();
93 for line in lines {
94 let t = line.trim_end();
95 if t.is_empty() {
96 break;
97 }
98 if !out.is_empty() {
99 out.push(' ');
100 }
101 out.push_str(t.trim_start());
102 }
103 out
104}
105
106/// Strips one `fixup! ` / `amend! ` / `squash! ` prefix (bang + space), matching Git's
107/// `skip_fixupish` in `sequencer.c` (`todo_list_rearrange_squash`).
108pub fn skip_fixupish_prefix(subject: &str) -> Option<&str> {
109 let s = subject.trim_start();
110 if let Some(rest) = s.strip_prefix("fixup! ") {
111 return Some(rest);
112 }
113 if let Some(rest) = s.strip_prefix("amend! ") {
114 return Some(rest);
115 }
116 if let Some(rest) = s.strip_prefix("squash! ") {
117 return Some(rest);
118 }
119 None
120}
121
122/// Strips every leading `fixup!`/`amend!`/`squash!` prefix from a subject.
123pub fn strip_fixupish_chain(mut p: &str) -> &str {
124 while let Some(rest) = skip_fixupish_prefix(p) {
125 p = rest;
126 p = p.trim_start();
127 }
128 p
129}
130
131/// The autosquash match key for a commit message: its folded subject line.
132pub fn format_autosquash_subject_for_match(message: &str) -> String {
133 commit_subject_single_line(message)
134}
135
136/// The command keyword shown for `cmd` in a generated todo list, accounting for
137/// amend-fixup (an `amend!` subject under `fixup` renders as `fixup -C`).
138pub fn rebase_todo_command_for_display(cmd: RebaseTodoCmd, commit: &CommitData) -> &'static str {
139 if cmd == RebaseTodoCmd::Fixup
140 && commit
141 .message
142 .lines()
143 .next()
144 .unwrap_or("")
145 .trim_start()
146 .starts_with("amend! ")
147 {
148 "fixup -C"
149 } else {
150 cmd.as_str()
151 }
152}
153
154/// Abbreviation-aware variant of [`rebase_todo_command_for_display`]. With `abbrev` set, mirrors
155/// Git's `command_to_char` (`pick`→`p`, `fixup`→`f`, `squash`→`s`, `reword`→`r`), keeping the
156/// `-C`/`-c` suffix for amend-fixup (`f -C`).
157pub fn rebase_todo_command_for_display_abbrev(
158 cmd: RebaseTodoCmd,
159 commit: &CommitData,
160 abbrev: bool,
161) -> String {
162 let is_amend_fixup = cmd == RebaseTodoCmd::Fixup
163 && commit
164 .message
165 .lines()
166 .next()
167 .unwrap_or("")
168 .trim_start()
169 .starts_with("amend! ");
170 if !abbrev {
171 return if is_amend_fixup {
172 "fixup -C".to_owned()
173 } else {
174 cmd.as_str().to_owned()
175 };
176 }
177 let ch = match cmd {
178 RebaseTodoCmd::Pick => "p",
179 RebaseTodoCmd::Reword => "r",
180 RebaseTodoCmd::Fixup => "f",
181 RebaseTodoCmd::Squash => "s",
182 };
183 if is_amend_fixup {
184 format!("{ch} -C")
185 } else {
186 ch.to_owned()
187 }
188}
189
190/// The message body (everything after the first line), or `""` if single-line.
191pub fn message_body_after_subject(message: &str) -> &str {
192 match message.find('\n') {
193 Some(i) => &message[i + 1..],
194 None => "",
195 }
196}
197
198/// Skip leading blank lines (lines containing only spaces/tabs) of `message`.
199pub fn skip_blank_lines(mut message: &str) -> &str {
200 loop {
201 let trimmed = message.trim_start_matches([' ', '\t']);
202 if trimmed.starts_with('\n') {
203 message = &trimmed[1..];
204 continue;
205 }
206 return message;
207 }
208}
209
210/// The message that replaces an accumulated buffer for a `fixup -C`/`amend!`
211/// step: an `amend!` subject contributes only its body, every other message its
212/// whole (newline-terminated) text.
213pub fn fixup_replacement_message(message: &str) -> String {
214 let subject = message.lines().next().unwrap_or("").trim_start();
215 if subject.starts_with("amend! ") {
216 complete_line(skip_blank_lines(message_body_after_subject(message)))
217 } else {
218 complete_line(message)
219 }
220}
221
222/// Byte length of the first line of `body` (excluding the newline).
223pub fn first_line_len(body: &str) -> usize {
224 match body.find('\n') {
225 Some(i) => i,
226 None => body.len(),
227 }
228}
229
230/// How many leading bytes of `body` form a `fixup!`/`squash!`/`amend!` subject
231/// that should be commented out in a squash buffer section.
232pub fn squash_comment_subject_prefix(body: &str, cmd: RebaseTodoCmd, seen_squash: bool) -> usize {
233 let t = body.trim_start();
234 if t.starts_with("amend! ") {
235 return first_line_len(body);
236 }
237 if (cmd == RebaseTodoCmd::Squash || seen_squash)
238 && (t.starts_with("squash! ") || t.starts_with("fixup! "))
239 {
240 return first_line_len(body);
241 }
242 0
243}
244
245/// Append `text` to `buf` with every line commented (`# `, or bare `#` for
246/// empty lines), matching Git's `strbuf_add_commented_lines`.
247pub fn append_commented(buf: &mut String, text: &str) {
248 for line in text.lines() {
249 if line.is_empty() {
250 buf.push_str("#\n");
251 } else {
252 buf.push_str("# ");
253 buf.push_str(line);
254 buf.push('\n');
255 }
256 }
257}
258
259/// Comment out any un-commented commit messages in the squash buffer and rewrite their headers
260/// from "This is the Nth commit message:" to "The Nth commit message will be skipped:", leaving
261/// already-skipped sections untouched.
262///
263/// This reproduces the behavior of Git's `update_squash_message_for_fixup`: it is run
264/// when a `fixup -C`/`fixup -c` step replaces the accumulated message (`is_fixup_flag && !seen_squash`)
265/// so every section accumulated so far is dropped from the final message. Unlike a single-section
266/// marker, it walks the whole buffer, so a chain such as `pick, fixup, fixup -C` correctly skips
267/// both the pick target's message and the plain fixup's section (t3437 #8/#12).
268pub fn update_squash_message_for_fixup(buf: &mut String) {
269 // `comment_line_str` is "#"; the commented header forms Git compares against.
270 let mut buf1 = String::from("# This is the 1st commit message:\n");
271 let mut buf2 = String::from("# The 1st commit message will be skipped:\n");
272 let update_comment_bufs = |b1: &mut String, b2: &mut String, n: usize| {
273 *b1 = format!("# This is the commit message #{n}:\n");
274 *b2 = format!("# The commit message #{n} will be skipped:\n");
275 };
276
277 let orig = std::mem::take(buf);
278 let bytes = orig.as_bytes();
279 let mut out = String::new();
280 // `start` marks the beginning of the not-yet-copied region; `comment_mode` selects whether the
281 // copied body is passed through verbatim or commented out (matching Git's `copy_lines` switch).
282 let mut start = 0usize;
283 let mut comment_mode = false;
284 let mut i = 1usize;
285 let mut s = 0usize;
286 while s < orig.len() {
287 if orig[s..].starts_with(buf1.as_str()) {
288 // An un-skipped header: copy the preceding section, drop the blank line that precedes
289 // this header, emit the "skipped" header, comment the following body.
290 let off = usize::from(s > start + 1 && bytes[s - 2] == b'\n');
291 copy_section(&mut out, &orig[start..s - off], comment_mode);
292 if off == 1 {
293 out.push('\n');
294 }
295 out.push_str(&buf2);
296 let mut next = s + buf1.len();
297 if next < orig.len() && bytes[next] == b'\n' {
298 out.push('\n');
299 next += 1;
300 }
301 start = next;
302 s = next;
303 comment_mode = true;
304 i += 1;
305 update_comment_bufs(&mut buf1, &mut buf2, i);
306 } else if orig[s..].starts_with(buf2.as_str()) {
307 // An already-skipped header: copy the preceding section verbatim (the body that follows
308 // is already commented), then continue in verbatim mode.
309 let off = usize::from(s > start + 1 && bytes[s - 2] == b'\n');
310 copy_section(&mut out, &orig[start..s - off], comment_mode);
311 start = s - off;
312 s += buf2.len();
313 comment_mode = false;
314 i += 1;
315 update_comment_bufs(&mut buf1, &mut buf2, i);
316 } else {
317 match orig[s..].find('\n') {
318 Some(rel) => s += rel + 1,
319 None => break,
320 }
321 }
322 }
323 copy_section(&mut out, &orig[start..], comment_mode);
324 *buf = out;
325}
326
327/// Copy `text` into `out`, commenting it out (Git's `add_commented_lines`, which avoids
328/// double-commenting already-commented lines) when `comment_mode` is set.
329///
330/// Already-commented lines (starting with `#`) are passed through verbatim; every other line —
331/// including empty ones, which become a bare `#` — is prefixed, matching Git's
332/// `strbuf_add_commented_lines`.
333pub fn copy_section(out: &mut String, text: &str, comment_mode: bool) {
334 if !comment_mode || text.is_empty() {
335 out.push_str(text);
336 return;
337 }
338 for line in text.split_inclusive('\n') {
339 let content = line.strip_suffix('\n').unwrap_or(line);
340 if content.starts_with('#') {
341 out.push_str(line);
342 } else if content.is_empty() {
343 out.push('#');
344 out.push_str(line);
345 } else {
346 out.push_str("# ");
347 out.push_str(line);
348 }
349 }
350}
351
352/// Append a commented, "will be skipped" squash section for `body` numbered `n`.
353pub fn append_skipped_squash_message(buf: &mut String, body: &str, n: usize) {
354 if !buf.ends_with("\n\n") {
355 buf.push('\n');
356 }
357 buf.push_str("# The commit message #");
358 buf.push_str(&n.to_string());
359 buf.push_str(" will be skipped:\n\n");
360 append_commented(buf, body.trim_end_matches('\n'));
361}
362
363/// Append the Nth squash-message section for `body`, commenting out only the
364/// `fixup!`/`squash!`/`amend!` subject prefix (per [`squash_comment_subject_prefix`]).
365pub fn append_nth_squash_message(
366 buf: &mut String,
367 body: &str,
368 cmd: RebaseTodoCmd,
369 seen_squash: bool,
370 n: usize,
371) {
372 if !buf.ends_with("\n\n") {
373 buf.push('\n');
374 }
375 buf.push_str("# This is the commit message #");
376 buf.push_str(&n.to_string());
377 buf.push_str(":\n\n");
378 let pre = squash_comment_subject_prefix(body, cmd, seen_squash).min(body.len());
379 if pre > 0 {
380 append_commented(buf, &body[..pre]);
381 let rest = &body[pre..];
382 let rest = rest.strip_prefix('\n').unwrap_or(rest);
383 buf.push_str(rest);
384 return;
385 }
386 buf.push_str(&body[pre..]);
387}