Skip to main content

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}