branchless/core/rewrite/
execute.rs

1use std::collections::{HashMap, HashSet};
2
3use std::fmt::Write;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7use bstr::BString;
8use eyre::Context;
9use tracing::warn;
10
11use crate::core::check_out::{check_out_commit, CheckOutCommitOptions, CheckoutTarget};
12use crate::core::effects::Effects;
13use crate::core::eventlog::{EventLogDb, EventTransactionId};
14use crate::core::formatting::Pluralize;
15use crate::core::repo_ext::RepoExt;
16use crate::git::{
17    BranchType, CategorizedReferenceName, GitRunInfo, MaybeZeroOid, NonZeroOid, ReferenceName,
18    Repo, ResolvedReferenceInfo,
19};
20use crate::util::{ExitCode, EyreExitOr};
21
22use super::plan::RebasePlan;
23
24/// Given a list of rewritten OIDs, move the branches attached to those OIDs
25/// from their old commits to their new commits. Invoke the
26/// `reference-transaction` hook when done.
27pub fn move_branches<'a>(
28    effects: &Effects,
29    git_run_info: &GitRunInfo,
30    repo: &'a Repo,
31    event_tx_id: EventTransactionId,
32    rewritten_oids_map: &'a HashMap<NonZeroOid, MaybeZeroOid>,
33) -> eyre::Result<()> {
34    let main_branch = repo.get_main_branch()?;
35    let main_branch_name = main_branch.get_reference_name()?;
36    let branch_oid_to_names = repo.get_branch_oid_to_names()?;
37
38    // We may experience an error in the case of a branch move. Ideally, we
39    // would use `git2::Transaction::commit`, which stops the transaction at the
40    // first error, but we don't know which references we successfully committed
41    // in that case. Instead, we just do things non-atomically and record which
42    // ones succeeded. See https://github.com/libgit2/libgit2/issues/5918
43    let mut branch_moves: Vec<(NonZeroOid, MaybeZeroOid, &ReferenceName)> = Vec::new();
44    let mut branch_move_err: Option<eyre::Error> = None;
45    'outer: for (old_oid, names) in branch_oid_to_names.iter() {
46        let new_oid = match rewritten_oids_map.get(old_oid) {
47            Some(new_oid) => new_oid,
48            None => continue,
49        };
50        let mut names: Vec<_> = names.iter().collect();
51        // Sort for determinism in tests.
52        names.sort_unstable();
53        match new_oid {
54            MaybeZeroOid::NonZero(new_oid) => {
55                let new_commit = match repo.find_commit_or_fail(*new_oid).wrap_err_with(|| {
56                    format!(
57                        "Could not find newly-rewritten commit with old OID: {old_oid:?}, new OID: {new_oid:?}",
58                    )
59                }) {
60                    Ok(commit) => commit,
61                    Err(err) => {
62                        branch_move_err = Some(err);
63                        break 'outer;
64                    }
65                };
66
67                for reference_name in names {
68                    if let Err(err) = repo.create_reference(
69                        reference_name,
70                        new_commit.get_oid(),
71                        true,
72                        "move branches",
73                    ) {
74                        branch_move_err = Some(eyre::eyre!(err));
75                        break 'outer;
76                    }
77                    branch_moves.push((*old_oid, MaybeZeroOid::NonZero(*new_oid), reference_name));
78                }
79            }
80
81            MaybeZeroOid::Zero => {
82                for reference_name in names {
83                    if reference_name == &main_branch_name {
84                        // Hack? Never delete the main branch. We probably got here by syncing the
85                        // main branch with the upstream version, but all main branch commits were
86                        // skipped. For a regular branch, we would delete the branch, but for the
87                        // main branch, we should update it to point directly to the upstream
88                        // version.
89                        let target_oid = match main_branch.get_upstream_branch_target()? {
90                            Some(target_oid) => {
91                                if let Err(err) = repo.create_reference(
92                                    &main_branch_name,
93                                    target_oid,
94                                    true,
95                                    "move main branch",
96                                ) {
97                                    branch_move_err = Some(eyre::eyre!(err));
98                                    break 'outer;
99                                }
100                                MaybeZeroOid::NonZero(target_oid)
101                            }
102                            None => {
103                                let mut main_branch_reference =
104                                    repo.get_main_branch()?.into_reference();
105                                if let Err(err) = main_branch_reference.delete() {
106                                    branch_move_err = Some(eyre::eyre!(err));
107                                    break 'outer;
108                                }
109                                MaybeZeroOid::Zero
110                            }
111                        };
112                        branch_moves.push((*old_oid, target_oid, reference_name));
113                    } else {
114                        let branch_name = CategorizedReferenceName::new(reference_name);
115                        match branch_name {
116                            CategorizedReferenceName::RemoteBranch { .. }
117                            | CategorizedReferenceName::OtherRef { .. } => {
118                                warn!(?reference_name, "Not deleting non-local-branch reference");
119                            }
120                            CategorizedReferenceName::LocalBranch { .. } => {
121                                let branch_name = branch_name.render_suffix();
122                                match repo.find_branch(&branch_name, BranchType::Local) {
123                                    Ok(Some(mut branch)) => {
124                                        if let Err(err) = branch.delete() {
125                                            branch_move_err = Some(eyre::eyre!(err));
126                                            break 'outer;
127                                        }
128                                    }
129                                    Ok(None) => {
130                                        warn!(?branch_name, "Branch not found, not deleting")
131                                    }
132                                    Err(err) => {
133                                        branch_move_err = Some(eyre::eyre!(err));
134                                        break 'outer;
135                                    }
136                                };
137                                branch_moves.push((*old_oid, MaybeZeroOid::Zero, reference_name));
138                            }
139                        }
140                    }
141                }
142            }
143        }
144    }
145
146    #[allow(clippy::format_collect)]
147    let branch_moves_stdin: String = branch_moves
148        .into_iter()
149        .map(|(old_oid, new_oid, name)| {
150            format!("{old_oid} {new_oid} {name}\n", name = name.as_str())
151        })
152        .collect();
153    let branch_moves_stdin = BString::from(branch_moves_stdin);
154    git_run_info.run_hook(
155        effects,
156        repo,
157        "reference-transaction",
158        event_tx_id,
159        &["committed"],
160        Some(branch_moves_stdin),
161    )?;
162    match branch_move_err {
163        Some(err) => Err(err),
164        None => Ok(()),
165    }
166}
167
168/// After a rebase, check out the appropriate new `HEAD`. This can be difficult
169/// because the commit might have been rewritten, dropped, or have a branch
170/// pointing to it which also needs to be checked out.
171///
172/// `skipped_head_updated_oid` is the caller's belief of what the new OID of
173/// `HEAD` should be in the event that the original commit was skipped. If the
174/// caller doesn't think that the previous `HEAD` commit was skipped, then they
175/// should pass in `None`.
176pub fn check_out_updated_head(
177    effects: &Effects,
178    git_run_info: &GitRunInfo,
179    repo: &Repo,
180    event_log_db: &EventLogDb,
181    event_tx_id: EventTransactionId,
182    rewritten_oids: &HashMap<NonZeroOid, MaybeZeroOid>,
183    previous_head_info: &ResolvedReferenceInfo,
184    skipped_head_updated_oid: Option<NonZeroOid>,
185    check_out_commit_options: &CheckOutCommitOptions,
186) -> EyreExitOr<()> {
187    let checkout_target: ResolvedReferenceInfo = match previous_head_info {
188        ResolvedReferenceInfo {
189            oid: None,
190            reference_name: None,
191        } => {
192            // Head was unborn, so no need to check out a new branch.
193            ResolvedReferenceInfo {
194                oid: skipped_head_updated_oid,
195                reference_name: None,
196            }
197        }
198
199        ResolvedReferenceInfo {
200            oid: None,
201            reference_name: Some(reference_name),
202        } => {
203            // Head was unborn but a branch was checked out. Not sure if this
204            // can happen, but if so, just use that branch.
205            ResolvedReferenceInfo {
206                oid: None,
207                reference_name: Some(reference_name.clone()),
208            }
209        }
210
211        ResolvedReferenceInfo {
212            oid: Some(previous_head_oid),
213            reference_name: None,
214        } => {
215            // No branch was checked out.
216            match rewritten_oids.get(previous_head_oid) {
217                Some(MaybeZeroOid::NonZero(oid)) => {
218                    // This OID was rewritten, so check out the new version of the commit.
219                    ResolvedReferenceInfo {
220                        oid: Some(*oid),
221                        reference_name: None,
222                    }
223                }
224                Some(MaybeZeroOid::Zero) => {
225                    // The commit was skipped. Get the new location for `HEAD`.
226                    ResolvedReferenceInfo {
227                        oid: skipped_head_updated_oid,
228                        reference_name: None,
229                    }
230                }
231                None => {
232                    // This OID was not rewritten, so check it out again.
233                    ResolvedReferenceInfo {
234                        oid: Some(*previous_head_oid),
235                        reference_name: None,
236                    }
237                }
238            }
239        }
240
241        ResolvedReferenceInfo {
242            oid: Some(_),
243            reference_name: Some(reference_name),
244        } => {
245            // Find the reference at current time to see if it still exists.
246            match repo.find_reference(reference_name)? {
247                Some(reference) => {
248                    // The branch moved, so we need to make sure that we are
249                    // still checked out to it.
250                    //
251                    // * On-disk rebases will end with the branch pointing to
252                    // the last rebase head, which may not be the `HEAD` commit
253                    // before the rebase.
254                    //
255                    // * In-memory rebases will detach `HEAD` before proceeding,
256                    // so we need to reattach it if necessary.
257                    let oid = repo.resolve_reference(&reference)?.oid;
258                    ResolvedReferenceInfo {
259                        oid,
260                        reference_name: Some(reference_name.clone()),
261                    }
262                }
263
264                None => {
265                    // The branch was deleted because it pointed to a skipped
266                    // commit. Get the new location for `HEAD`.
267                    ResolvedReferenceInfo {
268                        oid: skipped_head_updated_oid,
269                        reference_name: None,
270                    }
271                }
272            }
273        }
274    };
275
276    let head_info = repo.get_head_info()?;
277    if head_info == checkout_target {
278        return Ok(Ok(()));
279    }
280
281    let checkout_target: CheckoutTarget = match &checkout_target {
282        ResolvedReferenceInfo {
283            oid: None,
284            reference_name: None,
285        } => return Ok(Ok(())),
286
287        ResolvedReferenceInfo {
288            oid: Some(oid),
289            reference_name: None,
290        } => CheckoutTarget::Oid(*oid),
291
292        ResolvedReferenceInfo {
293            oid: _,
294            reference_name: Some(reference_name),
295        } => {
296            // FIXME: we could check to see if the OIDs are the same and, if so,
297            // reattach or detach `HEAD` manually without having to call `git checkout`.
298            let checkout_target = match checkout_target.get_branch_name()? {
299                Some(branch_name) => branch_name,
300                None => reference_name.as_str(),
301            };
302            CheckoutTarget::Reference(ReferenceName::from(checkout_target))
303        }
304    };
305
306    let result = check_out_commit(
307        effects,
308        git_run_info,
309        repo,
310        event_log_db,
311        event_tx_id,
312        Some(checkout_target),
313        check_out_commit_options,
314    )?;
315    Ok(result)
316}
317
318/// What to suggest that the user do in order to resolve a merge conflict.
319#[derive(Copy, Clone, Debug)]
320pub enum MergeConflictRemediation {
321    /// Indicate that the user should retry the merge operation (but with
322    /// `--merge`).
323    Retry,
324
325    /// Indicate that the user should run `git restack --merge`.
326    Restack,
327
328    /// Indicate that the user should run `git move -m -s 'siblings(.)'`.
329    Insert,
330}
331
332/// Information about a failure to merge that occurred while moving commits.
333#[derive(Debug)]
334pub enum FailedMergeInfo {
335    /// A merge conflict occurred.
336    Conflict {
337        /// The OID of the commit that, when moved, caused a conflict.
338        commit_oid: NonZeroOid,
339
340        /// The paths which were in conflict.
341        conflicting_paths: HashSet<PathBuf>,
342    },
343
344    /// A merge commit could not be rebased in memory.
345    CannotRebaseMergeInMemory {
346        /// The OID of the merge commit that could not be moved.
347        commit_oid: NonZeroOid,
348    },
349}
350
351impl FailedMergeInfo {
352    /// Describe the merge conflict in a user-friendly way and advise to rerun
353    /// with `--merge`.
354    pub fn describe(
355        &self,
356        effects: &Effects,
357        repo: &Repo,
358        remediation: MergeConflictRemediation,
359    ) -> eyre::Result<()> {
360        match self {
361            FailedMergeInfo::Conflict {
362                commit_oid,
363                conflicting_paths,
364            } => {
365                writeln!(
366                    effects.get_output_stream(),
367                    "This operation would cause a merge conflict:"
368                )?;
369                writeln!(
370                    effects.get_output_stream(),
371                    "{} ({}) {}",
372                    effects.get_glyphs().bullet_point,
373                    Pluralize {
374                        determiner: None,
375                        amount: conflicting_paths.len(),
376                        unit: ("conflicting file", "conflicting files"),
377                    },
378                    effects.get_glyphs().render(
379                        repo.friendly_describe_commit_from_oid(effects.get_glyphs(), *commit_oid)?
380                    )?
381                )?;
382            }
383
384            FailedMergeInfo::CannotRebaseMergeInMemory { commit_oid } => {
385                writeln!(
386                    effects.get_output_stream(),
387                    "Merge commits currently can't be rebased in-memory."
388                )?;
389                writeln!(
390                    effects.get_output_stream(),
391                    "The merge commit was: {}",
392                    effects.get_glyphs().render(
393                        repo.friendly_describe_commit_from_oid(effects.get_glyphs(), *commit_oid)?
394                    )?,
395                )?;
396            }
397        }
398
399        match remediation {
400            MergeConflictRemediation::Retry => {
401                writeln!(
402                    effects.get_output_stream(),
403                    "To resolve merge conflicts, retry this operation with the --merge option."
404                )?;
405            }
406            MergeConflictRemediation::Restack => {
407                writeln!(
408                    effects.get_output_stream(),
409                    "To resolve merge conflicts, run: git restack --merge"
410                )?;
411            }
412            MergeConflictRemediation::Insert => {
413                writeln!(
414                    effects.get_output_stream(),
415                    "To resolve merge conflicts, run: git move -m -s 'siblings(.)'"
416                )?;
417            }
418        }
419
420        Ok(())
421    }
422}
423
424mod in_memory {
425    use std::collections::HashMap;
426    use std::fmt::Write;
427
428    use bstr::{BString, ByteSlice};
429    use eyre::Context;
430    use tracing::{instrument, warn};
431
432    use crate::core::effects::{Effects, OperationIcon, OperationType};
433    use crate::core::eventlog::EventLogDb;
434    use crate::core::gc::mark_commit_reachable;
435    use crate::core::rewrite::execute::check_out_updated_head;
436    use crate::core::rewrite::move_branches;
437    use crate::core::rewrite::plan::{OidOrLabel, RebaseCommand, RebasePlan};
438    use crate::git::{
439        AmendFastOptions, CherryPickFastOptions, CreateCommitFastError, GitRunInfo, MaybeZeroOid,
440        NonZeroOid, Repo,
441    };
442    use crate::util::EyreExitOr;
443
444    use super::{ExecuteRebasePlanOptions, FailedMergeInfo};
445
446    pub enum RebaseInMemoryResult {
447        Succeeded {
448            rewritten_oids: HashMap<NonZeroOid, MaybeZeroOid>,
449
450            /// The new OID that `HEAD` should point to, based on the rebase.
451            ///
452            /// - This is only `None` if `HEAD` was unborn.
453            /// - This doesn't capture if `HEAD` was pointing to a branch. The
454            ///   caller will need to figure that out.
455            new_head_oid: Option<NonZeroOid>,
456        },
457        MergeFailed(FailedMergeInfo),
458    }
459
460    #[instrument]
461    pub fn rebase_in_memory(
462        effects: &Effects,
463        repo: &Repo,
464        rebase_plan: &RebasePlan,
465        options: &ExecuteRebasePlanOptions,
466    ) -> eyre::Result<RebaseInMemoryResult> {
467        if let Some(merge_commit_oid) =
468            rebase_plan
469                .commands
470                .iter()
471                .find_map(|command| match command {
472                    RebaseCommand::Merge {
473                        commit_oid,
474                        commits_to_merge: _,
475                    } => Some(commit_oid),
476                    RebaseCommand::CreateLabel { .. }
477                    | RebaseCommand::Reset { .. }
478                    | RebaseCommand::Pick { .. }
479                    | RebaseCommand::Replace { .. }
480                    | RebaseCommand::Break
481                    | RebaseCommand::RegisterExtraPostRewriteHook
482                    | RebaseCommand::DetectEmptyCommit { .. }
483                    | RebaseCommand::SkipUpstreamAppliedCommit { .. } => None,
484                })
485        {
486            return Ok(RebaseInMemoryResult::MergeFailed(
487                FailedMergeInfo::CannotRebaseMergeInMemory {
488                    commit_oid: *merge_commit_oid,
489                },
490            ));
491        }
492
493        let ExecuteRebasePlanOptions {
494            now,
495            // Transaction ID will be passed to the `post-rewrite` hook via
496            // environment variable.
497            event_tx_id: _,
498            preserve_timestamps,
499            force_in_memory: _,
500            force_on_disk: _,
501            resolve_merge_conflicts: _, // May be needed once we can resolve merge conflicts in memory.
502            check_out_commit_options: _, // Caller is responsible for checking out to new HEAD.
503        } = options;
504
505        let mut current_oid = rebase_plan.first_dest_oid;
506        let mut labels: HashMap<String, NonZeroOid> = HashMap::new();
507        let mut rewritten_oids: HashMap<NonZeroOid, MaybeZeroOid> = HashMap::new();
508
509        // Normally, we can determine the new `HEAD` OID by looking at the
510        // rewritten commits. However, if `HEAD` pointed to a commit that was
511        // skipped, then the rewritten OID is zero. In that case, we need to
512        // delete the branch (responsibility of the caller) and choose a
513        // different `HEAD` OID.
514        let head_oid = repo.get_head_info()?.oid;
515        let mut skipped_head_new_oid = None;
516        let mut maybe_set_skipped_head_new_oid = |skipped_head_oid, current_oid| {
517            if Some(skipped_head_oid) == head_oid {
518                skipped_head_new_oid.get_or_insert(current_oid);
519            }
520        };
521
522        let mut i = 0;
523        let num_picks = rebase_plan
524            .commands
525            .iter()
526            .filter(|command| match command {
527                RebaseCommand::CreateLabel { .. }
528                | RebaseCommand::Reset { .. }
529                | RebaseCommand::Break
530                | RebaseCommand::RegisterExtraPostRewriteHook
531                | RebaseCommand::DetectEmptyCommit { .. } => false,
532                RebaseCommand::Pick { .. }
533                | RebaseCommand::Merge { .. }
534                | RebaseCommand::Replace { .. }
535                | RebaseCommand::SkipUpstreamAppliedCommit { .. } => true,
536            })
537            .count();
538        let (effects, progress) = effects.start_operation(OperationType::RebaseCommits);
539
540        for command in rebase_plan.commands.iter() {
541            match command {
542                RebaseCommand::CreateLabel { label_name } => {
543                    labels.insert(label_name.clone(), current_oid);
544                }
545
546                RebaseCommand::Reset {
547                    target: OidOrLabel::Label(label_name),
548                } => {
549                    current_oid = match labels.get(label_name) {
550                        Some(oid) => *oid,
551                        None => eyre::bail!("BUG: no associated OID for label: {label_name}"),
552                    };
553                }
554
555                RebaseCommand::Reset {
556                    target: OidOrLabel::Oid(commit_oid),
557                } => {
558                    current_oid = match rewritten_oids.get(commit_oid) {
559                        Some(MaybeZeroOid::NonZero(rewritten_oid)) => {
560                            // HEAD has been rewritten.
561                            *rewritten_oid
562                        }
563                        Some(MaybeZeroOid::Zero) | None => {
564                            // Either HEAD was not rewritten, or it was but its
565                            // associated commit was skipped. Either way, just
566                            // use the current OID.
567                            *commit_oid
568                        }
569                    };
570                }
571
572                RebaseCommand::Pick {
573                    original_commit_oid,
574                    commits_to_apply_oids,
575                } => {
576                    let current_commit = repo
577                        .find_commit_or_fail(current_oid)
578                        .wrap_err("Finding current commit")?;
579
580                    let original_commit = repo
581                        .find_commit_or_fail(*original_commit_oid)
582                        .wrap_err("Finding commit to apply")?;
583                    i += 1;
584
585                    let commit_num = format!("[{i}/{num_picks}]");
586                    progress.notify_progress(i, num_picks);
587
588                    let commit_message = original_commit.get_message_raw();
589                    let commit_message = commit_message.to_str().with_context(|| {
590                        eyre::eyre!(
591                            "Could not decode commit message for commit: {:?}",
592                            original_commit_oid
593                        )
594                    })?;
595
596                    let commit_author = original_commit.get_author();
597                    let committer_signature = if *preserve_timestamps {
598                        original_commit.get_committer()
599                    } else {
600                        original_commit.get_committer().update_timestamp(*now)?
601                    };
602                    let mut rebased_commit_oid = None;
603                    let mut rebased_commit = None;
604
605                    for commit_oid in commits_to_apply_oids.iter() {
606                        let commit_to_apply = repo
607                            .find_commit_or_fail(*commit_oid)
608                            .wrap_err("Finding commit to apply")?;
609                        let commit_description = effects
610                            .get_glyphs()
611                            .render(commit_to_apply.friendly_describe(effects.get_glyphs())?)?;
612
613                        if commit_to_apply.get_parent_count() > 1 {
614                            warn!(
615                                ?commit_oid,
616                                "BUG: Merge commit should have been detected during planning phase"
617                            );
618                            return Ok(RebaseInMemoryResult::MergeFailed(
619                                FailedMergeInfo::CannotRebaseMergeInMemory {
620                                    commit_oid: *commit_oid,
621                                },
622                            ));
623                        };
624
625                        progress.notify_status(
626                            OperationIcon::InProgress,
627                            format!("Applying patch for commit: {commit_description}"),
628                        );
629
630                        // Create a commit and then repeatedly amend & re-create it
631                        // FIXME what #perf gains can be had by working directly on a tree?
632                        // Is it even possible to repeatedly amend a tree and then commit
633                        // it once at the end?
634
635                        let maybe_tree = if rebased_commit.is_none() {
636                            repo.cherry_pick_fast(
637                                &commit_to_apply,
638                                &current_commit,
639                                &CherryPickFastOptions {
640                                    reuse_parent_tree_if_possible: true,
641                                },
642                            )
643                        } else {
644                            repo.amend_fast(
645                                &rebased_commit.expect("rebased commit should not be None"),
646                                &AmendFastOptions::FromCommit {
647                                    commit: commit_to_apply,
648                                },
649                            )
650                        };
651                        let commit_tree = match maybe_tree {
652                            Ok(tree) => tree,
653                            Err(CreateCommitFastError::MergeConflict { conflicting_paths }) => {
654                                return Ok(RebaseInMemoryResult::MergeFailed(
655                                    FailedMergeInfo::Conflict {
656                                        commit_oid: *commit_oid,
657                                        conflicting_paths,
658                                    },
659                                ))
660                            }
661                            Err(other) => eyre::bail!(other),
662                        };
663
664                        // this is the description of each fixup commit
665                        // FIXME should we instead be using the description of the base commit?
666                        // or use a different message altogether when squashing multiple commits?
667                        progress.notify_status(
668                            OperationIcon::InProgress,
669                            format!("Committing to repository: {commit_description}"),
670                        );
671                        rebased_commit_oid = Some(
672                            repo.create_commit(
673                                None,
674                                &commit_author,
675                                &committer_signature,
676                                commit_message,
677                                &commit_tree,
678                                vec![&current_commit],
679                            )
680                            .wrap_err("Applying rebased commit")?,
681                        );
682
683                        rebased_commit = repo.find_commit(rebased_commit_oid.unwrap())?;
684                    }
685
686                    let rebased_commit_oid =
687                        rebased_commit_oid.expect("rebased oid should not be None");
688                    let commit_description =
689                        effects
690                            .get_glyphs()
691                            .render(repo.friendly_describe_commit_from_oid(
692                                effects.get_glyphs(),
693                                rebased_commit_oid,
694                            )?)?;
695
696                    if rebased_commit
697                        .expect("rebased commit should not be None")
698                        .is_empty()
699                    {
700                        rewritten_oids.insert(*original_commit_oid, MaybeZeroOid::Zero);
701                        maybe_set_skipped_head_new_oid(*original_commit_oid, current_oid);
702
703                        writeln!(
704                            effects.get_output_stream(),
705                            "{commit_num} Skipped now-empty commit: {commit_description}",
706                        )?;
707                    } else {
708                        rewritten_oids.insert(
709                            *original_commit_oid,
710                            MaybeZeroOid::NonZero(rebased_commit_oid),
711                        );
712                        for commit_oid in commits_to_apply_oids {
713                            rewritten_oids
714                                .insert(*commit_oid, MaybeZeroOid::NonZero(rebased_commit_oid));
715                        }
716
717                        current_oid = rebased_commit_oid;
718
719                        writeln!(
720                            effects.get_output_stream(),
721                            "{commit_num} Committed as: {commit_description}"
722                        )?;
723                    }
724                }
725
726                RebaseCommand::Merge {
727                    commit_oid,
728                    commits_to_merge: _,
729                } => {
730                    warn!(
731                        ?commit_oid,
732                        "BUG: Merge commit without replacement should have been detected when starting in-memory rebase"
733                    );
734                    return Ok(RebaseInMemoryResult::MergeFailed(
735                        FailedMergeInfo::CannotRebaseMergeInMemory {
736                            commit_oid: *commit_oid,
737                        },
738                    ));
739                }
740
741                RebaseCommand::Replace {
742                    commit_oid,
743                    replacement_commit_oid,
744                    parents,
745                } => {
746                    let original_commit = repo
747                        .find_commit_or_fail(*commit_oid)
748                        .wrap_err("Finding current commit")?;
749                    let original_commit_description = effects
750                        .get_glyphs()
751                        .render(original_commit.friendly_describe(effects.get_glyphs())?)?;
752
753                    i += 1;
754                    let commit_num = format!("[{i}/{num_picks}]");
755                    progress.notify_progress(i, num_picks);
756                    progress.notify_status(
757                        OperationIcon::InProgress,
758                        format!("Replacing commit: {original_commit_description}"),
759                    );
760
761                    let replacement_commit = repo.find_commit_or_fail(*replacement_commit_oid)?;
762                    let replacement_tree = replacement_commit.get_tree()?;
763                    let replacement_message = replacement_commit.get_message_raw();
764                    let replacement_commit_message =
765                        replacement_message.to_str().with_context(|| {
766                            eyre::eyre!(
767                                "Could not decode commit message for replacement commit: {:?}",
768                                replacement_commit
769                            )
770                        })?;
771
772                    let replacement_commit_description = effects
773                        .get_glyphs()
774                        .render(replacement_commit.friendly_describe(effects.get_glyphs())?)?;
775                    progress.notify_status(
776                        OperationIcon::InProgress,
777                        format!("Committing to repository: {replacement_commit_description}"),
778                    );
779                    let committer_signature = if *preserve_timestamps {
780                        replacement_commit.get_committer()
781                    } else {
782                        replacement_commit.get_committer().update_timestamp(*now)?
783                    };
784                    let parents = {
785                        let mut result = Vec::new();
786                        for parent in parents {
787                            let parent_oid = match parent {
788                                OidOrLabel::Oid(oid) => *oid,
789                                OidOrLabel::Label(label) => {
790                                    let oid = labels.get(label).ok_or_else(|| {
791                                        eyre::eyre!(
792                                            "Label {label} could not be resolved to a commit"
793                                        )
794                                    })?;
795                                    *oid
796                                }
797                            };
798                            let parent_commit = repo.find_commit_or_fail(parent_oid)?;
799                            result.push(parent_commit);
800                        }
801                        result
802                    };
803                    let rebased_commit_oid = repo
804                        .create_commit(
805                            None,
806                            &replacement_commit.get_author(),
807                            &committer_signature,
808                            replacement_commit_message,
809                            &replacement_tree,
810                            parents.iter().collect(),
811                        )
812                        .wrap_err("Applying rebased commit")?;
813
814                    let commit_description =
815                        effects
816                            .get_glyphs()
817                            .render(repo.friendly_describe_commit_from_oid(
818                                effects.get_glyphs(),
819                                rebased_commit_oid,
820                            )?)?;
821                    rewritten_oids.insert(*commit_oid, MaybeZeroOid::NonZero(rebased_commit_oid));
822                    current_oid = rebased_commit_oid;
823
824                    writeln!(
825                        effects.get_output_stream(),
826                        "{commit_num} Committed as: {commit_description}"
827                    )?;
828                }
829
830                RebaseCommand::Break => {
831                    eyre::bail!("`break` not supported for in-memory rebases");
832                }
833
834                RebaseCommand::SkipUpstreamAppliedCommit { commit_oid } => {
835                    i += 1;
836                    let commit_num = format!("[{i}/{num_picks}]");
837
838                    let commit = repo.find_commit_or_fail(*commit_oid)?;
839                    rewritten_oids.insert(*commit_oid, MaybeZeroOid::Zero);
840                    maybe_set_skipped_head_new_oid(*commit_oid, current_oid);
841
842                    let commit_description = commit.friendly_describe(effects.get_glyphs())?;
843                    let commit_description = effects.get_glyphs().render(commit_description)?;
844                    writeln!(
845                        effects.get_output_stream(),
846                        "{commit_num} Skipped commit (was already applied upstream): {commit_description}"
847                    )?;
848                }
849
850                RebaseCommand::RegisterExtraPostRewriteHook
851                | RebaseCommand::DetectEmptyCommit { .. } => {
852                    // Do nothing. We'll carry out post-rebase operations after the
853                    // in-memory rebase completes.
854                }
855            }
856        }
857
858        let new_head_oid: Option<NonZeroOid> = match head_oid {
859            None => {
860                // `HEAD` is unborn, so keep it that way.
861                None
862            }
863            Some(head_oid) => {
864                match rewritten_oids.get(&head_oid) {
865                    Some(MaybeZeroOid::NonZero(new_head_oid)) => {
866                        // `HEAD` was rewritten to this OID.
867                        Some(*new_head_oid)
868                    }
869                    Some(MaybeZeroOid::Zero) => {
870                        // `HEAD` was rewritten, but its associated commit was
871                        // skipped. Use whatever saved new `HEAD` OID we have.
872                        let new_head_oid = match skipped_head_new_oid {
873                            Some(new_head_oid) => new_head_oid,
874                            None => {
875                                warn!(
876                                    ?head_oid,
877                                    "`HEAD` OID was rewritten to 0, but no skipped `HEAD` OID was set",
878                                );
879                                head_oid
880                            }
881                        };
882                        Some(new_head_oid)
883                    }
884                    None => {
885                        // The `HEAD` OID was not rewritten, so use its current value.
886                        Some(head_oid)
887                    }
888                }
889            }
890        };
891        Ok(RebaseInMemoryResult::Succeeded {
892            rewritten_oids,
893            new_head_oid,
894        })
895    }
896
897    pub fn post_rebase_in_memory(
898        effects: &Effects,
899        git_run_info: &GitRunInfo,
900        repo: &Repo,
901        event_log_db: &EventLogDb,
902        rewritten_oids: &HashMap<NonZeroOid, MaybeZeroOid>,
903        skipped_head_updated_oid: Option<NonZeroOid>,
904        options: &ExecuteRebasePlanOptions,
905    ) -> EyreExitOr<()> {
906        let ExecuteRebasePlanOptions {
907            now: _,
908            event_tx_id,
909            preserve_timestamps: _,
910            force_in_memory: _,
911            force_on_disk: _,
912            resolve_merge_conflicts: _,
913            check_out_commit_options,
914        } = options;
915
916        for new_oid in rewritten_oids.values() {
917            if let MaybeZeroOid::NonZero(new_oid) = new_oid {
918                mark_commit_reachable(repo, *new_oid)?;
919            }
920        }
921
922        let head_info = repo.get_head_info()?;
923        if head_info.oid.is_some() {
924            // Avoid moving the branch which HEAD points to, or else the index will show
925            // a lot of changes in the working copy.
926            repo.detach_head(&head_info)?;
927        }
928
929        move_branches(effects, git_run_info, repo, *event_tx_id, rewritten_oids)?;
930
931        // Call the `post-rewrite` hook only after moving branches so that we don't
932        // produce a spurious abandoned-branch warning.
933        #[allow(clippy::format_collect)]
934        let post_rewrite_stdin: String = rewritten_oids
935            .iter()
936            .map(|(old_oid, new_oid)| format!("{old_oid} {new_oid}\n"))
937            .collect();
938        let post_rewrite_stdin = BString::from(post_rewrite_stdin);
939        git_run_info.run_hook(
940            effects,
941            repo,
942            "post-rewrite",
943            *event_tx_id,
944            &["rebase"],
945            Some(post_rewrite_stdin),
946        )?;
947
948        let exit_code = check_out_updated_head(
949            effects,
950            git_run_info,
951            repo,
952            event_log_db,
953            *event_tx_id,
954            rewritten_oids,
955            &head_info,
956            skipped_head_updated_oid,
957            check_out_commit_options,
958        )?;
959        Ok(exit_code)
960    }
961}
962
963mod on_disk {
964    use std::fmt::Write;
965
966    use eyre::Context;
967    use tracing::instrument;
968
969    use crate::core::effects::{Effects, OperationType};
970    use crate::core::rewrite::plan::RebaseCommand;
971    use crate::core::rewrite::plan::RebasePlan;
972    use crate::core::rewrite::rewrite_hooks::save_original_head_info;
973    use crate::git::{GitRunInfo, Repo};
974
975    use crate::util::ExitCode;
976
977    use super::ExecuteRebasePlanOptions;
978
979    pub enum Error {
980        ChangedFilesInRepository,
981        OperationAlreadyInProgress { operation_type: String },
982    }
983
984    fn write_rebase_state_to_disk(
985        effects: &Effects,
986        git_run_info: &GitRunInfo,
987        repo: &Repo,
988        rebase_plan: &RebasePlan,
989        options: &ExecuteRebasePlanOptions,
990    ) -> eyre::Result<Result<(), Error>> {
991        let ExecuteRebasePlanOptions {
992            now: _,
993            event_tx_id: _,
994            preserve_timestamps,
995            force_in_memory: _,
996            force_on_disk: _,
997            resolve_merge_conflicts: _,
998            check_out_commit_options: _, // Checkout happens after rebase has concluded.
999        } = options;
1000
1001        let (effects, _progress) = effects.start_operation(OperationType::InitializeRebase);
1002
1003        let head_info = repo.get_head_info()?;
1004
1005        let current_operation_type = repo.get_current_operation_type();
1006        if let Some(current_operation_type) = current_operation_type {
1007            return Ok(Err(Error::OperationAlreadyInProgress {
1008                operation_type: current_operation_type.to_string(),
1009            }));
1010        }
1011
1012        if repo.has_changed_files(&effects, git_run_info)? {
1013            return Ok(Err(Error::ChangedFilesInRepository));
1014        }
1015
1016        let rebase_state_dir = repo.get_rebase_state_dir_path();
1017        std::fs::create_dir_all(&rebase_state_dir).wrap_err_with(|| {
1018            format!(
1019                "Creating rebase state directory at: {:?}",
1020                &rebase_state_dir
1021            )
1022        })?;
1023
1024        // Mark this rebase as an interactive rebase. For whatever reason, if this
1025        // is not marked as an interactive rebase, then some rebase plans fail with
1026        // this error:
1027        //
1028        // ```
1029        // BUG: builtin/rebase.c:1178: Unhandled rebase type 1
1030        // ```
1031        let interactive_file_path = rebase_state_dir.join("interactive");
1032        std::fs::write(&interactive_file_path, "")
1033            .wrap_err_with(|| format!("Writing interactive to: {:?}", &interactive_file_path))?;
1034
1035        if let Some(head_oid) = head_info.oid {
1036            let orig_head_file_path = repo.get_path().join("ORIG_HEAD");
1037            std::fs::write(&orig_head_file_path, head_oid.to_string())
1038                .wrap_err_with(|| format!("Writing `ORIG_HEAD` to: {:?}", &orig_head_file_path))?;
1039
1040            // Confusingly, there is also a file at
1041            // `.git/rebase-merge/orig-head` (different from `.git/ORIG_HEAD`),
1042            // which seems to store the same thing.
1043            let rebase_orig_head_file_path = rebase_state_dir.join("orig-head");
1044            std::fs::write(&rebase_orig_head_file_path, head_oid.to_string()).wrap_err_with(
1045                || format!("Writing `orig-head` to: {:?}", &rebase_orig_head_file_path),
1046            )?;
1047
1048            // `head-name` contains the name of the branch which will be reset
1049            // to point to the OID contained in `orig-head` when the rebase is
1050            // aborted.
1051            let head_name_file_path = rebase_state_dir.join("head-name");
1052            std::fs::write(
1053                &head_name_file_path,
1054                head_info
1055                    .reference_name
1056                    .as_ref()
1057                    .map(|reference_name| reference_name.as_str())
1058                    .unwrap_or("detached HEAD"),
1059            )
1060            .wrap_err_with(|| format!("Writing head-name to: {:?}", &head_name_file_path))?;
1061
1062            save_original_head_info(repo, &head_info)?;
1063
1064            // Dummy `head` file. We will `reset` to the appropriate commit as soon as
1065            // we start the rebase.
1066            let rebase_merge_head_file_path = rebase_state_dir.join("head");
1067            std::fs::write(
1068                &rebase_merge_head_file_path,
1069                rebase_plan.first_dest_oid.to_string(),
1070            )
1071            .wrap_err_with(|| format!("Writing head to: {:?}", &rebase_merge_head_file_path))?;
1072        }
1073
1074        // Dummy `onto` file. We may be rebasing onto a set of unrelated
1075        // nodes in the same operation, so there may not be a single "onto" node to
1076        // refer to.
1077        let onto_file_path = rebase_state_dir.join("onto");
1078        std::fs::write(&onto_file_path, rebase_plan.first_dest_oid.to_string()).wrap_err_with(
1079            || {
1080                format!(
1081                    "Writing onto {:?} to: {:?}",
1082                    &rebase_plan.first_dest_oid, &onto_file_path
1083                )
1084            },
1085        )?;
1086
1087        if rebase_plan.commands.iter().any(|command| match command {
1088            RebaseCommand::Pick {
1089                original_commit_oid,
1090                commits_to_apply_oids,
1091            } => !commits_to_apply_oids
1092                .iter()
1093                .any(|oid| oid == original_commit_oid),
1094            _ => false,
1095        }) {
1096            eyre::bail!("Not implemented: replacing commits in an on disk rebase");
1097        }
1098
1099        let todo_file_path = rebase_state_dir.join("git-rebase-todo");
1100        #[allow(clippy::format_collect)]
1101        std::fs::write(
1102            &todo_file_path,
1103            rebase_plan
1104                .commands
1105                .iter()
1106                .map(|command| format!("{}\n", command.to_rebase_command()))
1107                .collect::<String>(),
1108        )
1109        .wrap_err_with(|| {
1110            format!(
1111                "Writing `git-rebase-todo` to: {:?}",
1112                todo_file_path.as_path()
1113            )
1114        })?;
1115
1116        let end_file_path = rebase_state_dir.join("end");
1117        std::fs::write(
1118            end_file_path.as_path(),
1119            format!("{}\n", rebase_plan.commands.len()),
1120        )
1121        .wrap_err_with(|| format!("Writing `end` to: {:?}", end_file_path.as_path()))?;
1122
1123        // Corresponds to the `--empty=keep` flag. We'll drop the commits later once
1124        // we find out that they're empty.
1125        let keep_redundant_commits_file_path = rebase_state_dir.join("keep_redundant_commits");
1126        std::fs::write(&keep_redundant_commits_file_path, "").wrap_err_with(|| {
1127            format!(
1128                "Writing `keep_redundant_commits` to: {:?}",
1129                &keep_redundant_commits_file_path
1130            )
1131        })?;
1132
1133        if *preserve_timestamps {
1134            let cdate_is_adate_file_path = rebase_state_dir.join("cdate_is_adate");
1135            std::fs::write(&cdate_is_adate_file_path, "").wrap_err_with(|| {
1136                format!(
1137                    "Writing `cdate_is_adate` option file to: {:?}",
1138                    &cdate_is_adate_file_path
1139                )
1140            })?;
1141        }
1142
1143        // Make sure we don't move around the current branch unintentionally. If it
1144        // actually needs to be moved, then it will be moved as part of the
1145        // post-rebase operations.
1146        if head_info.oid.is_some() {
1147            repo.detach_head(&head_info)?;
1148        }
1149
1150        Ok(Ok(()))
1151    }
1152
1153    /// Rebase on-disk. We don't use `git2`'s `Rebase` machinery because it ends up
1154    /// being too slow.
1155    ///
1156    /// Note that this calls `git rebase`, which may fail (e.g. if there are
1157    /// merge conflicts). The exit code is then propagated to the caller.
1158    #[instrument]
1159    pub fn rebase_on_disk(
1160        effects: &Effects,
1161        git_run_info: &GitRunInfo,
1162        repo: &Repo,
1163        rebase_plan: &RebasePlan,
1164        options: &ExecuteRebasePlanOptions,
1165    ) -> eyre::Result<Result<ExitCode, Error>> {
1166        let ExecuteRebasePlanOptions {
1167            // `git rebase` will make its own timestamp.
1168            now: _,
1169            event_tx_id,
1170            preserve_timestamps: _,
1171            force_in_memory: _,
1172            force_on_disk: _,
1173            resolve_merge_conflicts: _,
1174            check_out_commit_options: _, // Checkout happens after rebase has concluded.
1175        } = options;
1176
1177        match write_rebase_state_to_disk(effects, git_run_info, repo, rebase_plan, options)? {
1178            Ok(()) => {}
1179            Err(err) => return Ok(Err(err)),
1180        };
1181
1182        writeln!(
1183            effects.get_output_stream(),
1184            "Calling Git for on-disk rebase..."
1185        )?;
1186        match git_run_info.run(effects, Some(*event_tx_id), &["rebase", "--continue"])? {
1187            Ok(()) => Ok(Ok(ExitCode::success())),
1188            Err(err) => Ok(Ok(err)),
1189        }
1190    }
1191}
1192
1193/// Options to use when executing a `RebasePlan`.
1194#[derive(Clone, Debug)]
1195pub struct ExecuteRebasePlanOptions {
1196    /// The time which should be recorded for this event.
1197    pub now: SystemTime,
1198
1199    /// The transaction ID for this event.
1200    pub event_tx_id: EventTransactionId,
1201
1202    /// If `true`, any rewritten commits will keep the same authored and
1203    /// committed timestamps. If `false`, the committed timestamps will be updated
1204    /// to the current time.
1205    pub preserve_timestamps: bool,
1206
1207    /// Force an in-memory rebase (as opposed to an on-disk rebase).
1208    pub force_in_memory: bool,
1209
1210    /// Force an on-disk rebase (as opposed to an in-memory rebase).
1211    pub force_on_disk: bool,
1212
1213    /// Whether or not an attempt should be made to resolve merge conflicts,
1214    /// rather than failing-fast.
1215    pub resolve_merge_conflicts: bool,
1216
1217    /// If `HEAD` was moved, the options for checking out the new `HEAD` commit.
1218    pub check_out_commit_options: CheckOutCommitOptions,
1219}
1220
1221/// The result of executing a rebase plan.
1222#[must_use]
1223#[derive(Debug)]
1224pub enum ExecuteRebasePlanResult {
1225    /// The rebase operation succeeded.
1226    Succeeded {
1227        /// Mapping from old OID to new/rewritten OID. Will always be empty for on disk rebases.
1228        rewritten_oids: Option<HashMap<NonZeroOid, MaybeZeroOid>>,
1229    },
1230
1231    /// The rebase operation encounter a failure to merge, and it was not
1232    /// requested to try to resolve it.
1233    DeclinedToMerge {
1234        /// Information about the merge failure that occurred.
1235        failed_merge_info: FailedMergeInfo,
1236    },
1237
1238    /// The rebase operation failed.
1239    Failed {
1240        /// The exit code to exit with. (This value may have been obtained from
1241        /// a subcommand invocation.)
1242        exit_code: ExitCode,
1243    },
1244}
1245
1246/// Execute the provided rebase plan. Returns the exit status (zero indicates
1247/// success).
1248pub fn execute_rebase_plan(
1249    effects: &Effects,
1250    git_run_info: &GitRunInfo,
1251    repo: &Repo,
1252    event_log_db: &EventLogDb,
1253    rebase_plan: &RebasePlan,
1254    options: &ExecuteRebasePlanOptions,
1255) -> eyre::Result<ExecuteRebasePlanResult> {
1256    let ExecuteRebasePlanOptions {
1257        now: _,
1258        event_tx_id: _,
1259        preserve_timestamps: _,
1260        force_in_memory,
1261        force_on_disk,
1262        resolve_merge_conflicts,
1263        check_out_commit_options: _,
1264    } = options;
1265
1266    if !force_on_disk {
1267        use in_memory::*;
1268        writeln!(
1269            effects.get_output_stream(),
1270            "Attempting rebase in-memory..."
1271        )?;
1272
1273        let failed_merge_info = match rebase_in_memory(effects, repo, rebase_plan, options)? {
1274            RebaseInMemoryResult::MergeFailed(failed_merge_info) => failed_merge_info,
1275
1276            RebaseInMemoryResult::Succeeded {
1277                rewritten_oids,
1278                new_head_oid,
1279            } => {
1280                // Ignore the return code, as it probably indicates that the
1281                // checkout failed (which might happen if the user has changes
1282                // which don't merge cleanly). The user can resolve that
1283                // themselves.
1284                match post_rebase_in_memory(
1285                    effects,
1286                    git_run_info,
1287                    repo,
1288                    event_log_db,
1289                    &rewritten_oids,
1290                    new_head_oid,
1291                    options,
1292                )? {
1293                    Ok(()) => {}
1294                    Err(_exit_code) => {
1295                        // FIXME: we may still want to propagate the exit code to the
1296                        // caller.
1297                    }
1298                }
1299
1300                writeln!(effects.get_output_stream(), "In-memory rebase succeeded.")?;
1301                return Ok(ExecuteRebasePlanResult::Succeeded {
1302                    rewritten_oids: Some(rewritten_oids),
1303                });
1304            }
1305        };
1306
1307        if !resolve_merge_conflicts {
1308            return Ok(ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info });
1309        }
1310
1311        // The rebase has failed at this point, decide whether or not to try
1312        // again with an on-disk rebase.
1313        if *force_in_memory {
1314            writeln!(
1315                effects.get_output_stream(),
1316                "Aborting since an in-memory rebase was requested."
1317            )?;
1318            return Ok(ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info });
1319        } else {
1320            writeln!(
1321                effects.get_output_stream(),
1322                "Failed to merge in-memory, trying again on-disk..."
1323            )?;
1324        }
1325    }
1326
1327    if !force_in_memory {
1328        use on_disk::*;
1329        match rebase_on_disk(effects, git_run_info, repo, rebase_plan, options)? {
1330            Ok(exit_code) if exit_code.is_success() => {
1331                return Ok(ExecuteRebasePlanResult::Succeeded {
1332                    rewritten_oids: None,
1333                });
1334            }
1335            Ok(exit_code) => return Ok(ExecuteRebasePlanResult::Failed { exit_code }),
1336            Err(Error::ChangedFilesInRepository) => {
1337                write!(
1338                    effects.get_output_stream(),
1339                    "\
1340This operation would modify the working copy, but you have uncommitted changes
1341in your working copy which might be overwritten as a result.
1342Commit your changes and then try again.
1343"
1344                )?;
1345                return Ok(ExecuteRebasePlanResult::Failed {
1346                    exit_code: ExitCode(1),
1347                });
1348            }
1349            Err(Error::OperationAlreadyInProgress { operation_type }) => {
1350                writeln!(
1351                    effects.get_output_stream(),
1352                    "A {operation_type} operation is already in progress."
1353                )?;
1354                writeln!(
1355                    effects.get_output_stream(),
1356                    "Run git {operation_type} --continue or git {operation_type} --abort to resolve it and proceed."
1357                )?;
1358                return Ok(ExecuteRebasePlanResult::Failed {
1359                    exit_code: ExitCode(1),
1360                });
1361            }
1362        }
1363    }
1364
1365    eyre::bail!("Both force_in_memory and force_on_disk were requested, but these options conflict")
1366}