Skip to main content

sley_sequencer/
replay.rs

1//! The cherry-pick / revert sequencer state machine.
2//!
3//! Owns the on-disk contract of git's `sequencer.c` for the non-rebase replay
4//! actions: the `.git/sequencer/` directory (`todo`, `opts`, `head`,
5//! `abort-safety`), the single-commit `CHERRY_PICK_HEAD` / `REVERT_HEAD`
6//! state files, and the parse/serialize rules for each. The porcelain drive
7//! loop (merging trees, committing results) lives with the CLI; everything
8//! that reads or writes sequencer state goes through here.
9
10use sley_core::{GitError, ObjectId, Result};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14/// Which replay action a sequencer run performs.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ReplayAction {
17    Pick,
18    Revert,
19}
20
21impl ReplayAction {
22    /// The porcelain name (`cherry-pick` / `revert`) used in messages.
23    pub fn name(self) -> &'static str {
24        match self {
25            ReplayAction::Pick => "cherry-pick",
26            ReplayAction::Revert => "revert",
27        }
28    }
29
30    /// The todo-sheet command word (`pick` / `revert`).
31    pub fn command(self) -> &'static str {
32        match self {
33            ReplayAction::Pick => "pick",
34            ReplayAction::Revert => "revert",
35        }
36    }
37
38    /// The single-commit state file (`CHERRY_PICK_HEAD` / `REVERT_HEAD`).
39    pub fn head_file(self) -> &'static str {
40        match self {
41            ReplayAction::Pick => "CHERRY_PICK_HEAD",
42            ReplayAction::Revert => "REVERT_HEAD",
43        }
44    }
45}
46
47/// Replay options, mirroring git's `struct replay_opts` for the subset the
48/// cherry-pick / revert porcelains persist in `.git/sequencer/opts`.
49#[derive(Debug, Clone, Default)]
50pub struct ReplayOpts {
51    pub no_commit: bool,
52    /// Tri-state `--edit`: `None` = unspecified (git's `edit < 0`).
53    pub edit: Option<bool>,
54    pub allow_empty: bool,
55    pub allow_empty_message: bool,
56    pub drop_redundant_commits: bool,
57    pub keep_redundant_commits: bool,
58    pub signoff: bool,
59    /// `-x`: append `(cherry picked from commit ...)`.
60    pub record_origin: bool,
61    pub allow_ff: bool,
62    /// `-m <parent-number>`; 0 = unset.
63    pub mainline: u32,
64    pub strategy: Option<String>,
65    pub gpg_sign: Option<String>,
66    /// `-X` strategy options.
67    pub strategy_options: Vec<String>,
68    /// `--rerere-autoupdate` / `--no-rerere-autoupdate`.
69    pub allow_rerere_auto: Option<bool>,
70    /// `--cleanup=<mode>` (persisted as `options.default-msg-cleanup`).
71    pub default_msg_cleanup: Option<String>,
72    /// `--reference` (revert only; not persisted by git).
73    pub commit_use_reference: bool,
74}
75
76/// One instruction-sheet entry.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct TodoItem {
79    pub action: ReplayAction,
80    pub oid: ObjectId,
81    /// Short display oid + optional subject as written on the sheet.
82    pub display: String,
83}
84
85pub fn seq_dir(git_dir: &Path) -> PathBuf {
86    git_dir.join("sequencer")
87}
88
89pub fn todo_path(git_dir: &Path) -> PathBuf {
90    seq_dir(git_dir).join("todo")
91}
92
93pub fn opts_path(git_dir: &Path) -> PathBuf {
94    seq_dir(git_dir).join("opts")
95}
96
97pub fn head_path(git_dir: &Path) -> PathBuf {
98    seq_dir(git_dir).join("head")
99}
100
101pub fn abort_safety_path(git_dir: &Path) -> PathBuf {
102    seq_dir(git_dir).join("abort-safety")
103}
104
105/// Probe the first instruction of an existing todo sheet, mirroring
106/// `sequencer_get_last_command`: `Some(action)` when the sheet's first
107/// non-blank line starts with `pick ` / `revert ` (or their abbreviations).
108pub fn last_command(git_dir: &Path) -> Option<ReplayAction> {
109    let buf = fs::read(todo_path(git_dir)).ok()?;
110    let text = String::from_utf8_lossy(&buf);
111    let trimmed = text.trim_start_matches([' ', '\t', '\r', '\n']);
112    for (action, nick) in [(ReplayAction::Pick, "p"), (ReplayAction::Revert, "")] {
113        let full = action.command();
114        if let Some(rest) = trimmed.strip_prefix(full)
115            && rest.starts_with([' ', '\t'])
116        {
117            return Some(action);
118        }
119        if !nick.is_empty()
120            && let Some(rest) = trimmed.strip_prefix(nick)
121            && rest.starts_with([' ', '\t'])
122        {
123            return Some(action);
124        }
125    }
126    None
127}
128
129/// Create `.git/sequencer/`, failing when a sequence is already in progress
130/// (mirrors `create_seq_dir`). On the in-progress case the error/hint pair is
131/// printed by the caller; this returns the message text so the porcelain owns
132/// stderr ordering.
133pub struct InProgress {
134    pub error: String,
135    pub hint: String,
136}
137
138/// Check for an in-progress sequence; `advise_skip` selects the hint variant
139/// that offers `--skip`.
140pub fn in_progress_error(git_dir: &Path, advise_skip: bool) -> Option<InProgress> {
141    let action = last_command(git_dir)?;
142    let skip = if advise_skip { "--skip | " } else { "" };
143    Some(InProgress {
144        error: format!("{} is already in progress", action.name()),
145        hint: format!(
146            "try \"git {} (--continue | {}--abort | --quit)\"",
147            action.name(),
148            skip
149        ),
150    })
151}
152
153/// Create the sequencer directory (caller has already ruled out in-progress).
154pub fn create_seq_dir(git_dir: &Path) -> Result<()> {
155    fs::create_dir(seq_dir(git_dir)).map_err(|err| {
156        GitError::Command(format!(
157            "could not create sequencer directory '{}': {err}",
158            seq_dir(git_dir).display()
159        ))
160    })
161}
162
163/// Persist the pre-sequence HEAD (`.git/sequencer/head`).
164pub fn save_head(git_dir: &Path, head: &str) -> Result<()> {
165    fs::write(head_path(git_dir), format!("{head}\n"))?;
166    Ok(())
167}
168
169/// Read the pre-sequence HEAD back; `None` when no sequence recorded one.
170pub fn read_head(git_dir: &Path) -> Option<String> {
171    let buf = fs::read_to_string(head_path(git_dir)).ok()?;
172    Some(buf.lines().next().unwrap_or("").to_string())
173}
174
175/// Record the current HEAD in `abort-safety` (mirrors
176/// `update_abort_safety_file`; a no-op without a sequencer dir). `head` is
177/// `None` when HEAD is unresolvable, recorded as an empty file.
178pub fn update_abort_safety(git_dir: &Path, head: Option<&ObjectId>) {
179    if !seq_dir(git_dir).is_dir() {
180        return;
181    }
182    let text = match head {
183        Some(oid) => format!("{oid}\n"),
184        None => "\n".to_string(),
185    };
186    let _ = fs::write(abort_safety_path(git_dir), text);
187}
188
189/// `rollback_is_safe`: HEAD still matches the last recorded abort-safety oid.
190pub fn rollback_is_safe(git_dir: &Path, actual_head: Option<&ObjectId>) -> bool {
191    let expected = match fs::read_to_string(abort_safety_path(git_dir)) {
192        Ok(content) => content.trim().to_string(),
193        Err(_) => String::new(),
194    };
195    let actual = actual_head.map(|oid| oid.to_hex()).unwrap_or_default();
196    let zero_is_empty = |value: &str| {
197        if value.chars().all(|c| c == '0') {
198            String::new()
199        } else {
200            value.to_string()
201        }
202    };
203    zero_is_empty(&expected) == zero_is_empty(&actual)
204}
205
206/// Serialize the instruction sheet (`save_todo`): one `<command> <display>`
207/// line per remaining item.
208pub fn save_todo(git_dir: &Path, items: &[TodoItem]) -> Result<()> {
209    let mut out = String::new();
210    for item in items {
211        out.push_str(item.action.command());
212        out.push(' ');
213        out.push_str(&item.display);
214        out.push('\n');
215    }
216    fs::write(todo_path(git_dir), out)?;
217    Ok(())
218}
219
220/// A parse failure for one todo line, mirroring the error cascade git prints
221/// before "unusable instruction sheet".
222#[derive(Debug)]
223pub struct TodoParseError {
224    /// The per-line errors (`invalid command '...'`, `could not parse '...'`,
225    /// `missing arguments for ...`), in print order.
226    pub line_errors: Vec<String>,
227}
228
229/// Result of parsing an instruction sheet for `--continue`.
230pub enum TodoParse {
231    /// All lines parsed; per-item resolved oids are NOT validated here (the
232    /// caller resolves the display names against the repository).
233    Ok(Vec<ParsedTodoLine>),
234    Err(TodoParseError),
235}
236
237/// One successfully-parsed todo line: the command and its raw object-name
238/// token (still to be resolved by the caller) plus the rest of the line.
239#[derive(Debug, Clone)]
240pub struct ParsedTodoLine {
241    pub action: ReplayAction,
242    pub object_name: String,
243    /// The subject text following the object name (may be empty).
244    pub rest: String,
245}
246
247/// Parse the instruction sheet text (mirrors `todo_list_parse_insn_buffer` +
248/// `parse_insn_line` for the pick/revert subset). Comment and blank lines are
249/// skipped. Lines using any *other* todo command (edit/squash/...) are parsed
250/// as that command; the caller enforces the "cannot cherry-pick during a
251/// revert" rule, so such lines surface here as `Other`.
252pub fn parse_todo(text: &str) -> std::result::Result<Vec<ParsedTodoLine>, TodoParseError> {
253    let mut items = Vec::new();
254    let mut errors = Vec::new();
255    for (idx, raw_line) in text.split('\n').enumerate() {
256        if raw_line.is_empty() && text.split('\n').nth(idx + 1).is_none() {
257            // Trailing piece after the final newline.
258            break;
259        }
260        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
261        let bol = line.trim_start_matches([' ', '\t']);
262        if bol.is_empty() || bol.starts_with('#') {
263            continue;
264        }
265        let mut matched = None;
266        for action in [ReplayAction::Pick, ReplayAction::Revert] {
267            if let Some(rest) = strip_command(bol, action.command(), action_nick(action)) {
268                matched = Some((action, rest));
269                break;
270            }
271        }
272        let Some((action, rest)) = matched else {
273            let token: String = bol
274                .chars()
275                .take_while(|c| !matches!(c, ' ' | '\t' | '\r' | '\n'))
276                .collect();
277            errors.push(format!("invalid command '{token}'"));
278            errors.push(format!("invalid line {}: {}", idx + 1, line));
279            continue;
280        };
281        let padding = rest.len() - rest.trim_start_matches([' ', '\t']).len();
282        let rest = rest.trim_start_matches([' ', '\t']);
283        if padding == 0 {
284            errors.push(format!("missing arguments for {}", action.command()));
285            errors.push(format!("invalid line {}: {}", idx + 1, line));
286            continue;
287        }
288        let end = rest.find([' ', '\t']).unwrap_or(rest.len());
289        let (object_name, tail) = rest.split_at(end);
290        let tail = tail.trim_start_matches([' ', '\t']);
291        items.push(ParsedTodoLine {
292            action,
293            object_name: object_name.to_string(),
294            rest: tail.to_string(),
295        });
296    }
297    if errors.is_empty() {
298        Ok(items)
299    } else {
300        Err(TodoParseError {
301            line_errors: errors,
302        })
303    }
304}
305
306fn action_nick(action: ReplayAction) -> Option<char> {
307    match action {
308        ReplayAction::Pick => Some('p'),
309        ReplayAction::Revert => None,
310    }
311}
312
313/// `is_command`: full command word or single-char nick, followed by a space
314/// or tab; returns the remainder (starting at the separator).
315fn strip_command<'a>(bol: &'a str, word: &str, nick: Option<char>) -> Option<&'a str> {
316    if let Some(rest) = bol.strip_prefix(word)
317        && rest.starts_with([' ', '\t'])
318    {
319        return Some(rest);
320    }
321    if let Some(nick) = nick {
322        let mut chars = bol.chars();
323        if chars.next() == Some(nick) {
324            let rest = chars.as_str();
325            if rest.starts_with([' ', '\t']) {
326                return Some(rest);
327            }
328        }
329    }
330    None
331}
332
333/// Serialize replay options into `.git/sequencer/opts` (mirrors `save_opts`,
334/// including key order). Creates the file only when at least one option is
335/// recorded, matching git (which only writes set keys).
336pub fn save_opts(git_dir: &Path, opts: &ReplayOpts) -> Result<()> {
337    let mut body = String::new();
338    let mut set = |key: &str, value: &str| {
339        body.push_str(&format!("\t{key} = {value}\n"));
340    };
341    if opts.no_commit {
342        set("no-commit", "true");
343    }
344    if let Some(edit) = opts.edit {
345        set("edit", if edit { "true" } else { "false" });
346    }
347    if opts.allow_empty {
348        set("allow-empty", "true");
349    }
350    if opts.allow_empty_message {
351        set("allow-empty-message", "true");
352    }
353    if opts.drop_redundant_commits {
354        set("drop-redundant-commits", "true");
355    }
356    if opts.keep_redundant_commits {
357        set("keep-redundant-commits", "true");
358    }
359    if opts.signoff {
360        set("signoff", "true");
361    }
362    if opts.record_origin {
363        set("record-origin", "true");
364    }
365    if opts.allow_ff {
366        set("allow-ff", "true");
367    }
368    if opts.mainline > 0 {
369        let value = opts.mainline.to_string();
370        set("mainline", &value);
371    }
372    if let Some(strategy) = &opts.strategy {
373        set("strategy", strategy);
374    }
375    if let Some(gpg_sign) = &opts.gpg_sign {
376        set("gpg-sign", gpg_sign);
377    }
378    for option in &opts.strategy_options {
379        set("strategy-option", option);
380    }
381    if let Some(allow) = opts.allow_rerere_auto {
382        set("allow-rerere-auto", if allow { "true" } else { "false" });
383    }
384    if let Some(cleanup) = &opts.default_msg_cleanup {
385        set("default-msg-cleanup", cleanup);
386    }
387    if body.is_empty() {
388        return Ok(());
389    }
390    fs::write(opts_path(git_dir), format!("[options]\n{body}"))?;
391    Ok(())
392}
393
394/// Read `.git/sequencer/opts` back into a `ReplayOpts` (mirrors
395/// `read_populate_opts` / `populate_opts_cb`). Missing file yields defaults.
396pub fn read_opts(git_dir: &Path) -> Result<ReplayOpts> {
397    let mut opts = ReplayOpts::default();
398    let Ok(text) = fs::read_to_string(opts_path(git_dir)) else {
399        return Ok(opts);
400    };
401    let mut in_options = false;
402    for raw_line in text.lines() {
403        let line = raw_line.trim();
404        if line.is_empty() || line.starts_with(['#', ';']) {
405            continue;
406        }
407        if line.starts_with('[') {
408            in_options = line.eq_ignore_ascii_case("[options]");
409            continue;
410        }
411        if !in_options {
412            continue;
413        }
414        let Some((key, value)) = line.split_once('=') else {
415            continue;
416        };
417        let key = key.trim().to_ascii_lowercase();
418        let value = value.trim();
419        let truthy = value.eq_ignore_ascii_case("true") || value == "1";
420        match key.as_str() {
421            "no-commit" => opts.no_commit = truthy,
422            "edit" => opts.edit = Some(truthy),
423            "allow-empty" => opts.allow_empty = truthy,
424            "allow-empty-message" => opts.allow_empty_message = truthy,
425            "drop-redundant-commits" => opts.drop_redundant_commits = truthy,
426            "keep-redundant-commits" => opts.keep_redundant_commits = truthy,
427            "signoff" => opts.signoff = truthy,
428            "record-origin" => opts.record_origin = truthy,
429            "allow-ff" => opts.allow_ff = truthy,
430            "mainline" => opts.mainline = value.parse().unwrap_or(0),
431            "strategy" => opts.strategy = Some(value.to_string()),
432            "gpg-sign" => opts.gpg_sign = Some(value.to_string()),
433            "strategy-option" => opts.strategy_options.push(value.to_string()),
434            "allow-rerere-auto" => opts.allow_rerere_auto = Some(truthy),
435            "default-msg-cleanup" => opts.default_msg_cleanup = Some(value.to_string()),
436            other => {
437                return Err(GitError::Command(format!("invalid key: options.{other}")));
438            }
439        }
440    }
441    Ok(opts)
442}
443
444/// Remove the whole sequencer directory (`sequencer_remove_state`).
445pub fn remove_state(git_dir: &Path) {
446    let _ = fs::remove_dir_all(seq_dir(git_dir));
447}
448
449/// `have_finished_the_last_pick`: the todo sheet is missing, or holds at most
450/// one line.
451pub fn finished_last_pick(git_dir: &Path) -> bool {
452    let Ok(buf) = fs::read_to_string(todo_path(git_dir)) else {
453        return false;
454    };
455    match buf.find('\n') {
456        None => true,
457        Some(pos) => buf[pos + 1..].is_empty(),
458    }
459}
460
461/// `sequencer_post_commit_cleanup`: delete `CHERRY_PICK_HEAD` /
462/// `REVERT_HEAD`, and when one of them existed and the todo sheet is down to
463/// its last line, drop the whole sequencer dir. Mirrors the cleanup run by
464/// `git commit`, `git reset` (no pathspec) and `git checkout`.
465pub fn post_commit_cleanup(git_dir: &Path) {
466    let mut need_cleanup = false;
467    for name in ["CHERRY_PICK_HEAD", "REVERT_HEAD"] {
468        let path = git_dir.join(name);
469        if path.exists() {
470            let _ = fs::remove_file(&path);
471            need_cleanup = true;
472        }
473    }
474    if need_cleanup && finished_last_pick(git_dir) {
475        remove_state(git_dir);
476    }
477}
478
479/// `remove_branch_state`: post-commit cleanup plus the merge scratch files.
480pub fn remove_branch_state(git_dir: &Path) {
481    post_commit_cleanup(git_dir);
482    for name in [
483        "MERGE_HEAD",
484        "MERGE_RR",
485        "MERGE_MSG",
486        "MERGE_MODE",
487        "SQUASH_MSG",
488        "AUTO_MERGE",
489    ] {
490        let path = git_dir.join(name);
491        if path.exists() {
492            let _ = fs::remove_file(&path);
493        }
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use sley_core::ObjectFormat;
501
502    fn oid(hex: &str) -> ObjectId {
503        ObjectId::from_hex(ObjectFormat::Sha1, hex).expect("test operation should succeed")
504    }
505
506    #[test]
507    fn todo_round_trips() {
508        let dir = tempfile::tempdir().expect("test operation should succeed");
509        let git_dir = dir.path();
510        create_seq_dir(git_dir).expect("test operation should succeed");
511        let items = vec![
512            TodoItem {
513                action: ReplayAction::Pick,
514                oid: oid("21b83cd2e8f4d6d8d9615779ebaa801ba891eb04"),
515                display: "21b83cd base".to_string(),
516            },
517            TodoItem {
518                action: ReplayAction::Pick,
519                oid: oid("963b36c2ba8007f62b5ae23da601530554a72537"),
520                display: "963b36c picked".to_string(),
521            },
522        ];
523        save_todo(git_dir, &items).expect("test operation should succeed");
524        let text = fs::read_to_string(todo_path(git_dir)).expect("test operation should succeed");
525        assert_eq!(text, "pick 21b83cd base\npick 963b36c picked\n");
526        let parsed = parse_todo(&text).expect("test operation should succeed");
527        assert_eq!(parsed.len(), 2);
528        assert_eq!(parsed[0].object_name, "21b83cd");
529        assert_eq!(parsed[0].rest, "base");
530        assert_eq!(last_command(git_dir), Some(ReplayAction::Pick));
531    }
532
533    #[test]
534    fn todo_parse_flags_bad_lines() {
535        let err = parse_todo("pick63a subject\n").expect_err("must fail");
536        assert_eq!(
537            err.line_errors,
538            vec![
539                "invalid command 'pick63a'".to_string(),
540                "invalid line 1: pick63a subject".to_string(),
541            ]
542        );
543        // Fat-fingered extra whitespace between command and oid is fine.
544        let ok = parse_todo("pick \t 21b83cd base\n").expect("test operation should succeed");
545        assert_eq!(ok[0].object_name, "21b83cd");
546        // Subject is optional.
547        let ok = parse_todo("pick 21b83cd\n").expect("test operation should succeed");
548        assert_eq!(ok[0].rest, "");
549        // Missing space after the command word.
550        let err = parse_todo("pick\n").expect_err("must fail");
551        assert_eq!(err.line_errors.len(), 2);
552    }
553
554    #[test]
555    fn opts_round_trip_matches_git_key_order() {
556        let dir = tempfile::tempdir().expect("test operation should succeed");
557        let git_dir = dir.path();
558        create_seq_dir(git_dir).expect("test operation should succeed");
559        let opts = ReplayOpts {
560            signoff: true,
561            mainline: 4,
562            strategy: Some("recursive".to_string()),
563            strategy_options: vec!["patience".to_string(), "ours".to_string()],
564            edit: Some(true),
565            ..ReplayOpts::default()
566        };
567        save_opts(git_dir, &opts).expect("test operation should succeed");
568        let text = fs::read_to_string(opts_path(git_dir)).expect("test operation should succeed");
569        assert_eq!(
570            text,
571            "[options]\n\tedit = true\n\tsignoff = true\n\tmainline = 4\n\tstrategy = recursive\n\tstrategy-option = patience\n\tstrategy-option = ours\n"
572        );
573        let read = read_opts(git_dir).expect("test operation should succeed");
574        assert!(read.signoff);
575        assert_eq!(read.mainline, 4);
576        assert_eq!(read.strategy.as_deref(), Some("recursive"));
577        assert_eq!(read.strategy_options, vec!["patience", "ours"]);
578        assert_eq!(read.edit, Some(true));
579    }
580
581    #[test]
582    fn opts_without_any_set_option_writes_no_file() {
583        let dir = tempfile::tempdir().expect("test operation should succeed");
584        let git_dir = dir.path();
585        create_seq_dir(git_dir).expect("test operation should succeed");
586        save_opts(git_dir, &ReplayOpts::default()).expect("test operation should succeed");
587        assert!(!opts_path(git_dir).exists());
588    }
589
590    #[test]
591    fn post_commit_cleanup_removes_state_on_last_pick() {
592        let dir = tempfile::tempdir().expect("test operation should succeed");
593        let git_dir = dir.path();
594        create_seq_dir(git_dir).expect("test operation should succeed");
595        fs::write(git_dir.join("CHERRY_PICK_HEAD"), "x\n").expect("test operation should succeed");
596        fs::write(todo_path(git_dir), "pick 1234567 one\npick 89abcde two\n")
597            .expect("test operation should succeed");
598        post_commit_cleanup(git_dir);
599        assert!(!git_dir.join("CHERRY_PICK_HEAD").exists());
600        assert!(seq_dir(git_dir).is_dir(), "two items left: state stays");
601
602        fs::write(git_dir.join("CHERRY_PICK_HEAD"), "x\n").expect("test operation should succeed");
603        fs::write(todo_path(git_dir), "pick 1234567 one\n").expect("test operation should succeed");
604        post_commit_cleanup(git_dir);
605        assert!(!seq_dir(git_dir).is_dir(), "single item: state removed");
606    }
607
608    #[test]
609    fn rollback_safety_matches_head() {
610        let dir = tempfile::tempdir().expect("test operation should succeed");
611        let git_dir = dir.path();
612        create_seq_dir(git_dir).expect("test operation should succeed");
613        let head = oid("21b83cd2e8f4d6d8d9615779ebaa801ba891eb04");
614        update_abort_safety(git_dir, Some(&head));
615        assert!(rollback_is_safe(git_dir, Some(&head)));
616        let moved = oid("963b36c2ba8007f62b5ae23da601530554a72537");
617        assert!(!rollback_is_safe(git_dir, Some(&moved)));
618    }
619}