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, HashSet};
4use std::fs;
5use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8
9use crate::change_id::logical_change_id_for_commit;
10use crate::hooks::{
11    run_post_checkout, run_post_rewrite, HookName, PostOperationHooks, RewriteKind,
12};
13use crate::lockfile::{LockFile, LockWait};
14use crate::objects::ObjectId;
15use crate::operation::conflicts_from_tree_merge;
16use crate::porcelain::checkout::checkout_hard_to_tree_from_head_tree;
17use crate::porcelain::merge::apply_conflict_merge_worktree;
18use crate::porcelain::reset::{reset_to_commit, ResetMode, ResetOptions};
19use crate::porcelain::worktree_guard::{
20    ensure_safe_for_rebase_stop, ensure_worktree_clean_for_rebase_exec,
21};
22use crate::progress::{
23    poll_cancel, Cancel, NeverCancel, NullProgress, ProgressSink, UncancellableProgress,
24};
25use crate::refs;
26use crate::repo::Repository;
27use crate::state::{resolve_head_for_repository, write_orig_head, HeadState};
28use crate::write_tree::{write_tree_update_index, WriteTreeFlags};
29
30use super::apply::{apply_rewrite, log_head_move};
31use super::error::HistoryError;
32use super::last::{write_history_last, HistoryLastRecord};
33use super::plan::{
34    commit_parents, plan_rewrite_with_order_resumable_seeded, write_rebase_staged_resolution,
35    ResumablePlanOutcome,
36};
37use super::rebase::{
38    commits_in_rebase_range, plan_rebase_resumable, PreRebase, RebaseOntoOptions, RebasePlan,
39    RebaseRequest,
40};
41use super::types::RefScope;
42use super::types::{
43    AppliedRewrite, ApplyOptions, CommitEdit, HistoryEdits, PlannedRefUpdate, RewriteOptions,
44    RewritePlan, RewrittenCommit,
45};
46use super::{commit_tree, load_commit};
47use crate::operation::Conflict;
48
49/// Options for [`start_rebase`].
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct StartRebaseOptions {
52    /// Upstream, optional onto, and branch tip to rebase.
53    pub request: RebaseRequest,
54    /// Replay and cherry-detection options.
55    pub rebase: RebaseOntoOptions,
56    /// Reflog identity and message used when the rebase finishes with [`apply_rewrite`].
57    pub apply: ApplyOptions,
58    /// Whether [`start_rebase`] runs the `pre-rebase` hook. [`continue_rebase`] and
59    /// [`skip_rebase`] never run it.
60    pub pre_rebase: PreRebase,
61}
62
63/// Snapshot when a resumable rebase stops on conflicts.
64#[derive(Debug, Clone, PartialEq, Eq)]
65#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
66pub struct RebaseStop {
67    /// Original commit that could not be replayed cleanly.
68    pub commit: ObjectId,
69    /// Conflicted paths and kinds for the current step.
70    pub conflicts: Vec<Conflict>,
71    /// Source commits fully replayed before the stop (oldest first).
72    pub done: Vec<ObjectId>,
73    /// Source commits not yet replayed (including `commit`).
74    pub remaining: Vec<ObjectId>,
75}
76
77/// Result of starting or advancing a rebase session.
78#[derive(Debug, Clone, PartialEq, Eq)]
79#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
80#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
81pub enum RebaseOutcome {
82    /// Every commit was replayed and ref updates were applied.
83    Done {
84        /// Ref movements performed on completion.
85        applied: AppliedRewrite,
86        /// Final rewrite plan (commits and ref updates).
87        plan: Box<RewritePlan>,
88        /// `HEAD` commit OIDs before this invocation and after completion, as `(old, new)`.
89        ///
90        /// For [`continue_rebase`] and [`skip_rebase`], `old` is the stopped, detached
91        /// `HEAD` at the start of that call and `new` is `applied.head` after branch
92        /// reattachment. `None` means the OID is unchanged, including when only reattaching
93        /// `HEAD`. Unlike [`RewritePlan::head_change`], this covers the entire session call.
94        head_change: Option<(ObjectId, ObjectId)>,
95    },
96    /// `--exec` commands ran at every replayed commit (from [`start_rebase_with_exec`], or a
97    /// resumed exec stop) and the atomic plan was applied.
98    ExecDone {
99        /// Ref movements performed on completion.
100        applied: AppliedRewrite,
101        /// Final rewrite plan; boxed to keep the outcome small.
102        plan: Box<RewritePlan>,
103        /// Invocations captured during this call ([`start_rebase_with_exec`], or a
104        /// continue/skip that resumed an exec stop).
105        exec: super::ExecReport,
106    },
107    /// Replay stopped on conflicts; `.git/rebase-merge/` and grit state were written.
108    Stopped(RebaseStop),
109}
110
111/// Result of [`abort_rebase`].
112#[derive(Debug, Clone, PartialEq, Eq, Default)]
113#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
114pub struct RebaseAbort {
115    /// Branches that something else moved while the rebase was stopped and that the abort
116    /// reset to their pre-rebase tip: `old` is the tip that was overwritten (still reachable
117    /// from the branch reflog), `new` the restored pre-rebase tip. Empty when the branch was
118    /// where the rebase left it.
119    pub overwritten: Vec<PlannedRefUpdate>,
120}
121
122/// Failures while driving a rebase session.
123#[derive(Debug, thiserror::Error)]
124#[non_exhaustive]
125pub enum RebaseSessionError {
126    /// No rebase session is in progress.
127    #[error("no rebase in progress")]
128    NoRebaseInProgress,
129
130    /// A rebase is already in progress.
131    #[error("rebase is already in progress")]
132    RebaseAlreadyInProgress,
133
134    /// A rebase that another tool (normally `git`) started is in progress: its state directory
135    /// exists without grit's plan, so only that tool can continue or skip it.
136    ///
137    /// [`abort_rebase`] also returns this when it cannot restore the rebase safely (an
138    /// autostash is recorded, or `orig-head` is unreadable).
139    #[error("a rebase started by git is in progress")]
140    ForeignRebaseInProgress,
141
142    /// The index still has unmerged entries.
143    #[error("cannot continue: unmerged paths remain in the index")]
144    UnmergedPaths,
145
146    /// Nothing to commit after resolving conflicts.
147    #[error("nothing to commit after conflict resolution")]
148    NothingToCommit,
149
150    /// `HEAD` is not the recorded conflict parent and is not a descendant of it, so grit
151    /// cannot tell which commit finished the stopped pick.
152    #[error(
153        "HEAD moved away from the conflict resolution parent (expected {expected}); save commits on a branch or reset before `grit rebase --continue`"
154    )]
155    ResolutionHeadChanged {
156        /// Detached replay parent written when the rebase stopped.
157        expected: ObjectId,
158    },
159
160    /// Branch rebase requires a symbolic `HEAD`.
161    #[error("rebase session requires HEAD on a branch")]
162    NotOnBranch,
163
164    /// The caller moved HEAD away from the recorded exec stop. Save new commits on
165    /// a branch, abort, and restart instead of silently discarding them on continue.
166    #[error("exec replay HEAD changed; expected {expected}; save new commits on a branch, then abort and restart the rebase")]
167    ExecHeadChanged {
168        /// Detached replay commit at the exec stop.
169        expected: ObjectId,
170    },
171
172    /// A ref the stopped exec rebase will move (the rebased branch or one of its planned
173    /// updates) was moved by something else, so the saved plan can no longer be applied.
174    /// Nothing was changed; [`abort_rebase`] restores the pre-rebase state.
175    #[error(
176        "ref {name} moved from {expected} to {} while the rebase was stopped; abort the rebase to restore it",
177        .actual.map_or_else(|| "(deleted)".to_owned(), |oid| oid.to_hex())
178    )]
179    StaleExecPlan {
180        /// Full ref name.
181        name: String,
182        /// Tip the saved plan expects.
183        expected: ObjectId,
184        /// Current tip, or `None` when the ref was deleted.
185        actual: Option<ObjectId>,
186    },
187
188    /// The rebased branch moved again while [`abort_rebase`] was resetting it; the rebase is
189    /// still in progress and the abort can be retried.
190    #[error("ref {name} moved while aborting the rebase; retry the abort")]
191    AbortRefMoved {
192        /// Full ref name.
193        name: String,
194    },
195
196    /// Grit's saved rebase plan records a value that is not a valid object id, so the session
197    /// cannot be continued, skipped, or aborted.
198    #[error("rebase state records an invalid object id: {value:?}")]
199    InvalidStateOid {
200        /// The recorded text that failed to parse.
201        value: String,
202    },
203
204    /// Underlying history rewrite failure.
205    #[error(transparent)]
206    History(#[from] HistoryError),
207
208    /// Repository I/O or ref failure.
209    #[error(transparent)]
210    Library(#[from] crate::error::Error),
211}
212
213impl RebaseSessionError {
214    /// Whether the rebase session was cancelled ([`crate::error::Error::Cancelled`]).
215    #[must_use]
216    pub fn is_cancelled(&self) -> bool {
217        match self {
218            Self::Library(e) => e.is_cancelled(),
219            Self::History(e) => e.is_cancelled(),
220            Self::NoRebaseInProgress
221            | Self::RebaseAlreadyInProgress
222            | Self::ForeignRebaseInProgress
223            | Self::UnmergedPaths
224            | Self::NothingToCommit
225            | Self::ResolutionHeadChanged { .. }
226            | Self::NotOnBranch
227            | Self::ExecHeadChanged { .. }
228            | Self::StaleExecPlan { .. }
229            | Self::AbortRefMoved { .. }
230            | Self::InvalidStateOid { .. } => false,
231        }
232    }
233}
234
235/// Start a resumable rebase of `opts.request.branch_tip` onto the effective onto commit.
236///
237/// Unless the branch is already up to date, runs `pre-rebase` before any commit is replayed
238/// (see [`StartRebaseOptions::pre_rebase`]); a refusal returns [`RebaseSessionError::History`]
239/// wrapping [`HistoryError::Library`] with [`Error::Hook`](crate::error::Error::Hook), and
240/// nothing is changed or written.
241///
242/// On a clean replay, applies the plan atomically and returns [`RebaseOutcome::Done`].
243/// On conflict, writes Git-compatible rebase state and returns [`RebaseOutcome::Stopped`]
244/// without moving the branch ref away from its pre-rebase tip. Both record the pre-rebase tip
245/// as `ORIG_HEAD`, which finishing or aborting the rebase leaves in place; an up-to-date branch
246/// leaves `ORIG_HEAD` untouched.
247///
248/// # Errors
249///
250/// Returns [`RebaseSessionError::RebaseAlreadyInProgress`] when grit or `git` already has a
251/// rebase in progress, [`RebaseSessionError::NotOnBranch`] when `HEAD` is not on a branch,
252/// and planning/apply failures otherwise. When the replay would stop on conflicts but the index
253/// or worktree has local changes (or an untracked file sits where the stop checks out a path),
254/// returns [`RebaseSessionError::Library`] wrapping
255/// [`Error::DirtyWorktree`](crate::error::Error::DirtyWorktree) or the overwrite error and
256/// leaves the repository untouched, because stopping would discard that work.
257///
258/// Work-tree updates report [`crate::progress::ProgressPhase::CheckingOutFiles`] to `progress`.
259/// `cancel` (or [`ProgressSink::should_cancel`]) is checked after planning and during the apply
260/// and stop checkouts; a cancel returns [`RebaseSessionError::Library`] wrapping
261/// [`Error::Cancelled`](crate::error::Error::Cancelled) (see [`RebaseSessionError::is_cancelled`]).
262/// The last cancel point is before a conflict stop is recorded, so a cancel leaves nothing
263/// changed; once the stop state and `HEAD` are written, its checkout runs to completion and the
264/// rebase stops with its conflicts as without a cancel.
265pub fn start_rebase(
266    repo: &Repository,
267    opts: &StartRebaseOptions,
268    progress: &mut dyn ProgressSink,
269    cancel: &dyn Cancel,
270) -> Result<RebaseOutcome, RebaseSessionError> {
271    super::ensure_rewrite_apply_ready(repo)?;
272    if let Some(active) = active_rebase(repo.git_dir()) {
273        return Err(match active {
274            ActiveRebase::Foreign(_) => RebaseSessionError::ForeignRebaseInProgress,
275            ActiveRebase::Exec | ActiveRebase::Grit => RebaseSessionError::RebaseAlreadyInProgress,
276        });
277    }
278    let (head_ref, orig_head) = branch_head(repo)?;
279    let plan = plan_rebase_resumable(repo, &opts.request, &opts.rebase, &opts.pre_rebase)?;
280    poll_cancel(progress, cancel).map_err(map_lib)?;
281    match plan {
282        Err(RebasePlan::UpToDate) => Ok(up_to_date_outcome(orig_head)),
283        Err(RebasePlan::Plan(_) | RebasePlan::Conflict { .. }) => Err(HistoryError::Planning {
284            message: "unexpected non-resumable rebase plan".into(),
285        }
286        .into()),
287        Ok(ResumablePlanOutcome::Complete(plan)) => {
288            let applied = apply_rewrite(repo, &plan, &opts.apply, progress, cancel)?;
289            write_orig_head(repo, orig_head).map_err(map_lib)?;
290            Ok(RebaseOutcome::Done {
291                head_change: (orig_head != applied.head).then_some((orig_head, applied.head)),
292                applied,
293                plan,
294            })
295        }
296        Ok(ResumablePlanOutcome::Partial(partial)) => {
297            let orig_tree = commit_tree(repo, orig_head)?;
298            let stop_tree = commit_tree(repo, partial.conflict.replayed_parent)?;
299            ensure_safe_for_rebase_stop(repo, &orig_tree, &stop_tree)?;
300            let stop = install_stopped_rebase(
301                repo,
302                &head_ref,
303                orig_head,
304                &opts.request,
305                &opts.rebase,
306                &opts.apply,
307                *partial,
308                Vec::new(),
309                progress,
310                cancel,
311            )?;
312            write_orig_head(repo, orig_head).map_err(map_lib)?;
313            if opts.apply.hooks.enabled() {
314                let onto = opts.request.onto.unwrap_or(opts.request.upstream);
315                run_post_checkout(repo, Some(orig_head), onto, true);
316            }
317            Ok(RebaseOutcome::Stopped(stop))
318        }
319    }
320}
321
322/// Rebase like [`start_rebase`], running `spec.commands` at each replayed commit before any
323/// ref moves (`grit rebase --exec`).
324///
325/// [`StartRebaseOptions::pre_rebase`] runs before anything is replayed, as for [`start_rebase`].
326///
327/// The commands run through [`verify_rebase_with_exec`](super::verify_rebase_with_exec); when
328/// they all succeed, the (possibly amended) plan is applied atomically and the pre-rebase tip
329/// `opts.request.branch_tip` is recorded as `ORIG_HEAD`. When there is nothing to replay and
330/// no `--exec` commands, returns [`RebaseOutcome::Done`] with no `head_change` and leaves
331/// `ORIG_HEAD` untouched. With `--exec`, commits in the rebase range still run the commands
332/// even when the branch is already based on the upstream (matching `git rebase -x`). A replay
333/// conflict before any command runs stops like [`start_rebase`] with conflict markers and a
334/// resumable grit session; [`continue_rebase`] runs the exec commands at the resolved commit.
335///
336/// # Errors
337///
338/// Returns the `--exec` errors of
339/// [`verify_rebase_with_exec`](super::verify_rebase_with_exec)
340/// ([`HistoryError::ExecFailed`], [`HistoryError::ExecDirty`],
341/// [`HistoryError::ExecConflict`], …). The two that save a resumable stop
342/// ([`HistoryError::ExecDirty`] and [`HistoryError::ExecConflict`]) record `ORIG_HEAD` first;
343/// the others roll back and leave it alone. Planning, work-tree and apply failures propagate
344/// as for [`start_rebase`].
345pub fn start_rebase_with_exec(
346    repo: &Repository,
347    opts: &StartRebaseOptions,
348    spec: &super::ExecSpec,
349    runner: &dyn crate::command_runner::CommandRunner,
350    progress: &mut dyn ProgressSink,
351    cancel: &dyn Cancel,
352) -> Result<RebaseOutcome, RebaseSessionError> {
353    super::ensure_rewrite_apply_ready(repo)?;
354    if let Some(active) = active_rebase(repo.git_dir()) {
355        return Err(match active {
356            ActiveRebase::Foreign(_) => RebaseSessionError::ForeignRebaseInProgress,
357            ActiveRebase::Exec | ActiveRebase::Grit => RebaseSessionError::RebaseAlreadyInProgress,
358        });
359    }
360    let (head_ref, orig_head) = branch_head(repo)?;
361    let plan = plan_rebase_resumable(repo, &opts.request, &opts.rebase, &opts.pre_rebase)?;
362    poll_cancel(progress, cancel).map_err(map_lib)?;
363    match plan {
364        Err(RebasePlan::UpToDate) => {
365            if spec.commands.is_empty() {
366                return Ok(up_to_date_outcome(orig_head));
367            }
368            let fork =
369                super::rebase::rebase_fork(repo, opts.request.upstream, opts.request.branch_tip)?;
370            let effective_onto = opts.request.onto.unwrap_or(opts.request.upstream);
371            if opts.request.branch_tip == effective_onto {
372                return Ok(up_to_date_outcome(orig_head));
373            }
374            let range =
375                super::rebase::commits_in_rebase_range(repo, fork, &[opts.request.branch_tip])?;
376            if range.is_empty() {
377                return Ok(up_to_date_outcome(orig_head));
378            }
379            opts.pre_rebase.run(repo)?;
380            let mut plan = super::rebase::plan_rebase_identity_exec(
381                repo,
382                fork,
383                effective_onto,
384                &range,
385                &opts.rebase,
386            )?;
387            super::apply::verify_rewrite_plan(repo, &plan)?;
388            let exec = match super::verify_rebase_with_exec(
389                repo,
390                &mut plan,
391                spec,
392                &opts.rebase.rewrite,
393                runner,
394            ) {
395                Ok(exec) => exec,
396                Err(
397                    stop @ (HistoryError::ExecDirty { .. } | HistoryError::ExecConflict { .. }),
398                ) => {
399                    write_orig_head(repo, orig_head).map_err(map_lib)?;
400                    return Err(stop.into());
401                }
402                Err(err) => return Err(err.into()),
403            };
404            let applied = apply_rewrite(repo, &plan, &opts.apply, progress, cancel)?;
405            write_orig_head(repo, orig_head).map_err(map_lib)?;
406            Ok(RebaseOutcome::ExecDone {
407                applied,
408                plan: Box::new(plan),
409                exec,
410            })
411        }
412        Err(RebasePlan::Plan(_) | RebasePlan::Conflict { .. }) => Err(HistoryError::Planning {
413            message: "unexpected non-resumable rebase plan".into(),
414        }
415        .into()),
416        Ok(ResumablePlanOutcome::Partial(partial)) => {
417            let orig_tree = commit_tree(repo, orig_head)?;
418            let stop_tree = commit_tree(repo, partial.conflict.replayed_parent)?;
419            ensure_safe_for_rebase_stop(repo, &orig_tree, &stop_tree)?;
420            let stop = install_stopped_rebase(
421                repo,
422                &head_ref,
423                orig_head,
424                &opts.request,
425                &opts.rebase,
426                &opts.apply,
427                *partial,
428                spec.commands.clone(),
429                progress,
430                cancel,
431            )?;
432            write_orig_head(repo, orig_head).map_err(map_lib)?;
433            if opts.apply.hooks.enabled() {
434                let onto = opts.request.onto.unwrap_or(opts.request.upstream);
435                run_post_checkout(repo, Some(orig_head), onto, true);
436            }
437            Ok(RebaseOutcome::Stopped(stop))
438        }
439        Ok(ResumablePlanOutcome::Complete(plan)) => {
440            let mut plan = *plan;
441            super::apply::verify_rewrite_plan(repo, &plan)?;
442            let exec = match super::verify_rebase_with_exec(
443                repo,
444                &mut plan,
445                spec,
446                &opts.rebase.rewrite,
447                runner,
448            ) {
449                Ok(exec) => exec,
450                Err(
451                    stop @ (HistoryError::ExecDirty { .. } | HistoryError::ExecConflict { .. }),
452                ) => {
453                    write_orig_head(repo, orig_head).map_err(map_lib)?;
454                    return Err(stop.into());
455                }
456                Err(err) => return Err(err.into()),
457            };
458            let applied = apply_rewrite(repo, &plan, &opts.apply, progress, cancel)?;
459            write_orig_head(repo, orig_head).map_err(map_lib)?;
460            Ok(RebaseOutcome::ExecDone {
461                applied,
462                plan: Box::new(plan),
463                exec,
464            })
465        }
466    }
467}
468
469fn up_to_date_outcome(head: ObjectId) -> RebaseOutcome {
470    RebaseOutcome::Done {
471        head_change: None,
472        applied: AppliedRewrite {
473            ref_updates: Vec::new(),
474            head,
475            hooks_run: Vec::new(),
476        },
477        plan: Box::new(RewritePlan {
478            commits: Vec::new(),
479            ref_updates: Vec::new(),
480            head: super::types::HeadEffect::default(),
481        }),
482    }
483}
484
485/// When `grit-plan` predates `stopped_head`, record the detached replay parent before
486/// `grit commit` moves `HEAD` to the resolution commit.
487pub(crate) fn persist_rebase_stopped_head_if_missing(
488    repo: &Repository,
489) -> Result<(), RebaseSessionError> {
490    if !crate::state::grit_rebase_conflict_stop_active(repo.git_dir()) {
491        return Ok(());
492    }
493    let path = repo.git_dir().join("rebase-merge/grit-plan");
494    let raw = fs::read_to_string(&path).map_err(|_| RebaseSessionError::NoRebaseInProgress)?;
495    let mut parsed: PersistedRebaseState =
496        serde_json::from_str(&raw).map_err(|_| RebaseSessionError::NoRebaseInProgress)?;
497    if parsed.stopped_head.is_some() {
498        return Ok(());
499    }
500    let head = resolve_head_oid(repo)?;
501    parsed.stopped_head = Some(oid_hex(&head));
502    write_persisted_state(repo.git_dir(), &parsed)
503}
504
505fn replay_base_parent(state: &LoadedState, oid: ObjectId) -> bool {
506    oid == state.onto || state.mapping.values().any(|mapped| *mapped == oid)
507}
508
509fn stopped_head_for_continue(
510    state: &LoadedState,
511    repo: &Repository,
512) -> Result<ObjectId, RebaseSessionError> {
513    if let Some(oid) = state.stopped_head {
514        return Ok(oid);
515    }
516    let head = resolve_head_oid(repo)?;
517    if replay_base_parent(state, head) {
518        return Ok(head);
519    }
520    let head_commit = load_commit(repo, head)?;
521    if head_commit.parents.len() == 1 {
522        if let Some(&parent) = head_commit.parents.first() {
523            if replay_base_parent(state, parent) {
524                return Ok(parent);
525            }
526        }
527    } else {
528        for &parent in &head_commit.parents {
529            if replay_base_parent(state, parent) {
530                return Ok(parent);
531            }
532        }
533    }
534    Err(RebaseSessionError::NoRebaseInProgress)
535}
536
537fn is_first_parent_descendant_of(
538    repo: &Repository,
539    ancestor: ObjectId,
540    tip: ObjectId,
541) -> Result<bool, RebaseSessionError> {
542    if tip == ancestor {
543        return Ok(false);
544    }
545    let mut current = Some(tip);
546    while let Some(oid) = current {
547        let parents = commit_parents(repo, oid).map_err(RebaseSessionError::from)?;
548        let Some(first) = parents.first().copied() else {
549            break;
550        };
551        if first == ancestor {
552            return Ok(true);
553        }
554        current = Some(first);
555    }
556    Ok(false)
557}
558
559fn run_exec_after_resolution(
560    repo: &Repository,
561    state: &LoadedState,
562    head_oid: ObjectId,
563) -> Result<ObjectId, RebaseSessionError> {
564    if state.exec_commands.is_empty() {
565        return Ok(head_oid);
566    }
567    let spec = super::ExecSpec {
568        commands: state.exec_commands.clone(),
569    };
570    let runner = repo.command_runner();
571    let (tip, _) = super::exec::run_exec_at_tip(repo, &spec, runner.as_ref(), head_oid)?;
572    Ok(tip)
573}
574
575fn advance_after_user_resolution_commit(
576    repo: &Repository,
577    state: &LoadedState,
578    opts: &StartRebaseOptions,
579    current: ObjectId,
580    head_oid: ObjectId,
581    progress: &mut dyn ProgressSink,
582    cancel: &dyn Cancel,
583) -> Result<RebaseOutcome, RebaseSessionError> {
584    let head_oid = run_exec_after_resolution(repo, state, head_oid)?;
585    let current_data = load_commit(repo, current)?;
586    let mut mapping = state.mapping.clone();
587    mapping.insert(current, head_oid);
588    let mut rewritten = state.rewritten.clone();
589    rewritten.push(RewrittenCommit {
590        old: current,
591        new: Some(head_oid),
592        change_id: logical_change_id_for_commit(repo, head_oid).map_err(map_lib)?,
593        subject: super::subject::subject_for_rewrite_row(repo, &current_data, Some(head_oid))
594            .map_err(RebaseSessionError::from)?,
595        reused: false,
596    });
597    let resume_order: Vec<ObjectId> = replay_order_for_resume(state, current)
598        .into_iter()
599        .filter(|commit| *commit != current)
600        .collect();
601    let mut edits = state.edits.clone();
602    edits.retain(|commit, _| resume_order.contains(commit));
603    let rewrite = replan_rewrite_options(state);
604    let outcome = plan_rewrite_with_order_resumable_seeded(
605        repo,
606        &edits,
607        &rewrite,
608        Some(&resume_order),
609        state.flatten_leaf_merges.then_some(state.onto),
610        state.rebase.favor,
611        Some((mapping, rewritten)),
612        Some(state.scoped_refs.clone()),
613    )?;
614    advance_after_plan(
615        repo,
616        state,
617        &opts.apply,
618        outcome,
619        head_oid,
620        progress,
621        cancel,
622    )
623}
624
625/// Finish the current conflicted commit and replay remaining commits.
626///
627/// For an exec stop, requires a clean index/worktree at the recorded replay HEAD,
628/// then resumes at the next command (the successful dirty command is not rerun).
629/// Save exec edits with a stash before continuing. A moved HEAD is rejected;
630/// save new commits on a branch, abort, and restart in that case. For an exec replay
631/// conflict ([`HistoryError::ExecConflict`]), commits the staged resolution and runs the
632/// commands at it. Later replays and new commits use the rewrite options saved when the
633/// exec stop was recorded, not `opts.rebase.rewrite`.
634///
635/// # Errors
636///
637/// Returns [`RebaseSessionError::UnmergedPaths`] when the index still records conflicts,
638/// [`RebaseSessionError::ForeignRebaseInProgress`] when `git` started the rebase,
639/// [`RebaseSessionError::NoRebaseInProgress`] when no rebase is in progress, and, for an
640/// exec stop, [`RebaseSessionError::StaleExecPlan`] (changing nothing) when the rebased branch
641/// or a ref the plan updates was moved during the stop.
642/// Cancellation follows [`start_rebase`]: a cancel before the replayed commits are applied
643/// keeps the rebase stopped where it was.
644pub fn continue_rebase(
645    repo: &Repository,
646    opts: &StartRebaseOptions,
647    progress: &mut dyn ProgressSink,
648    cancel: &dyn Cancel,
649) -> Result<RebaseOutcome, RebaseSessionError> {
650    if matches!(active_rebase(repo.git_dir()), Some(ActiveRebase::Exec)) {
651        return super::exec::resume_exec_rebase(repo, &opts.apply, false, progress, cancel);
652    }
653    super::ensure_rewrite_apply_ready(repo)?;
654    let state = load_persisted_state(repo.git_dir())?;
655    if repo.load_index()?.has_unmerged_entries() {
656        return Err(RebaseSessionError::UnmergedPaths);
657    }
658    let current = state
659        .stopped_commit
660        .or_else(|| state.remaining.first().copied())
661        .ok_or(RebaseSessionError::NoRebaseInProgress)?;
662    let new_tree = {
663        let mut index = repo.load_index()?;
664        write_tree_update_index(repo.odb(), &mut index, "", WriteTreeFlags::silent())
665            .map_err(map_lib)?
666    };
667    let head_oid = resolve_head_oid(repo)?;
668    let head_tree = commit_tree(repo, head_oid)?;
669    if new_tree == head_tree {
670        let stopped_head = stopped_head_for_continue(&state, repo)?;
671        if head_oid == stopped_head {
672            return Err(RebaseSessionError::NothingToCommit);
673        }
674        if is_first_parent_descendant_of(repo, stopped_head, head_oid)? {
675            ensure_worktree_clean_for_rebase_exec(repo).map_err(map_lib)?;
676            return advance_after_user_resolution_commit(
677                repo, &state, opts, current, head_oid, progress, cancel,
678            );
679        }
680        return Err(RebaseSessionError::ResolutionHeadChanged {
681            expected: stopped_head,
682        });
683    }
684
685    let original_parents = commit_parents(repo, current).map_err(RebaseSessionError::from)?;
686    let parents = if original_parents.len() <= 1 {
687        vec![head_oid]
688    } else {
689        let mut mapped = vec![head_oid];
690        mapped.extend(
691            original_parents[1..]
692                .iter()
693                .map(|parent| state.mapping.get(parent).copied().unwrap_or(*parent)),
694        );
695        mapped
696    };
697
698    // Merge-preserving replay with side branches before mainline needs the resolution
699    // recorded as a real commit before resuming; a single-commit stop can plan the staged
700    // tree directly (matches the linear `git rebase --continue` path).
701    if state.remaining.len() > 1 {
702        let stopped_head = stopped_head_for_continue(&state, repo)?;
703        if head_oid != stopped_head {
704            return Err(RebaseSessionError::ResolutionHeadChanged {
705                expected: stopped_head,
706            });
707        }
708        let resolved =
709            write_rebase_staged_resolution(repo, &state.rewrite, current, new_tree, &parents)
710                .map_err(RebaseSessionError::from)?;
711        repo.odb().flush_for_publish().map_err(map_lib)?;
712        refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), "HEAD", &resolved)
713            .map_err(map_lib)?;
714        ensure_worktree_clean_for_rebase_exec(repo).map_err(map_lib)?;
715        return advance_after_user_resolution_commit(
716            repo, &state, opts, current, resolved, progress, cancel,
717        );
718    }
719
720    if !state.exec_commands.is_empty() && new_tree != head_tree {
721        let stopped_head = stopped_head_for_continue(&state, repo)?;
722        if head_oid != stopped_head {
723            return Err(RebaseSessionError::ResolutionHeadChanged {
724                expected: stopped_head,
725            });
726        }
727        let resolved =
728            write_rebase_staged_resolution(repo, &state.rewrite, current, new_tree, &parents)
729                .map_err(RebaseSessionError::from)?;
730        repo.odb().flush_for_publish().map_err(map_lib)?;
731        refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), "HEAD", &resolved)
732            .map_err(map_lib)?;
733        ensure_worktree_clean_for_rebase_exec(repo).map_err(map_lib)?;
734        return advance_after_user_resolution_commit(
735            repo, &state, opts, current, resolved, progress, cancel,
736        );
737    }
738
739    let resume_order = replay_order_for_resume(&state, current);
740    let mut edits = state.edits.clone();
741    edits.insert(
742        current,
743        CommitEdit::Rewrite {
744            tree: Some(new_tree),
745            message: None,
746            author: None,
747            parents: Some(parents),
748        },
749    );
750    edits.retain(|commit, _| resume_order.contains(commit));
751
752    let seed = (state.mapping.clone(), state.rewritten.clone());
753    let rewrite = replan_rewrite_options(&state);
754    let outcome = plan_rewrite_with_order_resumable_seeded(
755        repo,
756        &edits,
757        &rewrite,
758        Some(&resume_order),
759        state.flatten_leaf_merges.then_some(state.onto),
760        state.rebase.favor,
761        Some(seed),
762        Some(state.scoped_refs.clone()),
763    )?;
764    advance_after_plan(
765        repo,
766        &state,
767        &opts.apply,
768        outcome,
769        head_oid,
770        progress,
771        cancel,
772    )
773}
774
775/// Skip the current todo commit and replay the rest.
776///
777/// For an exec stop, requires the edits to be saved or removed first, then skips
778/// remaining commands at that replay commit; the already replayed commit is kept.
779/// For an exec replay conflict, drops the conflicted commit and runs that commit's
780/// commands at the current `HEAD`, as Git's todo list would.
781///
782/// # Errors
783///
784/// Returns [`RebaseSessionError::ForeignRebaseInProgress`] when `git` started the rebase,
785/// [`RebaseSessionError::NoRebaseInProgress`] when no rebase is in progress,
786/// [`RebaseSessionError::StaleExecPlan`] as for [`continue_rebase`], and
787/// planning/apply failures otherwise. Cancellation follows [`continue_rebase`].
788pub fn skip_rebase(
789    repo: &Repository,
790    opts: &StartRebaseOptions,
791    progress: &mut dyn ProgressSink,
792    cancel: &dyn Cancel,
793) -> Result<RebaseOutcome, RebaseSessionError> {
794    if matches!(active_rebase(repo.git_dir()), Some(ActiveRebase::Exec)) {
795        return super::exec::resume_exec_rebase(repo, &opts.apply, true, progress, cancel);
796    }
797    let state = load_persisted_state(repo.git_dir())?;
798    let current = state
799        .remaining
800        .first()
801        .copied()
802        .ok_or(RebaseSessionError::NoRebaseInProgress)?;
803    let head_oid = resolve_head_oid(repo)?;
804    reset_index_worktree_to_head(repo, head_oid, progress, cancel)?;
805
806    let replayed_parent = run_exec_after_resolution(repo, &state, head_oid)?;
807    let mut mapping = state.mapping.clone();
808    mapping.insert(current, replayed_parent);
809    let mut rewritten = state.rewritten.clone();
810    rewritten.push(RewrittenCommit {
811        old: current,
812        new: None,
813        change_id: None,
814        subject: None,
815        reused: false,
816    });
817
818    let resume_order: Vec<ObjectId> = state
819        .remaining
820        .iter()
821        .copied()
822        .filter(|c| *c != current)
823        .collect();
824    let mut edits = state.edits.clone();
825    edits.retain(|commit, _| resume_order.contains(commit));
826    let rewrite = replan_rewrite_options(&state);
827    let outcome = plan_rewrite_with_order_resumable_seeded(
828        repo,
829        &edits,
830        &rewrite,
831        Some(&resume_order),
832        state.flatten_leaf_merges.then_some(state.onto),
833        state.rebase.favor,
834        Some((mapping, rewritten)),
835        Some(state.scoped_refs.clone()),
836    )?;
837    advance_after_plan(
838        repo,
839        &state,
840        &opts.apply,
841        outcome,
842        head_oid,
843        progress,
844        cancel,
845    )
846}
847
848/// Cancel the in-progress rebase and restore the branch to `orig-head`.
849///
850/// Also cancels a rebase that `git` started (`rebase-merge/` or `rebase-apply/` without grit's
851/// plan): `HEAD` is re-attached to the rebased branch (or detached, when the rebase started
852/// detached), the index and worktree are reset to `orig-head`, and the state directory is
853/// removed. Either way, unresolved rerere records for the abandoned conflict are cleared
854/// (`MERGE_RR`), as `git rebase --abort` does.
855///
856/// The branch is restored to `orig-head` even when something else (another worktree, a
857/// `git branch -f`, an amend on the checked-out branch) moved it while the rebase was stopped;
858/// such movements are listed in [`RebaseAbort::overwritten`] and logged as `rebase (abort)`
859/// in the branch reflog with `identity`. `identity` also signs the reflog entries of the reset.
860///
861/// # Errors
862///
863/// Returns [`RebaseSessionError::NoRebaseInProgress`] when no rebase is in progress,
864/// [`RebaseSessionError::ForeignRebaseInProgress`] when a git-started rebase recorded an
865/// autostash or has no readable `orig-head` (finish those with `git rebase --abort`), and
866/// [`RebaseSessionError::AbortRefMoved`] when, for an exec stop, the branch moves again between
867/// reading and resetting it (nothing is restored and the abort can be retried).
868pub fn abort_rebase(
869    repo: &Repository,
870    identity: &crate::ident::Signature,
871) -> Result<RebaseAbort, RebaseSessionError> {
872    identity
873        .validate()
874        .map_err(|err| map_lib(crate::error::Error::InvalidSignature(err)))?;
875    let had_journal = super::rewrite_apply_journal_active(repo.git_dir());
876    if had_journal {
877        super::abort_interrupted_rewrite_apply(repo)?;
878    }
879    match active_rebase(repo.git_dir()) {
880        None => {
881            if had_journal {
882                Ok(RebaseAbort::default())
883            } else {
884                Err(RebaseSessionError::NoRebaseInProgress)
885            }
886        }
887        Some(ActiveRebase::Foreign(dir)) => abort_foreign_rebase(repo, &dir, identity),
888        Some(ActiveRebase::Exec) => super::exec::abort_exec_rebase(repo, identity),
889        Some(ActiveRebase::Grit) => {
890            let state = load_persisted_state(repo.git_dir())?;
891            crate::rerere::rerere_clear(repo.git_dir()).map_err(map_lib)?;
892            let abort = hard_reset_branch(repo, &state.head_ref, state.orig_head, identity)?;
893            clear_rebase_state(repo.git_dir())?;
894            Ok(abort)
895        }
896    }
897}
898
899fn abort_foreign_rebase(
900    repo: &Repository,
901    state_dir: &Path,
902    identity: &crate::ident::Signature,
903) -> Result<RebaseAbort, RebaseSessionError> {
904    if state_dir.join("autostash").exists() {
905        return Err(RebaseSessionError::ForeignRebaseInProgress);
906    }
907    let orig_head = first_line(&state_dir.join("orig-head"))
908        .and_then(|hex| ObjectId::from_hex(&hex).ok())
909        .ok_or(RebaseSessionError::ForeignRebaseInProgress)?;
910    crate::rerere::rerere_clear(repo.git_dir()).map_err(map_lib)?;
911    let abort =
912        match first_line(&state_dir.join("head-name")).filter(|name| name.starts_with("refs/")) {
913            Some(head_ref) => hard_reset_branch(repo, &head_ref, orig_head, identity)?,
914            None => {
915                hard_reset_head(repo, orig_head, identity)?;
916                RebaseAbort::default()
917            }
918        };
919    for name in ["REBASE_HEAD", "AUTO_MERGE", "MERGE_MSG"] {
920        remove_if_exists(&repo.git_dir().join(name))?;
921    }
922    fs::remove_dir_all(state_dir).map_err(map_lib)?;
923    Ok(abort)
924}
925
926/// Which tool owns the rebase state under the git directory.
927enum ActiveRebase {
928    /// Successful exec left tracked edits at a replay commit.
929    Exec,
930    /// `rebase-merge/grit-plan` exists.
931    Grit,
932    /// `rebase-merge/` or `rebase-apply/` (not an `am`) without grit's plan.
933    Foreign(std::path::PathBuf),
934}
935
936fn active_rebase(git_dir: &Path) -> Option<ActiveRebase> {
937    let merge = git_dir.join("rebase-merge");
938    if merge.join("grit-exec").is_file() {
939        return Some(ActiveRebase::Exec);
940    }
941    if merge.join("grit-plan").is_file() {
942        return Some(ActiveRebase::Grit);
943    }
944    if merge.is_dir() {
945        return Some(ActiveRebase::Foreign(merge));
946    }
947    let apply = git_dir.join("rebase-apply");
948    (apply.is_dir() && !apply.join("applying").exists()).then_some(ActiveRebase::Foreign(apply))
949}
950
951fn first_line(path: &Path) -> Option<String> {
952    let raw = fs::read_to_string(path).ok()?;
953    let line = raw.lines().next()?.trim();
954    (!line.is_empty()).then(|| line.to_owned())
955}
956
957fn remove_if_exists(path: &Path) -> Result<(), RebaseSessionError> {
958    match fs::remove_file(path) {
959        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(map_lib(e)),
960        _ => Ok(()),
961    }
962}
963
964// --- persisted session ---
965
966#[derive(Debug, Clone, Serialize, Deserialize)]
967struct PersistedRebaseState {
968    mapping: HashMap<String, String>,
969    rewritten: Vec<PersistedRewrittenRow>,
970    edits: Vec<PersistedEdit>,
971    rewrite: RewriteOptions,
972    rebase: RebaseOntoOptions,
973    apply: ApplyOptions,
974    replay_order: Vec<String>,
975    remaining: Vec<String>,
976    done: Vec<String>,
977    fork: String,
978    onto: String,
979    upstream: String,
980    head_ref: String,
981    orig_head: String,
982    branch_tip: String,
983    flatten_leaf_merges: bool,
984    scoped_refs: Vec<(String, String)>,
985    /// Detached `HEAD` at the conflict stop (`conflict.replayed_parent`).
986    #[serde(default)]
987    stopped_head: Option<String>,
988    /// Original commit being replayed when the rebase stopped (`partial.conflict.commit`).
989    #[serde(default)]
990    stopped_commit: Option<String>,
991    /// `--exec` commands to run at each replayed commit when resuming (empty when absent).
992    #[serde(default)]
993    exec_commands: Vec<String>,
994}
995
996#[derive(Debug, Clone, Serialize, Deserialize)]
997struct PersistedRewrittenRow {
998    old: String,
999    new: Option<String>,
1000    #[serde(default)]
1001    reused: bool,
1002}
1003
1004#[derive(Debug, Clone, Serialize, Deserialize)]
1005enum PersistedEdit {
1006    Reparent {
1007        commit: String,
1008        parent: String,
1009    },
1010    Reuse {
1011        commit: String,
1012        existing: String,
1013        /// Absent in sessions saved before skipped commits carried their continuation parent.
1014        #[serde(default)]
1015        parent: Option<String>,
1016    },
1017    Drop {
1018        commit: String,
1019    },
1020    Rewrite {
1021        commit: String,
1022        tree: Option<String>,
1023        message: Option<String>,
1024        author: Option<String>,
1025    },
1026}
1027
1028fn oid_hex(oid: &ObjectId) -> String {
1029    oid.to_hex()
1030}
1031
1032fn parse_oid(hex: &str) -> Result<ObjectId, RebaseSessionError> {
1033    ObjectId::from_hex(hex).map_err(|_| RebaseSessionError::InvalidStateOid {
1034        value: hex.to_owned(),
1035    })
1036}
1037
1038fn edits_to_persist(edits: &HistoryEdits) -> Vec<PersistedEdit> {
1039    edits
1040        .iter()
1041        .map(|(commit, edit)| match edit {
1042            CommitEdit::Reparent { parent } => PersistedEdit::Reparent {
1043                commit: oid_hex(commit),
1044                parent: oid_hex(parent),
1045            },
1046            CommitEdit::Reuse { existing, parent } => PersistedEdit::Reuse {
1047                commit: oid_hex(commit),
1048                existing: oid_hex(existing),
1049                parent: Some(oid_hex(parent)),
1050            },
1051            CommitEdit::Drop => PersistedEdit::Drop {
1052                commit: oid_hex(commit),
1053            },
1054            CommitEdit::Rewrite {
1055                tree,
1056                message,
1057                author,
1058                parents: _,
1059            } => PersistedEdit::Rewrite {
1060                commit: oid_hex(commit),
1061                tree: tree.as_ref().map(oid_hex),
1062                message: message.clone(),
1063                author: author.clone(),
1064            },
1065            CommitEdit::InsertBefore(_) | CommitEdit::SplitBefore { .. } => PersistedEdit::Drop {
1066                commit: oid_hex(commit),
1067            },
1068        })
1069        .collect()
1070}
1071
1072fn edits_from_persist(rows: &[PersistedEdit]) -> Result<HistoryEdits, RebaseSessionError> {
1073    let mut edits = HistoryEdits::new();
1074    for row in rows {
1075        match row {
1076            PersistedEdit::Reparent { commit, parent } => {
1077                edits.insert(
1078                    parse_oid(commit)?,
1079                    CommitEdit::Reparent {
1080                        parent: parse_oid(parent)?,
1081                    },
1082                );
1083            }
1084            PersistedEdit::Reuse {
1085                commit,
1086                existing,
1087                parent,
1088            } => {
1089                let existing = parse_oid(existing)?;
1090                let parent = parent.as_deref().map(parse_oid).transpose()?;
1091                edits.insert(
1092                    parse_oid(commit)?,
1093                    CommitEdit::Reuse {
1094                        existing,
1095                        parent: parent.unwrap_or(existing),
1096                    },
1097                );
1098            }
1099            PersistedEdit::Drop { commit } => {
1100                edits.insert(parse_oid(commit)?, CommitEdit::Drop);
1101            }
1102            PersistedEdit::Rewrite {
1103                commit,
1104                tree,
1105                message,
1106                author,
1107            } => {
1108                edits.insert(
1109                    parse_oid(commit)?,
1110                    CommitEdit::Rewrite {
1111                        tree: tree.as_deref().map(parse_oid).transpose()?,
1112                        message: message.clone(),
1113                        author: author.clone(),
1114                        parents: None,
1115                    },
1116                );
1117            }
1118        }
1119    }
1120    Ok(edits)
1121}
1122
1123fn mapping_to_persist(map: &HashMap<ObjectId, ObjectId>) -> HashMap<String, String> {
1124    map.iter().map(|(k, v)| (oid_hex(k), oid_hex(v))).collect()
1125}
1126
1127fn mapping_from_persist(
1128    map: &HashMap<String, String>,
1129) -> Result<HashMap<ObjectId, ObjectId>, RebaseSessionError> {
1130    map.iter()
1131        .map(|(k, v)| Ok((parse_oid(k)?, parse_oid(v)?)))
1132        .collect()
1133}
1134
1135/// Commits still to replay when resuming after a stop (not the full initial replay order).
1136fn replay_order_for_resume(state: &LoadedState, current: ObjectId) -> Vec<ObjectId> {
1137    if let Some(pos) = state.replay_order.iter().position(|c| *c == current) {
1138        return state.replay_order[pos..].to_vec();
1139    }
1140    let mut order = if state.remaining.is_empty() {
1141        state.replay_order.clone()
1142    } else {
1143        state.remaining.clone()
1144    };
1145    if !order.contains(&current) {
1146        order.insert(0, current);
1147    } else if order.first() != Some(&current) {
1148        order.retain(|c| *c != current);
1149        order.insert(0, current);
1150    }
1151    order
1152}
1153
1154#[allow(clippy::too_many_arguments)]
1155fn state_from_partial(
1156    partial: &super::plan::PartialRewritePlan,
1157    head_ref: &str,
1158    orig_head: ObjectId,
1159    request: &RebaseRequest,
1160    rebase: &RebaseOntoOptions,
1161    apply: &ApplyOptions,
1162    effective_onto: ObjectId,
1163    fork: ObjectId,
1164) -> PersistedRebaseState {
1165    let conflict = partial.conflict.commit;
1166    let stop_idx = partial
1167        .commits_to_replay
1168        .iter()
1169        .position(|c| *c == conflict)
1170        .unwrap_or(0);
1171    let done: Vec<ObjectId> = partial.commits_to_replay[..stop_idx]
1172        .iter()
1173        .copied()
1174        .filter(|c| partial.mapping.contains_key(c))
1175        .collect();
1176    let remaining: Vec<ObjectId> = partial.commits_to_replay[stop_idx..].to_vec();
1177    PersistedRebaseState {
1178        mapping: mapping_to_persist(&partial.mapping),
1179        rewritten: partial
1180            .rewritten
1181            .iter()
1182            .map(|r| PersistedRewrittenRow {
1183                old: oid_hex(&r.old),
1184                new: r.new.as_ref().map(oid_hex),
1185                reused: r.reused,
1186            })
1187            .collect(),
1188        edits: edits_to_persist(&partial.edits),
1189        rewrite: partial.opts.clone(),
1190        rebase: rebase.clone(),
1191        apply: apply.clone(),
1192        replay_order: partial.commits_to_replay.iter().map(oid_hex).collect(),
1193        remaining: remaining.iter().map(oid_hex).collect(),
1194        done: done.iter().map(oid_hex).collect(),
1195        fork: oid_hex(&fork),
1196        onto: oid_hex(&effective_onto),
1197        upstream: oid_hex(&request.upstream),
1198        head_ref: head_ref.to_owned(),
1199        orig_head: oid_hex(&orig_head),
1200        branch_tip: oid_hex(&request.branch_tip),
1201        flatten_leaf_merges: partial.linearize_onto.is_some(),
1202        scoped_refs: partial
1203            .scoped_refs
1204            .iter()
1205            .map(|(n, o)| (n.clone(), oid_hex(o)))
1206            .collect(),
1207        stopped_head: None,
1208        stopped_commit: Some(oid_hex(&conflict)),
1209        exec_commands: Vec::new(),
1210    }
1211}
1212
1213struct LoadedState {
1214    mapping: HashMap<ObjectId, ObjectId>,
1215    rewritten: Vec<RewrittenCommit>,
1216    edits: HistoryEdits,
1217    rewrite: RewriteOptions,
1218    rebase: RebaseOntoOptions,
1219    replay_order: Vec<ObjectId>,
1220    remaining: Vec<ObjectId>,
1221    head_ref: String,
1222    orig_head: ObjectId,
1223    onto: ObjectId,
1224    upstream: ObjectId,
1225    branch_tip: ObjectId,
1226    flatten_leaf_merges: bool,
1227    scoped_refs: Vec<(String, ObjectId)>,
1228    stopped_head: Option<ObjectId>,
1229    stopped_commit: Option<ObjectId>,
1230    exec_commands: Vec<String>,
1231}
1232
1233fn load_persisted_state(git_dir: &Path) -> Result<LoadedState, RebaseSessionError> {
1234    match active_rebase(git_dir) {
1235        None => return Err(RebaseSessionError::NoRebaseInProgress),
1236        Some(ActiveRebase::Foreign(_)) => return Err(RebaseSessionError::ForeignRebaseInProgress),
1237        Some(ActiveRebase::Exec) => return Err(RebaseSessionError::NoRebaseInProgress),
1238        Some(ActiveRebase::Grit) => {}
1239    }
1240    let path = git_dir.join("rebase-merge").join("grit-plan");
1241    let raw = fs::read_to_string(&path).map_err(|_| RebaseSessionError::NoRebaseInProgress)?;
1242    let parsed: PersistedRebaseState =
1243        serde_json::from_str(&raw).map_err(|_| RebaseSessionError::NoRebaseInProgress)?;
1244    Ok(LoadedState {
1245        mapping: mapping_from_persist(&parsed.mapping)?,
1246        rewritten: parsed
1247            .rewritten
1248            .iter()
1249            .map(|r| {
1250                Ok(RewrittenCommit {
1251                    old: parse_oid(&r.old)?,
1252                    new: r.new.as_deref().map(parse_oid).transpose()?,
1253                    change_id: None,
1254                    subject: None,
1255                    reused: r.reused,
1256                })
1257            })
1258            .collect::<Result<_, RebaseSessionError>>()?,
1259        edits: edits_from_persist(&parsed.edits)?,
1260        rewrite: parsed.rewrite,
1261        rebase: parsed.rebase,
1262        replay_order: parsed
1263            .replay_order
1264            .iter()
1265            .map(|h| parse_oid(h))
1266            .collect::<Result<_, _>>()?,
1267        remaining: parsed
1268            .remaining
1269            .iter()
1270            .map(|h| parse_oid(h))
1271            .collect::<Result<_, _>>()?,
1272        head_ref: parsed.head_ref,
1273        orig_head: parse_oid(&parsed.orig_head)?,
1274        onto: parse_oid(&parsed.onto)?,
1275        upstream: parse_oid(&parsed.upstream)?,
1276        branch_tip: parse_oid(&parsed.branch_tip)?,
1277        flatten_leaf_merges: parsed.flatten_leaf_merges,
1278        scoped_refs: parsed
1279            .scoped_refs
1280            .iter()
1281            .map(|(name, hex)| parse_oid(hex).map(|oid| (name.clone(), oid)))
1282            .collect::<Result<_, _>>()?,
1283        stopped_head: parsed.stopped_head.as_deref().map(parse_oid).transpose()?,
1284        stopped_commit: parsed
1285            .stopped_commit
1286            .as_deref()
1287            .map(parse_oid)
1288            .transpose()?,
1289        exec_commands: parsed.exec_commands.clone(),
1290    })
1291}
1292
1293fn write_persisted_state(
1294    git_dir: &Path,
1295    state: &PersistedRebaseState,
1296) -> Result<(), RebaseSessionError> {
1297    let dir = git_dir.join("rebase-merge");
1298    fs::create_dir_all(&dir).map_err(map_lib)?;
1299    let lock_path = dir.join("grit-plan.lock");
1300    let mut lock = LockFile::acquire(&lock_path, LockWait::Immediately)
1301        .map_err(|e| RebaseSessionError::Library(crate::error::Error::Message(e.to_string())))?;
1302    let body = serde_json::to_vec_pretty(state).map_err(map_json)?;
1303    lock.write_all(&body).map_err(map_lib)?;
1304    lock.commit_to(&dir.join("grit-plan"))
1305        .map_err(|e| RebaseSessionError::Library(crate::error::Error::Message(e.to_string())))?;
1306    Ok(())
1307}
1308
1309#[allow(clippy::too_many_arguments)]
1310fn install_stopped_rebase(
1311    repo: &Repository,
1312    head_ref: &str,
1313    orig_head: ObjectId,
1314    request: &RebaseRequest,
1315    rebase_opts: &RebaseOntoOptions,
1316    apply: &ApplyOptions,
1317    partial: super::plan::PartialRewritePlan,
1318    exec_commands: Vec<String>,
1319    progress: &mut dyn ProgressSink,
1320    cancel: &dyn Cancel,
1321) -> Result<RebaseStop, RebaseSessionError> {
1322    poll_cancel(progress, cancel).map_err(map_lib)?;
1323    let effective_onto = request.onto.unwrap_or(request.upstream);
1324    let fork = super::rebase::rebase_fork(repo, request.upstream, request.branch_tip)?;
1325    let mut persisted = state_from_partial(
1326        &partial,
1327        head_ref,
1328        orig_head,
1329        request,
1330        rebase_opts,
1331        apply,
1332        effective_onto,
1333        fork,
1334    );
1335    let conflict = partial.conflict;
1336    persisted.stopped_head = Some(oid_hex(&conflict.replayed_parent));
1337    persisted.exec_commands = exec_commands;
1338    let done = persisted
1339        .done
1340        .iter()
1341        .map(|h| parse_oid(h))
1342        .collect::<Result<Vec<_>, _>>()?;
1343    let remaining = persisted
1344        .remaining
1345        .iter()
1346        .map(|h| parse_oid(h))
1347        .collect::<Result<Vec<_>, _>>()?;
1348    let git_progress = git_visible_rebase_progress(
1349        repo,
1350        fork,
1351        request.branch_tip,
1352        &rebase_opts.rewrite,
1353        &done,
1354        &remaining,
1355        conflict.commit,
1356    )?;
1357    write_git_rebase_merge_files(
1358        repo,
1359        head_ref,
1360        orig_head,
1361        effective_onto,
1362        &git_progress.done,
1363        &git_progress.remaining,
1364        git_progress.stop.as_ref(),
1365    )?;
1366    write_persisted_state(repo.git_dir(), &persisted)?;
1367
1368    repo.odb().flush_for_publish().map_err(map_lib)?;
1369    refs::update_direct_ref_on_store(
1370        repo.refs(),
1371        repo.git_dir(),
1372        "HEAD",
1373        &conflict.replayed_parent,
1374    )
1375    .map_err(map_lib)?;
1376    // With the stop state and HEAD published, a half-installed stop would let continue commit
1377    // without the conflict stages, so the checkouts below run to completion.
1378    let mut progress = UncancellableProgress::new(progress);
1379    reset_index_worktree_to_head(repo, conflict.replayed_parent, &mut progress, &NeverCancel)?;
1380    let ours_tree = commit_tree(repo, conflict.replayed_parent)?;
1381    apply_conflict_merge_worktree(
1382        repo,
1383        &ours_tree,
1384        &conflict.merge,
1385        &mut progress,
1386        &NeverCancel,
1387    )
1388    .map_err(map_lib)?;
1389
1390    let message = load_commit(repo, conflict.commit)?.message;
1391    fs::write(repo.git_dir().join("MERGE_MSG"), &message).map_err(map_lib)?;
1392
1393    Ok(RebaseStop {
1394        commit: conflict.commit,
1395        conflicts: conflicts_from_tree_merge(&conflict.merge),
1396        done,
1397        remaining,
1398    })
1399}
1400
1401#[allow(clippy::too_many_arguments)]
1402fn advance_after_plan(
1403    repo: &Repository,
1404    prev: &LoadedState,
1405    apply: &ApplyOptions,
1406    outcome: ResumablePlanOutcome,
1407    head_before: ObjectId,
1408    progress: &mut dyn ProgressSink,
1409    cancel: &dyn Cancel,
1410) -> Result<RebaseOutcome, RebaseSessionError> {
1411    poll_cancel(progress, cancel).map_err(map_lib)?;
1412    match outcome {
1413        ResumablePlanOutcome::Complete(mut plan) => {
1414            if !prev.exec_commands.is_empty() {
1415                let spec = super::ExecSpec {
1416                    commands: prev.exec_commands.clone(),
1417                };
1418                let runner = repo.command_runner();
1419                super::exec::verify_rebase_with_exec_resume(
1420                    repo,
1421                    &mut plan,
1422                    &spec,
1423                    &prev.rewrite,
1424                    runner.as_ref(),
1425                )?;
1426            }
1427            if let Some(new_tip) = plan.mapped_tip(prev.orig_head) {
1428                if !plan.ref_updates.iter().any(|u| u.name == prev.head_ref) {
1429                    plan.ref_updates.push(PlannedRefUpdate {
1430                        name: prev.head_ref.clone(),
1431                        old: prev.orig_head,
1432                        new: new_tip,
1433                    });
1434                }
1435            }
1436            let apply_without_hooks = ApplyOptions {
1437                hooks: PostOperationHooks::Skip,
1438                ..apply.clone()
1439            };
1440            let mut applied = apply_rewrite(repo, &plan, &apply_without_hooks, progress, cancel)?;
1441            // The branch moved while HEAD was detached for the replay, so apply_rewrite only
1442            // logged it on the branch; HEAD's log still ends at the pre-rebase tip. Log before
1443            // reattaching so a failure leaves a detached, abortable rebase.
1444            if let Some(branch) = plan
1445                .ref_updates
1446                .iter()
1447                .find(|u| u.name == prev.head_ref && u.old != u.new)
1448            {
1449                log_head_move(repo, branch, apply)?;
1450            }
1451            refs::write_symbolic_ref_on_store(repo.refs(), repo.git_dir(), "HEAD", &prev.head_ref)
1452                .map_err(map_lib)?;
1453            let branch_tip = repo.resolve_ref_oid(&prev.head_ref).map_err(map_lib)?;
1454            // The refs already moved: finish the rebase even if a cancel arrives now.
1455            reset_index_worktree_to_head(repo, branch_tip, &mut NullProgress, &NeverCancel)?;
1456            applied.head = branch_tip;
1457            if let Some(operation) = apply.record_operation.as_deref() {
1458                let head_old_tree = commit_tree(repo, prev.orig_head)?;
1459                let head_new_tree = commit_tree(repo, branch_tip)?;
1460                let record = HistoryLastRecord {
1461                    operation: operation.into(),
1462                    head_ref: Some(prev.head_ref.clone()),
1463                    head_old_commit: Some(prev.orig_head),
1464                    head_new_commit: Some(branch_tip),
1465                    head_old_tree: Some(head_old_tree),
1466                    head_new_tree: Some(head_new_tree),
1467                    ref_updates: plan.ref_updates.clone(),
1468                };
1469                write_history_last(repo, &record, None)?;
1470            }
1471            clear_rebase_state(repo.git_dir())?;
1472            if apply.hooks.enabled()
1473                && run_post_rewrite(repo, RewriteKind::Rebase, &plan.rewritten_pairs())
1474            {
1475                applied.hooks_run.push(HookName::PostRewrite);
1476            }
1477            Ok(RebaseOutcome::Done {
1478                head_change: (head_before != applied.head).then_some((head_before, applied.head)),
1479                applied,
1480                plan,
1481            })
1482        }
1483        ResumablePlanOutcome::Partial(partial) => {
1484            let request = RebaseRequest {
1485                upstream: prev.upstream,
1486                onto: Some(prev.onto),
1487                branch_tip: prev.branch_tip,
1488            };
1489            let stop = install_stopped_rebase(
1490                repo,
1491                &prev.head_ref,
1492                prev.orig_head,
1493                &request,
1494                &prev.rebase,
1495                apply,
1496                *partial,
1497                prev.exec_commands.clone(),
1498                progress,
1499                cancel,
1500            )?;
1501            Ok(RebaseOutcome::Stopped(stop))
1502        }
1503    }
1504}
1505
1506struct GitVisibleRebaseProgress {
1507    done: Vec<ObjectId>,
1508    remaining: Vec<ObjectId>,
1509    stop: Option<ObjectId>,
1510}
1511
1512/// Commits written to Git's `rebase-merge/` files for cross-tool `--continue`.
1513///
1514/// Grit's full replay order can include stacked-branch commits when
1515/// [`RefScope::Branches`](super::types::RefScope::Branches) is active; Git must only see the
1516/// rebased branch's own range so `git rebase --continue` finishes on that branch's tip.
1517fn git_visible_rebase_progress(
1518    repo: &Repository,
1519    fork: ObjectId,
1520    branch_tip: ObjectId,
1521    rewrite: &RewriteOptions,
1522    done: &[ObjectId],
1523    remaining: &[ObjectId],
1524    conflict: ObjectId,
1525) -> Result<GitVisibleRebaseProgress, RebaseSessionError> {
1526    if matches!(rewrite.scope, RefScope::Head) {
1527        return Ok(GitVisibleRebaseProgress {
1528            done: done.to_vec(),
1529            remaining: remaining.to_vec(),
1530            stop: Some(conflict),
1531        });
1532    }
1533    let primary = commits_in_rebase_range(repo, fork, &[branch_tip])?;
1534    let primary_set: HashSet<ObjectId> = primary.iter().copied().collect();
1535    let git_done: Vec<ObjectId> = done
1536        .iter()
1537        .copied()
1538        .filter(|oid| primary_set.contains(oid))
1539        .collect();
1540    let git_remaining: Vec<ObjectId> = remaining
1541        .iter()
1542        .copied()
1543        .filter(|oid| primary_set.contains(oid))
1544        .collect();
1545    let git_stop = primary_set.contains(&conflict).then_some(conflict);
1546    Ok(GitVisibleRebaseProgress {
1547        done: git_done,
1548        remaining: git_remaining,
1549        stop: git_stop,
1550    })
1551}
1552
1553fn write_git_rebase_merge_files(
1554    repo: &Repository,
1555    head_ref: &str,
1556    orig_head: ObjectId,
1557    onto: ObjectId,
1558    done: &[ObjectId],
1559    remaining: &[ObjectId],
1560    current: Option<&ObjectId>,
1561) -> Result<(), RebaseSessionError> {
1562    let dir = repo.git_dir().join("rebase-merge");
1563    fs::create_dir_all(&dir).map_err(map_lib)?;
1564    fs::write(dir.join("head-name"), format!("{head_ref}\n")).map_err(map_lib)?;
1565    fs::write(dir.join("onto"), format!("{}\n", onto.to_hex())).map_err(map_lib)?;
1566    fs::write(dir.join("orig-head"), format!("{}\n", orig_head.to_hex())).map_err(map_lib)?;
1567    let todo_remaining: Vec<ObjectId> = match current {
1568        Some(current) if remaining.first() == Some(current) => {
1569            remaining.iter().skip(1).copied().collect()
1570        }
1571        _ => remaining.to_vec(),
1572    };
1573    fs::write(
1574        dir.join("git-rebase-todo"),
1575        todo_lines(repo, &todo_remaining)?,
1576    )
1577    .map_err(map_lib)?;
1578    // Git lists the stopped pick as the last `done` line and resumes from `stopped-sha`,
1579    // committing the staged resolution with `message` and `author-script`.
1580    let done_with_current: Vec<ObjectId> = match current {
1581        Some(current) => done.iter().chain([current]).copied().collect(),
1582        None => done.to_vec(),
1583    };
1584    fs::write(dir.join("done"), todo_lines(repo, &done_with_current)?).map_err(map_lib)?;
1585    let msgnum = done_with_current.len();
1586    let end = done_with_current.len() + todo_remaining.len();
1587    fs::write(dir.join("msgnum"), format!("{msgnum}\n")).map_err(map_lib)?;
1588    fs::write(dir.join("end"), format!("{end}\n")).map_err(map_lib)?;
1589    if let Some(current) = current {
1590        fs::write(dir.join("stopped-sha"), format!("{}\n", current.to_hex())).map_err(map_lib)?;
1591        let commit = load_commit(repo, *current)?;
1592        match author_script(&commit.author) {
1593            Some(script) => fs::write(dir.join("author-script"), script).map_err(map_lib)?,
1594            None => remove_if_exists(&dir.join("author-script"))?,
1595        }
1596        fs::write(dir.join("message"), commit.message).map_err(map_lib)?;
1597    } else {
1598        remove_if_exists(&dir.join("stopped-sha"))?;
1599        remove_if_exists(&dir.join("author-script"))?;
1600        remove_if_exists(&dir.join("message"))?;
1601    }
1602    Ok(())
1603}
1604
1605fn todo_lines(repo: &Repository, commits: &[ObjectId]) -> Result<String, RebaseSessionError> {
1606    let mut out = String::new();
1607    for &oid in commits {
1608        let subject = super::subject::commit_message_subject_line(&load_commit(repo, oid)?.message);
1609        let abbrev = oid.to_short_hex();
1610        out.push_str(&format!("pick {abbrev} {subject}\n"));
1611    }
1612    Ok(out)
1613}
1614
1615/// `rebase-merge/author-script` body for a commit's `author` header: shell assignments of
1616/// `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL` and `GIT_AUTHOR_DATE` (`@<seconds> <offset>`), each
1617/// value single-quoted. `None` when the header has no `<email>`.
1618fn author_script(author: &str) -> Option<String> {
1619    let lt = author.find('<')?;
1620    let gt = lt + author[lt..].find('>')?;
1621    let name = author[..lt].trim_end();
1622    let email = &author[lt + 1..gt];
1623    let date = format!("@{}", author[gt + 1..].trim());
1624    let quote = crate::transport::sq_quote_shell_arg;
1625    Some(format!(
1626        "GIT_AUTHOR_NAME={}\nGIT_AUTHOR_EMAIL={}\nGIT_AUTHOR_DATE={}\n",
1627        quote(name),
1628        quote(email),
1629        quote(&date)
1630    ))
1631}
1632
1633pub(super) fn clear_rebase_state(git_dir: &Path) -> Result<(), RebaseSessionError> {
1634    remove_if_exists(&git_dir.join("MERGE_MSG"))?;
1635    let dir = git_dir.join("rebase-merge");
1636    if dir.is_dir() {
1637        fs::remove_dir_all(&dir).map_err(map_lib)?;
1638    }
1639    Ok(())
1640}
1641
1642fn branch_head(repo: &Repository) -> Result<(String, ObjectId), RebaseSessionError> {
1643    match resolve_head_for_repository(repo).map_err(map_lib)? {
1644        HeadState::Branch {
1645            refname,
1646            oid: Some(oid),
1647            ..
1648        } => Ok((refname, oid)),
1649        HeadState::Branch { oid: None, .. } | HeadState::Detached { .. } | HeadState::Invalid => {
1650            Err(RebaseSessionError::NotOnBranch)
1651        }
1652    }
1653}
1654
1655fn resolve_head_oid(repo: &Repository) -> Result<ObjectId, RebaseSessionError> {
1656    resolve_head_for_repository(repo)
1657        .map_err(map_lib)?
1658        .oid()
1659        .copied()
1660        .ok_or(RebaseSessionError::NotOnBranch)
1661}
1662
1663fn reset_index_worktree_to_head(
1664    repo: &Repository,
1665    head_oid: ObjectId,
1666    progress: &mut dyn ProgressSink,
1667    cancel: &dyn Cancel,
1668) -> Result<(), RebaseSessionError> {
1669    let head_tree = commit_tree(repo, head_oid)?;
1670    // Paths tracked before the move but absent from `head_oid` must go too; leaving them as
1671    // untracked files makes `git rebase --skip/--abort` refuse to overwrite them later.
1672    checkout_hard_to_tree_from_head_tree(repo, &head_tree, &head_tree, progress, cancel)
1673        .map_err(map_lib)?;
1674    Ok(())
1675}
1676
1677/// Re-attach `HEAD` to `refname` and hard-reset it to `target`, reporting the tip it had
1678/// when that was not `target`.
1679fn hard_reset_branch(
1680    repo: &Repository,
1681    refname: &str,
1682    target: ObjectId,
1683    identity: &crate::ident::Signature,
1684) -> Result<RebaseAbort, RebaseSessionError> {
1685    let before = repo
1686        .references()
1687        .resolve_if_exists(refname)
1688        .map_err(map_lib)?;
1689    refs::write_symbolic_ref_on_store(repo.refs(), repo.git_dir(), "HEAD", refname)
1690        .map_err(map_lib)?;
1691    hard_reset_head(repo, target, identity)?;
1692    let overwritten = (before != Some(target)).then(|| PlannedRefUpdate {
1693        name: refname.to_owned(),
1694        old: before.unwrap_or_else(crate::diff::zero_oid),
1695        new: target,
1696    });
1697    Ok(RebaseAbort {
1698        overwritten: overwritten.into_iter().collect(),
1699    })
1700}
1701
1702fn hard_reset_head(
1703    repo: &Repository,
1704    target: ObjectId,
1705    identity: &crate::ident::Signature,
1706) -> Result<(), RebaseSessionError> {
1707    reset_to_commit(
1708        repo,
1709        &ResetOptions::new(target, ResetMode::Hard, identity.clone(), "rebase (abort)"),
1710        &mut crate::progress::NullProgress,
1711        &crate::progress::NeverCancel,
1712    )
1713    .map_err(map_lib)?;
1714    Ok(())
1715}
1716
1717use std::io::Write;
1718
1719fn map_lib<E: Into<crate::error::Error>>(err: E) -> RebaseSessionError {
1720    RebaseSessionError::Library(err.into())
1721}
1722
1723fn replan_rewrite_options(state: &LoadedState) -> RewriteOptions {
1724    state.rewrite.clone()
1725}
1726
1727fn map_json(err: serde_json::Error) -> RebaseSessionError {
1728    RebaseSessionError::Library(crate::error::Error::Message(err.to_string()))
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use super::{
1734        author_script, edits_from_persist, edits_to_persist, parse_oid, PersistedEdit,
1735        RebaseSessionError,
1736    };
1737    use crate::history::types::{CommitEdit, HistoryEdits};
1738    use crate::objects::ObjectId;
1739
1740    fn oid(byte: u8) -> ObjectId {
1741        ObjectId::from_bytes(&[byte; 20]).unwrap_or_else(|_| ObjectId::zero())
1742    }
1743
1744    #[test]
1745    fn reuse_edit_round_trips_its_continuation_parent() {
1746        let mut edits = HistoryEdits::new();
1747        edits.insert(
1748            oid(1),
1749            CommitEdit::Reuse {
1750                existing: oid(2),
1751                parent: oid(3),
1752            },
1753        );
1754        let rows = edits_to_persist(&edits);
1755        let json = serde_json::to_string(&rows).unwrap_or_default();
1756        let parsed: Vec<PersistedEdit> = serde_json::from_str(&json).unwrap_or_default();
1757        assert_eq!(edits_from_persist(&parsed).ok(), Some(edits));
1758    }
1759
1760    #[test]
1761    fn reuse_edit_saved_without_parent_continues_from_the_reused_commit() {
1762        let json = format!(
1763            r#"[{{"Reuse":{{"commit":"{}","existing":"{}"}}}}]"#,
1764            oid(1).to_hex(),
1765            oid(2).to_hex()
1766        );
1767        let parsed: Vec<PersistedEdit> = serde_json::from_str(&json).unwrap_or_default();
1768        let edits = edits_from_persist(&parsed).ok();
1769        assert_eq!(
1770            edits.and_then(|edits| edits.get(&oid(1)).cloned()),
1771            Some(CommitEdit::Reuse {
1772                existing: oid(2),
1773                parent: oid(2),
1774            })
1775        );
1776    }
1777
1778    #[test]
1779    fn parse_oid_reports_the_bad_value() {
1780        assert_eq!(parse_oid(&oid(7).to_hex()).ok(), Some(oid(7)));
1781        let err = parse_oid("abc").err();
1782        assert!(
1783            matches!(&err, Some(RebaseSessionError::InvalidStateOid { value }) if value == "abc"),
1784            "{err:?}"
1785        );
1786    }
1787
1788    #[test]
1789    fn persisted_edit_with_bad_oid_is_rejected() {
1790        let rows = vec![PersistedEdit::Drop {
1791            commit: "zz".into(),
1792        }];
1793        assert!(matches!(
1794            edits_from_persist(&rows),
1795            Err(RebaseSessionError::InvalidStateOid { value }) if value == "zz"
1796        ));
1797    }
1798
1799    #[test]
1800    fn author_script_quotes_values_and_prefixes_date() {
1801        assert_eq!(
1802            author_script("A U Thor <a@example.com> 1700000000 -0130").as_deref(),
1803            Some(
1804                "GIT_AUTHOR_NAME='A U Thor'\nGIT_AUTHOR_EMAIL='a@example.com'\nGIT_AUTHOR_DATE='@1700000000 -0130'\n"
1805            )
1806        );
1807        assert_eq!(
1808            author_script("O'Brien <o@example.com> 1 +0000").as_deref(),
1809            Some(
1810                "GIT_AUTHOR_NAME='O'\\''Brien'\nGIT_AUTHOR_EMAIL='o@example.com'\nGIT_AUTHOR_DATE='@1 +0000'\n"
1811            )
1812        );
1813        assert_eq!(author_script("no email here 1 +0000"), None);
1814    }
1815}