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