Skip to main content

grit_lib/history/
rebase_session.rs

1//! Stop/resume rebase sessions with Git-compatible `.git/rebase-merge/` state.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8
9use crate::hooks::{
10    run_post_checkout, run_post_rewrite, HookName, PostOperationHooks, RewriteKind,
11};
12use crate::lockfile::{LockFile, LockWait};
13use crate::objects::ObjectId;
14use crate::operation::conflicts_from_tree_merge;
15use crate::porcelain::checkout::checkout_hard_to_tree_from_head_tree;
16use crate::porcelain::merge::apply_conflict_merge_worktree;
17use crate::porcelain::reset::{reset_to_commit, ResetMode, ResetOptions};
18use crate::porcelain::worktree_guard::ensure_safe_for_rebase_stop;
19use crate::progress::{
20    poll_cancel, Cancel, NeverCancel, NullProgress, ProgressSink, UncancellableProgress,
21};
22use crate::refs;
23use crate::repo::Repository;
24use crate::state::{resolve_head_for_repository, HeadState};
25use crate::write_tree::{write_tree_update_index, WriteTreeFlags};
26
27use super::apply::{apply_rewrite, log_head_move};
28use super::error::HistoryError;
29use super::plan::{plan_rewrite_with_order_resumable_seeded, ResumablePlanOutcome};
30use super::rebase::{plan_rebase_resumable, RebaseOntoOptions, RebasePlan, RebaseRequest};
31use super::types::{
32    AppliedRewrite, ApplyOptions, CommitEdit, HistoryEdits, PlannedRefUpdate, RewriteOptions,
33    RewritePlan, RewrittenCommit,
34};
35use crate::operation::Conflict;
36
37/// Options for [`start_rebase`].
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct StartRebaseOptions {
40    /// Upstream, optional onto, and branch tip to rebase.
41    pub request: RebaseRequest,
42    /// Replay and cherry-detection options.
43    pub rebase: RebaseOntoOptions,
44    /// Reflog identity and message used when the rebase finishes with [`apply_rewrite`].
45    pub apply: ApplyOptions,
46}
47
48/// Snapshot when a resumable rebase stops on conflicts.
49#[derive(Debug, Clone, PartialEq, Eq)]
50#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
51pub struct RebaseStop {
52    /// Original commit that could not be replayed cleanly.
53    pub commit: ObjectId,
54    /// Conflicted paths and kinds for the current step.
55    pub conflicts: Vec<Conflict>,
56    /// Source commits fully replayed before the stop (oldest first).
57    pub done: Vec<ObjectId>,
58    /// Source commits not yet replayed (including `commit`).
59    pub remaining: Vec<ObjectId>,
60}
61
62/// Result of starting or advancing a rebase session.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
65#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
66pub enum RebaseOutcome {
67    /// Every commit was replayed and ref updates were applied.
68    Done {
69        /// Ref movements performed on completion.
70        applied: AppliedRewrite,
71        /// Final rewrite plan (commits and ref updates).
72        plan: Box<RewritePlan>,
73        /// `HEAD` commit OIDs before this invocation and after completion, as `(old, new)`.
74        ///
75        /// For [`continue_rebase`] and [`skip_rebase`], `old` is the stopped, detached
76        /// `HEAD` at the start of that call and `new` is `applied.head` after branch
77        /// reattachment. `None` means the OID is unchanged, including when only reattaching
78        /// `HEAD`. Unlike [`RewritePlan::head_change`], this covers the entire session call.
79        head_change: Option<(ObjectId, ObjectId)>,
80    },
81    /// An exec stop was resumed to completion and the atomic plan was applied.
82    ExecDone {
83        /// Ref movements performed on completion.
84        applied: AppliedRewrite,
85        /// Final rewrite plan; boxed to keep the outcome small.
86        plan: Box<RewritePlan>,
87        /// Invocations captured during this continue/skip call.
88        exec: super::ExecReport,
89    },
90    /// Replay stopped on conflicts; `.git/rebase-merge/` and grit state were written.
91    Stopped(RebaseStop),
92}
93
94/// Failures while driving a rebase session.
95#[derive(Debug, thiserror::Error)]
96#[non_exhaustive]
97pub enum RebaseSessionError {
98    /// No rebase session is in progress.
99    #[error("no rebase in progress")]
100    NoRebaseInProgress,
101
102    /// A rebase is already in progress.
103    #[error("rebase is already in progress")]
104    RebaseAlreadyInProgress,
105
106    /// A rebase that another tool (normally `git`) started is in progress: its state directory
107    /// exists without grit's plan, so only that tool can continue or skip it.
108    ///
109    /// [`abort_rebase`] also returns this when it cannot restore the rebase safely (an
110    /// autostash is recorded, or `orig-head` is unreadable).
111    #[error("a rebase started by git is in progress")]
112    ForeignRebaseInProgress,
113
114    /// The index still has unmerged entries.
115    #[error("cannot continue: unmerged paths remain in the index")]
116    UnmergedPaths,
117
118    /// Nothing to commit after resolving conflicts.
119    #[error("nothing to commit after conflict resolution")]
120    NothingToCommit,
121
122    /// Branch rebase requires a symbolic `HEAD`.
123    #[error("rebase session requires HEAD on a branch")]
124    NotOnBranch,
125
126    /// The caller moved HEAD away from the recorded exec stop. Save new commits on
127    /// a branch, abort, and restart instead of silently discarding them on continue.
128    #[error("exec replay HEAD changed; expected {expected}; save new commits on a branch, then abort and restart the rebase")]
129    ExecHeadChanged {
130        /// Detached replay commit at the exec stop.
131        expected: ObjectId,
132    },
133
134    /// Underlying history rewrite failure.
135    #[error(transparent)]
136    History(#[from] HistoryError),
137
138    /// Repository I/O or ref failure.
139    #[error(transparent)]
140    Library(#[from] crate::error::Error),
141}
142
143impl RebaseSessionError {
144    /// Whether the rebase session was cancelled ([`crate::error::Error::Cancelled`]).
145    #[must_use]
146    pub fn is_cancelled(&self) -> bool {
147        match self {
148            Self::Library(e) => e.is_cancelled(),
149            Self::History(e) => e.is_cancelled(),
150            Self::NoRebaseInProgress
151            | Self::RebaseAlreadyInProgress
152            | Self::ForeignRebaseInProgress
153            | Self::UnmergedPaths
154            | Self::NothingToCommit
155            | Self::NotOnBranch
156            | Self::ExecHeadChanged { .. } => false,
157        }
158    }
159}
160
161/// Start a resumable rebase of `opts.request.branch_tip` onto the effective onto commit.
162///
163/// On a clean replay, applies the plan atomically and returns [`RebaseOutcome::Done`].
164/// On conflict, writes Git-compatible rebase state and returns [`RebaseOutcome::Stopped`]
165/// without moving the branch ref away from its pre-rebase tip.
166///
167/// # Errors
168///
169/// Returns [`RebaseSessionError::RebaseAlreadyInProgress`] when grit or `git` already has a
170/// rebase in progress, [`RebaseSessionError::NotOnBranch`] when `HEAD` is not on a branch,
171/// and planning/apply failures otherwise. When the replay would stop on conflicts but the index
172/// or worktree has local changes (or an untracked file sits where the stop checks out a path),
173/// returns [`RebaseSessionError::Library`] wrapping
174/// [`Error::DirtyWorktree`](crate::error::Error::DirtyWorktree) or the overwrite error and
175/// leaves the repository untouched, because stopping would discard that work.
176///
177/// Work-tree updates report [`crate::progress::ProgressPhase::CheckingOutFiles`] to `progress`.
178/// `cancel` (or [`ProgressSink::should_cancel`]) is checked after planning and during the apply
179/// and stop checkouts; a cancel returns [`RebaseSessionError::Library`] wrapping
180/// [`Error::Cancelled`](crate::error::Error::Cancelled) (see [`RebaseSessionError::is_cancelled`]).
181/// The last cancel point is before a conflict stop is recorded, so a cancel leaves nothing
182/// changed; once the stop state and `HEAD` are written, its checkout runs to completion and the
183/// rebase stops with its conflicts as without a cancel.
184pub fn start_rebase(
185    repo: &Repository,
186    opts: &StartRebaseOptions,
187    progress: &mut dyn ProgressSink,
188    cancel: &dyn Cancel,
189) -> Result<RebaseOutcome, RebaseSessionError> {
190    super::ensure_rewrite_apply_ready(repo)?;
191    if active_rebase(repo.git_dir()).is_some() {
192        return Err(RebaseSessionError::RebaseAlreadyInProgress);
193    }
194    let (head_ref, orig_head) = branch_head(repo)?;
195    let plan = plan_rebase_resumable(repo, &opts.request, &opts.rebase)?;
196    poll_cancel(progress, cancel).map_err(map_lib)?;
197    match plan {
198        Err(RebasePlan::UpToDate) => Ok(RebaseOutcome::Done {
199            head_change: None,
200            applied: AppliedRewrite {
201                ref_updates: Vec::new(),
202                head: orig_head,
203                hooks_run: Vec::new(),
204            },
205            plan: Box::new(RewritePlan {
206                commits: Vec::new(),
207                ref_updates: Vec::new(),
208                head: super::types::HeadEffect::default(),
209            }),
210        }),
211        Err(RebasePlan::Plan(_) | RebasePlan::Conflict { .. }) => Err(HistoryError::Planning {
212            message: "unexpected non-resumable rebase plan".into(),
213        }
214        .into()),
215        Ok(ResumablePlanOutcome::Complete(plan)) => {
216            let applied = apply_rewrite(repo, &plan, &opts.apply, progress, cancel)?;
217            Ok(RebaseOutcome::Done {
218                head_change: (orig_head != applied.head).then_some((orig_head, applied.head)),
219                applied,
220                plan,
221            })
222        }
223        Ok(ResumablePlanOutcome::Partial(partial)) => {
224            let orig_tree = commit_tree(repo, orig_head)?;
225            let stop_tree = commit_tree(repo, partial.conflict.replayed_parent)?;
226            ensure_safe_for_rebase_stop(repo, &orig_tree, &stop_tree)?;
227            let stop = install_stopped_rebase(
228                repo,
229                &head_ref,
230                orig_head,
231                &opts.request,
232                &opts.rebase,
233                &opts.apply,
234                *partial,
235                progress,
236                cancel,
237            )?;
238            if opts.apply.hooks.enabled() {
239                let onto = opts.request.onto.unwrap_or(opts.request.upstream);
240                run_post_checkout(repo, Some(orig_head), onto, true);
241            }
242            Ok(RebaseOutcome::Stopped(stop))
243        }
244    }
245}
246
247/// Finish the current conflicted commit and replay remaining commits.
248///
249/// For an exec stop, requires a clean index/worktree at the recorded replay HEAD,
250/// then resumes at the next command (the successful dirty command is not rerun).
251/// Save exec edits with a stash before continuing. A moved HEAD is rejected;
252/// save new commits on a branch, abort, and restart in that case.
253///
254/// # Errors
255///
256/// Returns [`RebaseSessionError::UnmergedPaths`] when the index still records conflicts,
257/// [`RebaseSessionError::ForeignRebaseInProgress`] when `git` started the rebase, and
258/// [`RebaseSessionError::NoRebaseInProgress`] when no rebase is in progress.
259/// Cancellation follows [`start_rebase`]: a cancel before the replayed commits are applied
260/// keeps the rebase stopped where it was.
261pub fn continue_rebase(
262    repo: &Repository,
263    opts: &StartRebaseOptions,
264    progress: &mut dyn ProgressSink,
265    cancel: &dyn Cancel,
266) -> Result<RebaseOutcome, RebaseSessionError> {
267    if matches!(active_rebase(repo.git_dir()), Some(ActiveRebase::Exec)) {
268        return super::exec::resume_exec_rebase(repo, &opts.apply, false);
269    }
270    super::ensure_rewrite_apply_ready(repo)?;
271    let state = load_persisted_state(repo.git_dir())?;
272    if repo.load_index()?.has_unmerged_entries() {
273        return Err(RebaseSessionError::UnmergedPaths);
274    }
275    let current = state
276        .remaining
277        .first()
278        .copied()
279        .ok_or(RebaseSessionError::NoRebaseInProgress)?;
280    let new_tree = {
281        let mut index = repo.load_index()?;
282        write_tree_update_index(repo.odb(), &mut index, "", WriteTreeFlags::silent())
283            .map_err(map_lib)?
284    };
285    let head_oid = resolve_head_oid(repo)?;
286    let head_tree = commit_tree(repo, head_oid)?;
287    if new_tree == head_tree {
288        return Err(RebaseSessionError::NothingToCommit);
289    }
290
291    let mut edits = state.edits.clone();
292    edits.insert(
293        current,
294        CommitEdit::Rewrite {
295            tree: Some(new_tree),
296            message: None,
297            author: None,
298            parents: Some(vec![head_oid]),
299        },
300    );
301
302    let seed = (state.mapping.clone(), state.rewritten.clone());
303    let rewrite = replan_rewrite_options(&state);
304    let outcome = plan_rewrite_with_order_resumable_seeded(
305        repo,
306        &edits,
307        &rewrite,
308        Some(&state.replay_order),
309        state.flatten_leaf_merges.then_some(state.onto),
310        Some(seed),
311        Some(state.scoped_refs.clone()),
312    )?;
313    advance_after_plan(
314        repo,
315        &state,
316        &opts.apply,
317        outcome,
318        head_oid,
319        progress,
320        cancel,
321    )
322}
323
324/// Skip the current todo commit and replay the rest.
325///
326/// For an exec stop, requires the edits to be saved or removed first, then skips
327/// remaining commands at that replay commit; the already replayed commit is kept.
328///
329/// # Errors
330///
331/// Returns [`RebaseSessionError::ForeignRebaseInProgress`] when `git` started the rebase,
332/// [`RebaseSessionError::NoRebaseInProgress`] when no rebase is in progress, and
333/// planning/apply failures otherwise. Cancellation follows [`continue_rebase`].
334pub fn skip_rebase(
335    repo: &Repository,
336    opts: &StartRebaseOptions,
337    progress: &mut dyn ProgressSink,
338    cancel: &dyn Cancel,
339) -> Result<RebaseOutcome, RebaseSessionError> {
340    if matches!(active_rebase(repo.git_dir()), Some(ActiveRebase::Exec)) {
341        return super::exec::resume_exec_rebase(repo, &opts.apply, true);
342    }
343    let state = load_persisted_state(repo.git_dir())?;
344    let current = state
345        .remaining
346        .first()
347        .copied()
348        .ok_or(RebaseSessionError::NoRebaseInProgress)?;
349    let head_oid = resolve_head_oid(repo)?;
350    reset_index_worktree_to_head(repo, head_oid, progress, cancel)?;
351
352    let replayed_parent = head_oid;
353    let mut mapping = state.mapping.clone();
354    mapping.insert(current, replayed_parent);
355    let mut rewritten = state.rewritten.clone();
356    rewritten.push(RewrittenCommit {
357        old: current,
358        new: None,
359        change_id: None,
360        subject: None,
361        reused: false,
362    });
363
364    let rewrite = replan_rewrite_options(&state);
365    let outcome = plan_rewrite_with_order_resumable_seeded(
366        repo,
367        &state.edits,
368        &rewrite,
369        Some(&state.replay_order),
370        state.flatten_leaf_merges.then_some(state.onto),
371        Some((mapping, rewritten)),
372        Some(state.scoped_refs.clone()),
373    )?;
374    advance_after_plan(
375        repo,
376        &state,
377        &opts.apply,
378        outcome,
379        head_oid,
380        progress,
381        cancel,
382    )
383}
384
385/// Cancel the in-progress rebase and restore the branch to `orig-head`.
386///
387/// Also cancels a rebase that `git` started (`rebase-merge/` or `rebase-apply/` without grit's
388/// plan): `HEAD` is re-attached to the rebased branch (or detached, when the rebase started
389/// detached), the index and worktree are reset to `orig-head`, and the state directory is
390/// removed. Either way, unresolved rerere records for the abandoned conflict are cleared
391/// (`MERGE_RR`), as `git rebase --abort` does.
392///
393/// # Errors
394///
395/// Returns [`RebaseSessionError::NoRebaseInProgress`] when no rebase is in progress, and
396/// [`RebaseSessionError::ForeignRebaseInProgress`] when a git-started rebase recorded an
397/// autostash or has no readable `orig-head` (finish those with `git rebase --abort`).
398pub fn abort_rebase(
399    repo: &Repository,
400    identity: &crate::ident::Signature,
401) -> Result<(), RebaseSessionError> {
402    identity
403        .validate()
404        .map_err(|err| map_lib(crate::error::Error::InvalidSignature(err)))?;
405    let had_journal = super::rewrite_apply_journal_active(repo.git_dir());
406    if had_journal {
407        super::abort_interrupted_rewrite_apply(repo)?;
408    }
409    match active_rebase(repo.git_dir()) {
410        None => {
411            if had_journal {
412                Ok(())
413            } else {
414                Err(RebaseSessionError::NoRebaseInProgress)
415            }
416        }
417        Some(ActiveRebase::Foreign(dir)) => abort_foreign_rebase(repo, &dir, identity),
418        Some(ActiveRebase::Exec) => super::exec::abort_exec_rebase(repo),
419        Some(ActiveRebase::Grit) => {
420            let state = load_persisted_state(repo.git_dir())?;
421            crate::rerere::rerere_clear(repo.git_dir()).map_err(map_lib)?;
422            hard_reset_branch(repo, &state.head_ref, state.orig_head, identity)?;
423            clear_rebase_state(repo.git_dir())?;
424            Ok(())
425        }
426    }
427}
428
429fn abort_foreign_rebase(
430    repo: &Repository,
431    state_dir: &Path,
432    identity: &crate::ident::Signature,
433) -> Result<(), RebaseSessionError> {
434    if state_dir.join("autostash").exists() {
435        return Err(RebaseSessionError::ForeignRebaseInProgress);
436    }
437    let orig_head = first_line(&state_dir.join("orig-head"))
438        .and_then(|hex| ObjectId::from_hex(&hex).ok())
439        .ok_or(RebaseSessionError::ForeignRebaseInProgress)?;
440    crate::rerere::rerere_clear(repo.git_dir()).map_err(map_lib)?;
441    match first_line(&state_dir.join("head-name")).filter(|name| name.starts_with("refs/")) {
442        Some(head_ref) => hard_reset_branch(repo, &head_ref, orig_head, identity)?,
443        None => hard_reset_head(repo, orig_head, identity)?,
444    }
445    for name in ["REBASE_HEAD", "AUTO_MERGE", "MERGE_MSG"] {
446        remove_if_exists(&repo.git_dir().join(name))?;
447    }
448    fs::remove_dir_all(state_dir).map_err(map_lib)?;
449    Ok(())
450}
451
452/// Which tool owns the rebase state under the git directory.
453enum ActiveRebase {
454    /// Successful exec left tracked edits at a replay commit.
455    Exec,
456    /// `rebase-merge/grit-plan` exists.
457    Grit,
458    /// `rebase-merge/` or `rebase-apply/` (not an `am`) without grit's plan.
459    Foreign(std::path::PathBuf),
460}
461
462fn active_rebase(git_dir: &Path) -> Option<ActiveRebase> {
463    let merge = git_dir.join("rebase-merge");
464    if merge.join("grit-exec").is_file() {
465        return Some(ActiveRebase::Exec);
466    }
467    if merge.join("grit-plan").is_file() {
468        return Some(ActiveRebase::Grit);
469    }
470    if merge.is_dir() {
471        return Some(ActiveRebase::Foreign(merge));
472    }
473    let apply = git_dir.join("rebase-apply");
474    (apply.is_dir() && !apply.join("applying").exists()).then_some(ActiveRebase::Foreign(apply))
475}
476
477fn first_line(path: &Path) -> Option<String> {
478    let raw = fs::read_to_string(path).ok()?;
479    let line = raw.lines().next()?.trim();
480    (!line.is_empty()).then(|| line.to_owned())
481}
482
483fn remove_if_exists(path: &Path) -> Result<(), RebaseSessionError> {
484    match fs::remove_file(path) {
485        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(map_lib(e)),
486        _ => Ok(()),
487    }
488}
489
490// --- persisted session ---
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
493struct PersistedRebaseState {
494    mapping: HashMap<String, String>,
495    rewritten: Vec<PersistedRewrittenRow>,
496    edits: Vec<PersistedEdit>,
497    rewrite: RewriteOptions,
498    rebase: RebaseOntoOptions,
499    apply: ApplyOptions,
500    replay_order: Vec<String>,
501    remaining: Vec<String>,
502    done: Vec<String>,
503    fork: String,
504    onto: String,
505    upstream: String,
506    head_ref: String,
507    orig_head: String,
508    branch_tip: String,
509    flatten_leaf_merges: bool,
510    scoped_refs: Vec<(String, String)>,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
514struct PersistedRewrittenRow {
515    old: String,
516    new: Option<String>,
517    #[serde(default)]
518    reused: bool,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
522enum PersistedEdit {
523    Reparent {
524        commit: String,
525        parent: String,
526    },
527    Reuse {
528        commit: String,
529        existing: String,
530        /// Absent in sessions saved before skipped commits carried their continuation parent.
531        #[serde(default)]
532        parent: Option<String>,
533    },
534    Drop {
535        commit: String,
536    },
537    Rewrite {
538        commit: String,
539        tree: Option<String>,
540        message: Option<String>,
541        author: Option<String>,
542    },
543}
544
545fn oid_hex(oid: &ObjectId) -> String {
546    oid.to_hex()
547}
548
549fn parse_oid(hex: &str) -> Result<ObjectId, RebaseSessionError> {
550    ObjectId::from_hex(hex).map_err(|e| {
551        HistoryError::Planning {
552            message: e.to_string(),
553        }
554        .into()
555    })
556}
557
558fn edits_to_persist(edits: &HistoryEdits) -> Vec<PersistedEdit> {
559    edits
560        .iter()
561        .map(|(commit, edit)| match edit {
562            CommitEdit::Reparent { parent } => PersistedEdit::Reparent {
563                commit: oid_hex(commit),
564                parent: oid_hex(parent),
565            },
566            CommitEdit::Reuse { existing, parent } => PersistedEdit::Reuse {
567                commit: oid_hex(commit),
568                existing: oid_hex(existing),
569                parent: Some(oid_hex(parent)),
570            },
571            CommitEdit::Drop => PersistedEdit::Drop {
572                commit: oid_hex(commit),
573            },
574            CommitEdit::Rewrite {
575                tree,
576                message,
577                author,
578                parents: _,
579            } => PersistedEdit::Rewrite {
580                commit: oid_hex(commit),
581                tree: tree.as_ref().map(oid_hex),
582                message: message.clone(),
583                author: author.clone(),
584            },
585            CommitEdit::InsertBefore(_) | CommitEdit::SplitBefore { .. } => PersistedEdit::Drop {
586                commit: oid_hex(commit),
587            },
588        })
589        .collect()
590}
591
592fn edits_from_persist(rows: &[PersistedEdit]) -> Result<HistoryEdits, RebaseSessionError> {
593    let mut edits = HistoryEdits::new();
594    for row in rows {
595        match row {
596            PersistedEdit::Reparent { commit, parent } => {
597                edits.insert(
598                    parse_oid(commit)?,
599                    CommitEdit::Reparent {
600                        parent: parse_oid(parent)?,
601                    },
602                );
603            }
604            PersistedEdit::Reuse {
605                commit,
606                existing,
607                parent,
608            } => {
609                let existing = parse_oid(existing)?;
610                let parent = parent.as_deref().map(parse_oid).transpose()?;
611                edits.insert(
612                    parse_oid(commit)?,
613                    CommitEdit::Reuse {
614                        existing,
615                        parent: parent.unwrap_or(existing),
616                    },
617                );
618            }
619            PersistedEdit::Drop { commit } => {
620                edits.insert(parse_oid(commit)?, CommitEdit::Drop);
621            }
622            PersistedEdit::Rewrite {
623                commit,
624                tree,
625                message,
626                author,
627            } => {
628                edits.insert(
629                    parse_oid(commit)?,
630                    CommitEdit::Rewrite {
631                        tree: tree.as_deref().map(parse_oid).transpose()?,
632                        message: message.clone(),
633                        author: author.clone(),
634                        parents: None,
635                    },
636                );
637            }
638        }
639    }
640    Ok(edits)
641}
642
643fn mapping_to_persist(map: &HashMap<ObjectId, ObjectId>) -> HashMap<String, String> {
644    map.iter().map(|(k, v)| (oid_hex(k), oid_hex(v))).collect()
645}
646
647fn mapping_from_persist(
648    map: &HashMap<String, String>,
649) -> Result<HashMap<ObjectId, ObjectId>, RebaseSessionError> {
650    map.iter()
651        .map(|(k, v)| Ok((parse_oid(k)?, parse_oid(v)?)))
652        .collect()
653}
654
655#[allow(clippy::too_many_arguments)]
656fn state_from_partial(
657    partial: &super::plan::PartialRewritePlan,
658    head_ref: &str,
659    orig_head: ObjectId,
660    request: &RebaseRequest,
661    rebase: &RebaseOntoOptions,
662    apply: &ApplyOptions,
663    effective_onto: ObjectId,
664    fork: ObjectId,
665) -> PersistedRebaseState {
666    let done: Vec<ObjectId> = partial
667        .commits_to_replay
668        .iter()
669        .copied()
670        .filter(|c| partial.mapping.contains_key(c))
671        .collect();
672    let remaining: Vec<ObjectId> = partial
673        .commits_to_replay
674        .iter()
675        .copied()
676        .filter(|c| !partial.mapping.contains_key(c))
677        .collect();
678    PersistedRebaseState {
679        mapping: mapping_to_persist(&partial.mapping),
680        rewritten: partial
681            .rewritten
682            .iter()
683            .map(|r| PersistedRewrittenRow {
684                old: oid_hex(&r.old),
685                new: r.new.as_ref().map(oid_hex),
686                reused: r.reused,
687            })
688            .collect(),
689        edits: edits_to_persist(&partial.edits),
690        rewrite: partial.opts.clone(),
691        rebase: rebase.clone(),
692        apply: apply.clone(),
693        replay_order: partial.commits_to_replay.iter().map(oid_hex).collect(),
694        remaining: remaining.iter().map(oid_hex).collect(),
695        done: done.iter().map(oid_hex).collect(),
696        fork: oid_hex(&fork),
697        onto: oid_hex(&effective_onto),
698        upstream: oid_hex(&request.upstream),
699        head_ref: head_ref.to_owned(),
700        orig_head: oid_hex(&orig_head),
701        branch_tip: oid_hex(&request.branch_tip),
702        flatten_leaf_merges: partial.linearize_onto.is_some(),
703        scoped_refs: partial
704            .scoped_refs
705            .iter()
706            .map(|(n, o)| (n.clone(), oid_hex(o)))
707            .collect(),
708    }
709}
710
711struct LoadedState {
712    mapping: HashMap<ObjectId, ObjectId>,
713    rewritten: Vec<RewrittenCommit>,
714    edits: HistoryEdits,
715    rewrite: RewriteOptions,
716    rebase: RebaseOntoOptions,
717    replay_order: Vec<ObjectId>,
718    remaining: Vec<ObjectId>,
719    head_ref: String,
720    orig_head: ObjectId,
721    onto: ObjectId,
722    upstream: ObjectId,
723    branch_tip: ObjectId,
724    flatten_leaf_merges: bool,
725    scoped_refs: Vec<(String, ObjectId)>,
726}
727
728fn load_persisted_state(git_dir: &Path) -> Result<LoadedState, RebaseSessionError> {
729    match active_rebase(git_dir) {
730        None => return Err(RebaseSessionError::NoRebaseInProgress),
731        Some(ActiveRebase::Foreign(_)) => return Err(RebaseSessionError::ForeignRebaseInProgress),
732        Some(ActiveRebase::Exec) => return Err(RebaseSessionError::NoRebaseInProgress),
733        Some(ActiveRebase::Grit) => {}
734    }
735    let path = git_dir.join("rebase-merge").join("grit-plan");
736    let raw = fs::read_to_string(&path).map_err(|_| RebaseSessionError::NoRebaseInProgress)?;
737    let parsed: PersistedRebaseState =
738        serde_json::from_str(&raw).map_err(|_| RebaseSessionError::NoRebaseInProgress)?;
739    Ok(LoadedState {
740        mapping: mapping_from_persist(&parsed.mapping)?,
741        rewritten: parsed
742            .rewritten
743            .iter()
744            .map(|r| {
745                Ok(RewrittenCommit {
746                    old: parse_oid(&r.old)?,
747                    new: r.new.as_deref().map(parse_oid).transpose()?,
748                    change_id: None,
749                    subject: None,
750                    reused: r.reused,
751                })
752            })
753            .collect::<Result<_, RebaseSessionError>>()?,
754        edits: edits_from_persist(&parsed.edits)?,
755        rewrite: parsed.rewrite,
756        rebase: parsed.rebase,
757        replay_order: parsed
758            .replay_order
759            .iter()
760            .map(|h| parse_oid(h))
761            .collect::<Result<_, _>>()?,
762        remaining: parsed
763            .remaining
764            .iter()
765            .map(|h| parse_oid(h))
766            .collect::<Result<_, _>>()?,
767        head_ref: parsed.head_ref,
768        orig_head: parse_oid(&parsed.orig_head)?,
769        onto: parse_oid(&parsed.onto)?,
770        upstream: parse_oid(&parsed.upstream)?,
771        branch_tip: parse_oid(&parsed.branch_tip)?,
772        flatten_leaf_merges: parsed.flatten_leaf_merges,
773        scoped_refs: parsed
774            .scoped_refs
775            .iter()
776            .map(|(name, hex)| parse_oid(hex).map(|oid| (name.clone(), oid)))
777            .collect::<Result<_, _>>()?,
778    })
779}
780
781fn write_persisted_state(
782    git_dir: &Path,
783    state: &PersistedRebaseState,
784) -> Result<(), RebaseSessionError> {
785    let dir = git_dir.join("rebase-merge");
786    fs::create_dir_all(&dir).map_err(map_lib)?;
787    let lock_path = dir.join("grit-plan.lock");
788    let mut lock = LockFile::acquire(&lock_path, LockWait::Immediately)
789        .map_err(|e| RebaseSessionError::Library(crate::error::Error::Message(e.to_string())))?;
790    let body = serde_json::to_vec_pretty(state).map_err(map_json)?;
791    lock.write_all(&body).map_err(map_lib)?;
792    lock.commit_to(&dir.join("grit-plan"))
793        .map_err(|e| RebaseSessionError::Library(crate::error::Error::Message(e.to_string())))?;
794    Ok(())
795}
796
797#[allow(clippy::too_many_arguments)]
798fn install_stopped_rebase(
799    repo: &Repository,
800    head_ref: &str,
801    orig_head: ObjectId,
802    request: &RebaseRequest,
803    rebase_opts: &RebaseOntoOptions,
804    apply: &ApplyOptions,
805    partial: super::plan::PartialRewritePlan,
806    progress: &mut dyn ProgressSink,
807    cancel: &dyn Cancel,
808) -> Result<RebaseStop, RebaseSessionError> {
809    poll_cancel(progress, cancel).map_err(map_lib)?;
810    let effective_onto = request.onto.unwrap_or(request.upstream);
811    let fork = super::rebase::rebase_fork(repo, request.upstream, request.branch_tip)?;
812    let persisted = state_from_partial(
813        &partial,
814        head_ref,
815        orig_head,
816        request,
817        rebase_opts,
818        apply,
819        effective_onto,
820        fork,
821    );
822    let conflict = partial.conflict;
823    let done = persisted
824        .done
825        .iter()
826        .map(|h| parse_oid(h))
827        .collect::<Result<Vec<_>, _>>()?;
828    let remaining = persisted
829        .remaining
830        .iter()
831        .map(|h| parse_oid(h))
832        .collect::<Result<Vec<_>, _>>()?;
833    write_git_rebase_merge_files(
834        repo,
835        head_ref,
836        orig_head,
837        effective_onto,
838        &done,
839        &remaining,
840        &conflict.commit,
841    )?;
842    write_persisted_state(repo.git_dir(), &persisted)?;
843
844    refs::update_direct_ref_on_store(
845        repo.refs(),
846        repo.git_dir(),
847        "HEAD",
848        &conflict.replayed_parent,
849    )
850    .map_err(map_lib)?;
851    // With the stop state and HEAD published, a half-installed stop would let continue commit
852    // without the conflict stages, so the checkouts below run to completion.
853    let mut progress = UncancellableProgress::new(progress);
854    reset_index_worktree_to_head(repo, conflict.replayed_parent, &mut progress, &NeverCancel)?;
855    let ours_tree = commit_tree(repo, conflict.replayed_parent)?;
856    apply_conflict_merge_worktree(
857        repo,
858        &ours_tree,
859        &conflict.merge,
860        &mut progress,
861        &NeverCancel,
862    )
863    .map_err(map_lib)?;
864
865    let message = load_commit(repo, conflict.commit)?.message;
866    fs::write(repo.git_dir().join("MERGE_MSG"), &message).map_err(map_lib)?;
867
868    Ok(RebaseStop {
869        commit: conflict.commit,
870        conflicts: conflicts_from_tree_merge(&conflict.merge),
871        done,
872        remaining,
873    })
874}
875
876#[allow(clippy::too_many_arguments)]
877fn advance_after_plan(
878    repo: &Repository,
879    prev: &LoadedState,
880    apply: &ApplyOptions,
881    outcome: ResumablePlanOutcome,
882    head_before: ObjectId,
883    progress: &mut dyn ProgressSink,
884    cancel: &dyn Cancel,
885) -> Result<RebaseOutcome, RebaseSessionError> {
886    poll_cancel(progress, cancel).map_err(map_lib)?;
887    match outcome {
888        ResumablePlanOutcome::Complete(mut plan) => {
889            if let Some(new_tip) = plan.mapped_tip(prev.orig_head) {
890                if !plan.ref_updates.iter().any(|u| u.name == prev.head_ref) {
891                    plan.ref_updates.push(PlannedRefUpdate {
892                        name: prev.head_ref.clone(),
893                        old: prev.orig_head,
894                        new: new_tip,
895                    });
896                }
897            }
898            let apply_without_hooks = ApplyOptions {
899                hooks: PostOperationHooks::Skip,
900                ..apply.clone()
901            };
902            let mut applied = apply_rewrite(repo, &plan, &apply_without_hooks, progress, cancel)?;
903            // The branch moved while HEAD was detached for the replay, so apply_rewrite only
904            // logged it on the branch; HEAD's log still ends at the pre-rebase tip. Log before
905            // reattaching so a failure leaves a detached, abortable rebase.
906            if let Some(branch) = plan
907                .ref_updates
908                .iter()
909                .find(|u| u.name == prev.head_ref && u.old != u.new)
910            {
911                log_head_move(repo, branch, apply)?;
912            }
913            refs::write_symbolic_ref_on_store(repo.refs(), repo.git_dir(), "HEAD", &prev.head_ref)
914                .map_err(map_lib)?;
915            let branch_tip = repo.resolve_ref_oid(&prev.head_ref).map_err(map_lib)?;
916            // The refs already moved: finish the rebase even if a cancel arrives now.
917            reset_index_worktree_to_head(repo, branch_tip, &mut NullProgress, &NeverCancel)?;
918            applied.head = branch_tip;
919            clear_rebase_state(repo.git_dir())?;
920            if apply.hooks.enabled()
921                && run_post_rewrite(repo, RewriteKind::Rebase, &plan.rewritten_pairs())
922            {
923                applied.hooks_run.push(HookName::PostRewrite);
924            }
925            Ok(RebaseOutcome::Done {
926                head_change: (head_before != applied.head).then_some((head_before, applied.head)),
927                applied,
928                plan,
929            })
930        }
931        ResumablePlanOutcome::Partial(partial) => {
932            let request = RebaseRequest {
933                upstream: prev.upstream,
934                onto: Some(prev.onto),
935                branch_tip: prev.branch_tip,
936            };
937            let stop = install_stopped_rebase(
938                repo,
939                &prev.head_ref,
940                prev.orig_head,
941                &request,
942                &prev.rebase,
943                apply,
944                *partial,
945                progress,
946                cancel,
947            )?;
948            Ok(RebaseOutcome::Stopped(stop))
949        }
950    }
951}
952
953fn write_git_rebase_merge_files(
954    repo: &Repository,
955    head_ref: &str,
956    orig_head: ObjectId,
957    onto: ObjectId,
958    done: &[ObjectId],
959    remaining: &[ObjectId],
960    current: &ObjectId,
961) -> Result<(), RebaseSessionError> {
962    let dir = repo.git_dir().join("rebase-merge");
963    fs::create_dir_all(&dir).map_err(map_lib)?;
964    fs::write(dir.join("head-name"), format!("{head_ref}\n")).map_err(map_lib)?;
965    fs::write(dir.join("onto"), format!("{}\n", onto.to_hex())).map_err(map_lib)?;
966    fs::write(dir.join("orig-head"), format!("{}\n", orig_head.to_hex())).map_err(map_lib)?;
967    let todo_remaining: Vec<ObjectId> = if remaining.first() == Some(current) {
968        remaining.iter().skip(1).copied().collect()
969    } else {
970        remaining.to_vec()
971    };
972    fs::write(
973        dir.join("git-rebase-todo"),
974        todo_lines(repo, &todo_remaining)?,
975    )
976    .map_err(map_lib)?;
977    // Git lists the stopped pick as the last `done` line and resumes from `stopped-sha`,
978    // committing the staged resolution with `message` and `author-script`.
979    let done_with_current: Vec<ObjectId> = done.iter().chain([current]).copied().collect();
980    fs::write(dir.join("done"), todo_lines(repo, &done_with_current)?).map_err(map_lib)?;
981    let msgnum = done.len() + 1;
982    let end = done.len() + remaining.len();
983    fs::write(dir.join("msgnum"), format!("{msgnum}\n")).map_err(map_lib)?;
984    fs::write(dir.join("end"), format!("{end}\n")).map_err(map_lib)?;
985    fs::write(dir.join("stopped-sha"), format!("{}\n", current.to_hex())).map_err(map_lib)?;
986    let commit = load_commit(repo, *current)?;
987    match author_script(&commit.author) {
988        Some(script) => fs::write(dir.join("author-script"), script).map_err(map_lib)?,
989        None => remove_if_exists(&dir.join("author-script"))?,
990    }
991    fs::write(dir.join("message"), commit.message).map_err(map_lib)?;
992    fs::write(
993        repo.git_dir().join("ORIG_HEAD"),
994        format!("{}\n", orig_head.to_hex()),
995    )
996    .map_err(map_lib)?;
997    Ok(())
998}
999
1000fn todo_lines(repo: &Repository, commits: &[ObjectId]) -> Result<String, RebaseSessionError> {
1001    let mut out = String::new();
1002    for &oid in commits {
1003        let subject = super::subject::commit_message_subject_line(&load_commit(repo, oid)?.message);
1004        let abbrev = &oid.to_hex()[..7];
1005        out.push_str(&format!("pick {abbrev} {subject}\n"));
1006    }
1007    Ok(out)
1008}
1009
1010/// `rebase-merge/author-script` body for a commit's `author` header: shell assignments of
1011/// `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL` and `GIT_AUTHOR_DATE` (`@<seconds> <offset>`), each
1012/// value single-quoted. `None` when the header has no `<email>`.
1013fn author_script(author: &str) -> Option<String> {
1014    let lt = author.find('<')?;
1015    let gt = lt + author[lt..].find('>')?;
1016    let name = author[..lt].trim_end();
1017    let email = &author[lt + 1..gt];
1018    let date = format!("@{}", author[gt + 1..].trim());
1019    let quote = crate::transport::sq_quote_shell_arg;
1020    Some(format!(
1021        "GIT_AUTHOR_NAME={}\nGIT_AUTHOR_EMAIL={}\nGIT_AUTHOR_DATE={}\n",
1022        quote(name),
1023        quote(email),
1024        quote(&date)
1025    ))
1026}
1027
1028pub(super) fn clear_rebase_state(git_dir: &Path) -> Result<(), RebaseSessionError> {
1029    for name in ["ORIG_HEAD", "MERGE_MSG"] {
1030        let path = git_dir.join(name);
1031        if path.exists() {
1032            fs::remove_file(&path).map_err(map_lib)?;
1033        }
1034    }
1035    let dir = git_dir.join("rebase-merge");
1036    if dir.is_dir() {
1037        fs::remove_dir_all(&dir).map_err(map_lib)?;
1038    }
1039    Ok(())
1040}
1041
1042fn branch_head(repo: &Repository) -> Result<(String, ObjectId), RebaseSessionError> {
1043    match resolve_head_for_repository(repo).map_err(map_lib)? {
1044        HeadState::Branch {
1045            refname,
1046            oid: Some(oid),
1047            ..
1048        } => Ok((refname, oid)),
1049        HeadState::Branch { oid: None, .. } | HeadState::Detached { .. } | HeadState::Invalid => {
1050            Err(RebaseSessionError::NotOnBranch)
1051        }
1052    }
1053}
1054
1055fn resolve_head_oid(repo: &Repository) -> Result<ObjectId, RebaseSessionError> {
1056    resolve_head_for_repository(repo)
1057        .map_err(map_lib)?
1058        .oid()
1059        .copied()
1060        .ok_or(RebaseSessionError::NotOnBranch)
1061}
1062
1063fn commit_tree(repo: &Repository, commit: ObjectId) -> Result<ObjectId, RebaseSessionError> {
1064    repo.odb().commit_tree(&commit).map_err(map_lib)
1065}
1066
1067fn load_commit(
1068    repo: &Repository,
1069    oid: ObjectId,
1070) -> Result<crate::objects::CommitData, RebaseSessionError> {
1071    repo.odb().read_commit(&oid).map_err(map_lib)
1072}
1073
1074fn reset_index_worktree_to_head(
1075    repo: &Repository,
1076    head_oid: ObjectId,
1077    progress: &mut dyn ProgressSink,
1078    cancel: &dyn Cancel,
1079) -> Result<(), RebaseSessionError> {
1080    let head_tree = commit_tree(repo, head_oid)?;
1081    // Paths tracked before the move but absent from `head_oid` must go too; leaving them as
1082    // untracked files makes `git rebase --skip/--abort` refuse to overwrite them later.
1083    checkout_hard_to_tree_from_head_tree(repo, &head_tree, &head_tree, progress, cancel)
1084        .map_err(map_lib)?;
1085    Ok(())
1086}
1087
1088fn hard_reset_branch(
1089    repo: &Repository,
1090    refname: &str,
1091    target: ObjectId,
1092    identity: &crate::ident::Signature,
1093) -> Result<(), RebaseSessionError> {
1094    refs::write_symbolic_ref_on_store(repo.refs(), repo.git_dir(), "HEAD", refname)
1095        .map_err(map_lib)?;
1096    hard_reset_head(repo, target, identity)
1097}
1098
1099fn hard_reset_head(
1100    repo: &Repository,
1101    target: ObjectId,
1102    identity: &crate::ident::Signature,
1103) -> Result<(), RebaseSessionError> {
1104    reset_to_commit(
1105        repo,
1106        &ResetOptions::new(target, ResetMode::Hard, identity.clone(), "rebase (abort)"),
1107        &mut crate::progress::NullProgress,
1108        &crate::progress::NeverCancel,
1109    )
1110    .map_err(map_lib)?;
1111    Ok(())
1112}
1113
1114use std::io::Write;
1115
1116fn map_lib<E: Into<crate::error::Error>>(err: E) -> RebaseSessionError {
1117    RebaseSessionError::Library(err.into())
1118}
1119
1120fn replan_rewrite_options(state: &LoadedState) -> RewriteOptions {
1121    state.rewrite.clone()
1122}
1123
1124fn map_json(err: serde_json::Error) -> RebaseSessionError {
1125    RebaseSessionError::Library(crate::error::Error::Message(err.to_string()))
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::{author_script, edits_from_persist, edits_to_persist, PersistedEdit};
1131    use crate::history::types::{CommitEdit, HistoryEdits};
1132    use crate::objects::ObjectId;
1133
1134    fn oid(byte: u8) -> ObjectId {
1135        ObjectId::from_bytes(&[byte; 20]).unwrap_or_else(|_| ObjectId::zero())
1136    }
1137
1138    #[test]
1139    fn reuse_edit_round_trips_its_continuation_parent() {
1140        let mut edits = HistoryEdits::new();
1141        edits.insert(
1142            oid(1),
1143            CommitEdit::Reuse {
1144                existing: oid(2),
1145                parent: oid(3),
1146            },
1147        );
1148        let rows = edits_to_persist(&edits);
1149        let json = serde_json::to_string(&rows).unwrap_or_default();
1150        let parsed: Vec<PersistedEdit> = serde_json::from_str(&json).unwrap_or_default();
1151        assert_eq!(edits_from_persist(&parsed).ok(), Some(edits));
1152    }
1153
1154    #[test]
1155    fn reuse_edit_saved_without_parent_continues_from_the_reused_commit() {
1156        let json = format!(
1157            r#"[{{"Reuse":{{"commit":"{}","existing":"{}"}}}}]"#,
1158            oid(1).to_hex(),
1159            oid(2).to_hex()
1160        );
1161        let parsed: Vec<PersistedEdit> = serde_json::from_str(&json).unwrap_or_default();
1162        let edits = edits_from_persist(&parsed).ok();
1163        assert_eq!(
1164            edits.and_then(|edits| edits.get(&oid(1)).cloned()),
1165            Some(CommitEdit::Reuse {
1166                existing: oid(2),
1167                parent: oid(2),
1168            })
1169        );
1170    }
1171
1172    #[test]
1173    fn author_script_quotes_values_and_prefixes_date() {
1174        assert_eq!(
1175            author_script("A U Thor <a@example.com> 1700000000 -0130").as_deref(),
1176            Some(
1177                "GIT_AUTHOR_NAME='A U Thor'\nGIT_AUTHOR_EMAIL='a@example.com'\nGIT_AUTHOR_DATE='@1700000000 -0130'\n"
1178            )
1179        );
1180        assert_eq!(
1181            author_script("O'Brien <o@example.com> 1 +0000").as_deref(),
1182            Some(
1183                "GIT_AUTHOR_NAME='O'\\''Brien'\nGIT_AUTHOR_EMAIL='o@example.com'\nGIT_AUTHOR_DATE='@1 +0000'\n"
1184            )
1185        );
1186        assert_eq!(author_script("no email here 1 +0000"), None);
1187    }
1188}