1use sley_core::{GitError, ObjectId, Result};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ReplayAction {
17 Pick,
18 Revert,
19}
20
21impl ReplayAction {
22 pub fn name(self) -> &'static str {
24 match self {
25 ReplayAction::Pick => "cherry-pick",
26 ReplayAction::Revert => "revert",
27 }
28 }
29
30 pub fn command(self) -> &'static str {
32 match self {
33 ReplayAction::Pick => "pick",
34 ReplayAction::Revert => "revert",
35 }
36 }
37
38 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#[derive(Debug, Clone, Default)]
50pub struct ReplayOpts {
51 pub no_commit: bool,
52 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 pub record_origin: bool,
61 pub allow_ff: bool,
62 pub mainline: u32,
64 pub strategy: Option<String>,
65 pub gpg_sign: Option<String>,
66 pub strategy_options: Vec<String>,
68 pub allow_rerere_auto: Option<bool>,
70 pub default_msg_cleanup: Option<String>,
72 pub commit_use_reference: bool,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct TodoItem {
79 pub action: ReplayAction,
80 pub oid: ObjectId,
81 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
105pub 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
129pub struct InProgress {
134 pub error: String,
135 pub hint: String,
136}
137
138pub 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
153pub 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
163pub fn save_head(git_dir: &Path, head: &str) -> Result<()> {
165 fs::write(head_path(git_dir), format!("{head}\n"))?;
166 Ok(())
167}
168
169pub 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
175pub 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
189pub 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
206pub 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#[derive(Debug)]
223pub struct TodoParseError {
224 pub line_errors: Vec<String>,
227}
228
229pub enum TodoParse {
231 Ok(Vec<ParsedTodoLine>),
234 Err(TodoParseError),
235}
236
237#[derive(Debug, Clone)]
240pub struct ParsedTodoLine {
241 pub action: ReplayAction,
242 pub object_name: String,
243 pub rest: String,
245}
246
247pub 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 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
313fn 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
333pub 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
394pub 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
444pub fn remove_state(git_dir: &Path) {
446 let _ = fs::remove_dir_all(seq_dir(git_dir));
447}
448
449pub 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
461pub 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
479pub 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 let ok = parse_todo("pick \t 21b83cd base\n").expect("test operation should succeed");
545 assert_eq!(ok[0].object_name, "21b83cd");
546 let ok = parse_todo("pick 21b83cd\n").expect("test operation should succeed");
548 assert_eq!(ok[0].rest, "");
549 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}