1use std::collections::HashSet;
23use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27use crate::hash::{self, HEX_LEN, Hash};
28use crate::layout::RepoLayout;
29use crate::object::Object;
30use crate::store::ObjectStore;
31
32pub const REBASE_DIR: &str = "rebase-apply";
34
35const HEAD_NAME_FILE: &str = "head-name";
36const ORIG_HEAD_FILE: &str = "orig-head";
37const ONTO_FILE: &str = "onto";
38const TODO_FILE: &str = "todo";
39const DONE_FILE: &str = "done";
40const ACTIONS_FILE: &str = "actions";
41
42const MAX_HEAD_NAME_BYTES: u64 = 4096;
43const MAX_HASH_FILE_BYTES: u64 = 128;
44const MAX_HASH_LIST_BYTES: u64 = 1024 * 1024;
45
46const MAX_REPLAY_DEPTH: usize = 10_000;
48
49const MAX_ANCESTORS: usize = 10_000;
51
52#[derive(Debug, thiserror::Error)]
54pub enum RebaseError {
55 #[error("no rebase in progress")]
57 NoRebaseInProgress,
58 #[error("rebase state on disk is malformed")]
60 InvalidRebaseState,
61 #[error(transparent)]
63 Io(#[from] io::Error),
64 #[error(transparent)]
66 Object(#[from] crate::object::MkitError),
67 #[error(transparent)]
69 Store(#[from] crate::store::StoreError),
70}
71
72pub type RebaseResult<T> = Result<T, RebaseError>;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum RebaseAction {
80 Pick,
82 Reword,
84 Squash,
87 Fixup,
90}
91
92impl RebaseAction {
93 #[must_use]
95 pub fn keyword(self) -> &'static str {
96 match self {
97 RebaseAction::Pick => "pick",
98 RebaseAction::Reword => "reword",
99 RebaseAction::Squash => "squash",
100 RebaseAction::Fixup => "fixup",
101 }
102 }
103
104 #[must_use]
107 pub fn folds_into_previous(self) -> bool {
108 matches!(self, RebaseAction::Squash | RebaseAction::Fixup)
109 }
110
111 #[must_use]
113 pub fn from_keyword(s: &str) -> Option<Self> {
114 match s {
115 "pick" => Some(RebaseAction::Pick),
116 "reword" => Some(RebaseAction::Reword),
117 "squash" => Some(RebaseAction::Squash),
118 "fixup" => Some(RebaseAction::Fixup),
119 _ => None,
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct RebaseState {
127 pub head_name: String,
129 pub orig_head: Hash,
131 pub onto: Hash,
133 pub todo: Vec<Hash>,
135 pub actions: Vec<RebaseAction>,
138 pub done: Vec<Hash>,
140}
141
142impl RebaseState {
143 #[must_use]
146 pub fn front_action(&self) -> RebaseAction {
147 self.actions.first().copied().unwrap_or(RebaseAction::Pick)
148 }
149
150 pub fn consume_front(&mut self) {
153 if !self.todo.is_empty() {
154 self.todo.remove(0);
155 }
156 if !self.actions.is_empty() {
157 self.actions.remove(0);
158 }
159 }
160}
161
162#[must_use]
164pub fn is_rebase_in_progress(layout: &RepoLayout) -> bool {
165 layout.rebase_dir().is_dir()
166}
167
168pub fn read_state(layout: &RepoLayout) -> RebaseResult<RebaseState> {
174 let dir = layout.rebase_dir();
175 if !dir.is_dir() {
176 return Err(RebaseError::NoRebaseInProgress);
177 }
178
179 let head_name = read_text_capped(&dir.join(HEAD_NAME_FILE), MAX_HEAD_NAME_BYTES)
180 .map_err(|_| RebaseError::InvalidRebaseState)?;
181 let head_name = trim_trailing(&head_name).to_string();
182
183 let orig_head = read_hex_hash(&dir.join(ORIG_HEAD_FILE))?;
184 let onto = read_hex_hash(&dir.join(ONTO_FILE))?;
185
186 let todo = read_hash_list(&dir.join(TODO_FILE))?;
187 let done = read_hash_list(&dir.join(DONE_FILE))?;
188 let actions = read_actions(&dir.join(ACTIONS_FILE), todo.len())?;
192
193 Ok(RebaseState {
194 head_name,
195 orig_head,
196 onto,
197 todo,
198 actions,
199 done,
200 })
201}
202
203pub fn write_state(layout: &RepoLayout, state: &RebaseState) -> RebaseResult<()> {
208 let dir = layout.rebase_dir();
209 fs::create_dir_all(&dir)?;
210
211 write_with_newline(&dir.join(HEAD_NAME_FILE), state.head_name.as_bytes())?;
212 write_with_newline(
213 &dir.join(ORIG_HEAD_FILE),
214 hash::to_hex(&state.orig_head).as_bytes(),
215 )?;
216 write_with_newline(&dir.join(ONTO_FILE), hash::to_hex(&state.onto).as_bytes())?;
217 write_hash_list(&dir.join(TODO_FILE), &state.todo)?;
218 write_actions(&dir.join(ACTIONS_FILE), &state.actions)?;
219 write_hash_list(&dir.join(DONE_FILE), &state.done)?;
220 Ok(())
221}
222
223pub fn cleanup_rebase(layout: &RepoLayout) -> RebaseResult<()> {
228 let dir = layout.rebase_dir();
229 match fs::remove_dir_all(&dir) {
230 Ok(()) => Ok(()),
231 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
232 Err(e) => Err(RebaseError::Io(e)),
233 }
234}
235
236pub fn collect_commits_to_replay(
243 store: &ObjectStore,
244 head_hash: Hash,
245 onto_hash: Hash,
246) -> RebaseResult<Vec<Hash>> {
247 if head_hash == onto_hash {
248 return Ok(Vec::new());
249 }
250
251 let mut onto_ancestors: HashSet<Hash> = HashSet::new();
252 collect_ancestor_set(store, onto_hash, &mut onto_ancestors);
253
254 let mut commits: Vec<Hash> = Vec::new();
255 let mut current = head_hash;
256 let mut depth = 0usize;
257
258 while depth < MAX_REPLAY_DEPTH {
259 if current == onto_hash || onto_ancestors.contains(¤t) {
260 break;
261 }
262 commits.push(current);
263
264 let Ok(obj) = store.read_object(¤t) else {
265 break;
266 };
267 let Object::Commit(commit) = obj else { break };
268 if commit.parents.is_empty() {
269 break;
270 }
271 current = commit.parents[0];
272 depth += 1;
273 }
274
275 commits.reverse();
276 Ok(commits)
277}
278
279fn collect_ancestor_set(store: &ObjectStore, start: Hash, set: &mut HashSet<Hash>) {
284 let mut stack: Vec<Hash> = vec![start];
285 let mut count = 0usize;
286 while let Some(current) = stack.pop() {
287 if count >= MAX_ANCESTORS {
288 break;
289 }
290 if !set.insert(current) {
291 continue;
292 }
293 count += 1;
294 let Ok(obj) = store.read_object(¤t) else {
295 continue;
296 };
297 if let Object::Commit(commit) = obj {
298 for p in commit.parents {
299 stack.push(p);
300 }
301 }
302 }
303}
304
305fn read_hex_hash(path: &Path) -> RebaseResult<Hash> {
306 let raw =
307 read_text_capped(path, MAX_HASH_FILE_BYTES).map_err(|_| RebaseError::InvalidRebaseState)?;
308 let trimmed = trim_trailing(&raw);
309 hash::from_hex(trimmed).map_err(|_| RebaseError::InvalidRebaseState)
310}
311
312fn read_hash_list(path: &Path) -> RebaseResult<Vec<Hash>> {
313 let raw = match read_text_capped(path, MAX_HASH_LIST_BYTES) {
314 Ok(s) => s,
315 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
316 Err(e) => return Err(RebaseError::Io(e)),
317 };
318 let trimmed = trim_trailing(&raw);
319 if trimmed.is_empty() {
320 return Ok(Vec::new());
321 }
322 let mut out = Vec::new();
323 for line in trimmed.split('\n') {
324 let line = line.trim_end_matches(['\r', ' ']);
325 if line.is_empty() {
326 continue;
327 }
328 if line.len() != HEX_LEN {
329 return Err(RebaseError::InvalidRebaseState);
330 }
331 let h = hash::from_hex(line).map_err(|_| RebaseError::InvalidRebaseState)?;
332 out.push(h);
333 }
334 Ok(out)
335}
336
337fn read_actions(path: &Path, todo_len: usize) -> RebaseResult<Vec<RebaseAction>> {
343 let raw = match read_text_capped(path, MAX_HASH_LIST_BYTES) {
344 Ok(s) => s,
345 Err(e) if e.kind() == io::ErrorKind::NotFound => {
346 return Ok(vec![RebaseAction::Pick; todo_len]);
347 }
348 Err(e) => return Err(RebaseError::Io(e)),
349 };
350 let mut out = Vec::with_capacity(todo_len);
351 for line in trim_trailing(&raw).split('\n') {
352 let line = line.trim();
353 if line.is_empty() {
354 continue;
355 }
356 let action = RebaseAction::from_keyword(line).ok_or(RebaseError::InvalidRebaseState)?;
357 out.push(action);
358 }
359 out.resize(todo_len, RebaseAction::Pick);
360 Ok(out)
361}
362
363fn write_actions(path: &Path, actions: &[RebaseAction]) -> RebaseResult<()> {
364 if actions.is_empty() {
365 write_with_newline(path, b"")?;
366 return Ok(());
367 }
368 let mut buf = String::with_capacity(actions.len() * 8);
369 for a in actions {
370 buf.push_str(a.keyword());
371 buf.push('\n');
372 }
373 fs::write(path, buf.as_bytes())?;
374 Ok(())
375}
376
377fn write_hash_list(path: &Path, hashes: &[Hash]) -> RebaseResult<()> {
378 if hashes.is_empty() {
379 write_with_newline(path, b"")?;
380 return Ok(());
381 }
382 let mut buf = String::with_capacity(hashes.len() * (HEX_LEN + 1));
383 for h in hashes {
384 buf.push_str(&hash::to_hex(h));
385 buf.push('\n');
386 }
387 fs::write(path, buf.as_bytes())?;
388 Ok(())
389}
390
391fn write_with_newline(path: &Path, content: &[u8]) -> io::Result<()> {
392 let mut buf: Vec<u8> = Vec::with_capacity(content.len() + 1);
393 buf.extend_from_slice(content);
394 if buf.last().copied() != Some(b'\n') {
395 buf.push(b'\n');
396 }
397 fs::write(path, buf)
398}
399
400fn read_text_capped(path: &Path, cap: u64) -> io::Result<String> {
401 let meta = fs::metadata(path)?;
402 if meta.len() > cap {
403 return Err(io::Error::new(io::ErrorKind::InvalidData, "file too large"));
404 }
405 let raw = fs::read(path)?;
406 String::from_utf8(raw).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8"))
407}
408
409fn trim_trailing(s: &str) -> &str {
410 s.trim_end_matches(['\n', '\r', ' '])
411}
412
413#[must_use]
415pub fn rebase_dir_path(layout: &RepoLayout) -> PathBuf {
416 layout.rebase_dir()
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::object::{Commit, Identity, Tree, TreeEntry};
423 use crate::serialize;
424 use tempfile::TempDir;
425
426 fn fresh_store() -> (TempDir, ObjectStore) {
427 let dir = TempDir::new().unwrap();
428 let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
429 (dir, store)
430 }
431
432 fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
433 let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
434 data: data.to_vec(),
435 }))
436 .unwrap();
437 store.write(&bytes).unwrap()
438 }
439
440 fn put_tree(store: &ObjectStore, name: &str, blob_h: Hash) -> Hash {
441 let tree = Object::Tree(Tree {
442 entries: vec![TreeEntry {
443 name: name.as_bytes().to_vec(),
444 mode: crate::object::EntryMode::Blob,
445 object_hash: blob_h,
446 }],
447 });
448 let bytes = serialize::serialize(&tree).unwrap();
449 store.write(&bytes).unwrap()
450 }
451
452 fn put_commit(store: &ObjectStore, tree_h: Hash, parents: Vec<Hash>, ts: u64) -> Hash {
453 let commit = Object::Commit(Commit::new_unannotated(
454 tree_h,
455 parents,
456 Identity::ed25519([0u8; 32]),
457 [0u8; 32],
458 b"msg".to_vec(),
459 ts,
460 [0u8; 64],
461 ));
462 let bytes = serialize::serialize(&commit).unwrap();
463 store.write(&bytes).unwrap()
464 }
465
466 #[test]
467 fn state_roundtrip_writes_recoverable_files() {
468 let tmp = TempDir::new().unwrap();
469 let mkit = RepoLayout::single(tmp.path());
470 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
471
472 let state = RebaseState {
473 head_name: "feature-branch".to_string(),
474 orig_head: hash::hash(b"orig-head"),
475 onto: hash::hash(b"onto"),
476 todo: vec![hash::hash(b"t1"), hash::hash(b"t2")],
477 actions: vec![RebaseAction::Pick, RebaseAction::Reword],
478 done: vec![hash::hash(b"d1")],
479 };
480 write_state(&mkit, &state).unwrap();
481
482 let dir = mkit.rebase_dir();
485 assert!(dir.join("head-name").is_file());
486 assert!(dir.join("orig-head").is_file());
487 assert!(dir.join("onto").is_file());
488 assert!(dir.join("todo").is_file());
489 assert!(dir.join("actions").is_file());
490 assert!(dir.join("done").is_file());
491
492 let read = read_state(&mkit).unwrap();
493 assert_eq!(read, state);
494 }
495
496 #[test]
497 fn missing_actions_file_defaults_to_all_pick() {
498 let tmp = TempDir::new().unwrap();
499 let mkit = RepoLayout::single(tmp.path());
500 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
501 let state = RebaseState {
502 head_name: "main".to_string(),
503 orig_head: hash::hash(b"head"),
504 onto: hash::hash(b"onto"),
505 todo: vec![hash::hash(b"t1"), hash::hash(b"t2")],
506 actions: vec![RebaseAction::Pick, RebaseAction::Pick],
507 done: Vec::new(),
508 };
509 write_state(&mkit, &state).unwrap();
510 fs::remove_file(mkit.rebase_dir().join("actions")).unwrap();
513 let read = read_state(&mkit).unwrap();
514 assert_eq!(read.actions, vec![RebaseAction::Pick, RebaseAction::Pick]);
515 }
516
517 #[test]
518 fn action_keyword_roundtrip_and_folds() {
519 for a in [
520 RebaseAction::Pick,
521 RebaseAction::Reword,
522 RebaseAction::Squash,
523 RebaseAction::Fixup,
524 ] {
525 assert_eq!(RebaseAction::from_keyword(a.keyword()), Some(a));
526 }
527 assert!(RebaseAction::Squash.folds_into_previous());
528 assert!(RebaseAction::Fixup.folds_into_previous());
529 assert!(!RebaseAction::Pick.folds_into_previous());
530 assert!(!RebaseAction::Reword.folds_into_previous());
531 assert_eq!(RebaseAction::from_keyword("nope"), None);
532 }
533
534 #[test]
535 fn consume_front_keeps_todo_and_actions_aligned() {
536 let mut state = RebaseState {
537 head_name: "main".to_string(),
538 orig_head: hash::hash(b"head"),
539 onto: hash::hash(b"onto"),
540 todo: vec![hash::hash(b"t1"), hash::hash(b"t2")],
541 actions: vec![RebaseAction::Reword, RebaseAction::Pick],
542 done: Vec::new(),
543 };
544 assert_eq!(state.front_action(), RebaseAction::Reword);
545 state.consume_front();
546 assert_eq!(state.todo, vec![hash::hash(b"t2")]);
547 assert_eq!(state.actions, vec![RebaseAction::Pick]);
548 assert_eq!(state.front_action(), RebaseAction::Pick);
549 }
550
551 #[test]
552 fn state_roundtrip_with_empty_todo_and_done() {
553 let tmp = TempDir::new().unwrap();
554 let mkit = RepoLayout::single(tmp.path());
555 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
556
557 let state = RebaseState {
558 head_name: "main".to_string(),
559 orig_head: hash::hash(b"head"),
560 onto: hash::hash(b"onto"),
561 todo: Vec::new(),
562 actions: Vec::new(),
563 done: Vec::new(),
564 };
565 write_state(&mkit, &state).unwrap();
566 let read = read_state(&mkit).unwrap();
567 assert_eq!(read, state);
568 }
569
570 #[test]
571 fn is_rebase_in_progress_detection() {
572 let tmp = TempDir::new().unwrap();
573 let mkit = RepoLayout::single(tmp.path());
574 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
575 assert!(!is_rebase_in_progress(&mkit));
576 fs::create_dir_all(mkit.rebase_dir()).unwrap();
577 assert!(is_rebase_in_progress(&mkit));
578 }
579
580 #[test]
581 fn cleanup_removes_state_dir() {
582 let tmp = TempDir::new().unwrap();
583 let mkit = RepoLayout::single(tmp.path());
584 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
585 fs::create_dir_all(mkit.rebase_dir()).unwrap();
586 fs::write(mkit.rebase_dir().join("head-name"), b"main\n").unwrap();
587 assert!(is_rebase_in_progress(&mkit));
588 cleanup_rebase(&mkit).unwrap();
589 assert!(!is_rebase_in_progress(&mkit));
590 }
591
592 #[test]
593 fn cleanup_on_missing_dir_is_noop() {
594 let tmp = TempDir::new().unwrap();
595 let mkit = RepoLayout::single(tmp.path());
596 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
597 cleanup_rebase(&mkit).unwrap();
598 }
599
600 #[test]
601 fn read_state_when_no_rebase_returns_error() {
602 let tmp = TempDir::new().unwrap();
603 let mkit = RepoLayout::single(tmp.path());
604 fs::create_dir_all(mkit.worktree_state_dir()).unwrap();
605 let err = read_state(&mkit).unwrap_err();
606 assert!(matches!(err, RebaseError::NoRebaseInProgress));
607 }
608
609 #[test]
610 fn collect_commits_on_linear_chain() {
611 let (_d, store) = fresh_store();
612 let blob = put_blob(&store, b"data");
613 let tree = put_tree(&store, "f.txt", blob);
614 let c1 = put_commit(&store, tree, vec![], 1);
615 let c2 = put_commit(&store, tree, vec![c1], 2);
616 let c3 = put_commit(&store, tree, vec![c2], 3);
617 let c4 = put_commit(&store, tree, vec![c3], 4);
618
619 let res = collect_commits_to_replay(&store, c4, c2).unwrap();
620 assert_eq!(res, vec![c3, c4]);
621 }
622
623 #[test]
624 fn collect_commits_same_commit_returns_empty() {
625 let (_d, store) = fresh_store();
626 let blob = put_blob(&store, b"data");
627 let tree = put_tree(&store, "f.txt", blob);
628 let c1 = put_commit(&store, tree, vec![], 1);
629 let res = collect_commits_to_replay(&store, c1, c1).unwrap();
630 assert!(res.is_empty());
631 }
632
633 #[test]
634 fn collect_commits_y_shape_stops_at_ancestor_of_onto() {
635 let (_d, store) = fresh_store();
636 let blob = put_blob(&store, b"data");
637 let tree = put_tree(&store, "f.txt", blob);
638 let c1 = put_commit(&store, tree, vec![], 1);
639 let c2 = put_commit(&store, tree, vec![c1], 2);
640 let c3 = put_commit(&store, tree, vec![c2], 3);
641 let c4 = put_commit(&store, tree, vec![c1], 4);
642 let c5 = put_commit(&store, tree, vec![c4], 5);
643
644 let res = collect_commits_to_replay(&store, c5, c3).unwrap();
646 assert_eq!(res, vec![c4, c5]);
647 }
648}