Skip to main content

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::{CheckOutCommitOptions, CheckoutTarget, check_out_commit};
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            dry_run: _,
502            resolve_merge_conflicts: _, // May be needed once we can resolve merge conflicts in memory.
503            check_out_commit_options: _, // Caller is responsible for checking out to new HEAD.
504        } = options;
505
506        let mut current_oid = rebase_plan.first_dest_oid;
507        let mut labels: HashMap<String, NonZeroOid> = HashMap::new();
508        let mut rewritten_oids: HashMap<NonZeroOid, MaybeZeroOid> = HashMap::new();
509
510        // Normally, we can determine the new `HEAD` OID by looking at the
511        // rewritten commits. However, if `HEAD` pointed to a commit that was
512        // skipped, then the rewritten OID is zero. In that case, we need to
513        // delete the branch (responsibility of the caller) and choose a
514        // different `HEAD` OID.
515        let head_oid = repo.get_head_info()?.oid;
516        let mut skipped_head_new_oid = None;
517        let mut maybe_set_skipped_head_new_oid = |skipped_head_oid, current_oid| {
518            if Some(skipped_head_oid) == head_oid {
519                skipped_head_new_oid.get_or_insert(current_oid);
520            }
521        };
522
523        let mut i = 0;
524        let num_picks = rebase_plan
525            .commands
526            .iter()
527            .filter(|command| match command {
528                RebaseCommand::CreateLabel { .. }
529                | RebaseCommand::Reset { .. }
530                | RebaseCommand::Break
531                | RebaseCommand::RegisterExtraPostRewriteHook
532                | RebaseCommand::DetectEmptyCommit { .. } => false,
533                RebaseCommand::Pick { .. }
534                | RebaseCommand::Merge { .. }
535                | RebaseCommand::Replace { .. }
536                | RebaseCommand::SkipUpstreamAppliedCommit { .. } => true,
537            })
538            .count();
539        let (effects, progress) = effects.start_operation(OperationType::RebaseCommits);
540
541        for command in rebase_plan.commands.iter() {
542            match command {
543                RebaseCommand::CreateLabel { label_name } => {
544                    labels.insert(label_name.clone(), current_oid);
545                }
546
547                RebaseCommand::Reset {
548                    target: OidOrLabel::Label(label_name),
549                } => {
550                    current_oid = match labels.get(label_name) {
551                        Some(oid) => *oid,
552                        None => eyre::bail!("BUG: no associated OID for label: {label_name}"),
553                    };
554                }
555
556                RebaseCommand::Reset {
557                    target: OidOrLabel::Oid(commit_oid),
558                } => {
559                    current_oid = match rewritten_oids.get(commit_oid) {
560                        Some(MaybeZeroOid::NonZero(rewritten_oid)) => {
561                            // HEAD has been rewritten.
562                            *rewritten_oid
563                        }
564                        Some(MaybeZeroOid::Zero) | None => {
565                            // Either HEAD was not rewritten, or it was but its
566                            // associated commit was skipped. Either way, just
567                            // use the current OID.
568                            *commit_oid
569                        }
570                    };
571                }
572
573                RebaseCommand::Pick {
574                    original_commit_oid,
575                    commits_to_apply_oids,
576                } => {
577                    let current_commit = repo
578                        .find_commit_or_fail(current_oid)
579                        .wrap_err("Finding current commit")?;
580
581                    let original_commit = repo
582                        .find_commit_or_fail(*original_commit_oid)
583                        .wrap_err("Finding commit to apply")?;
584                    i += 1;
585
586                    let commit_num = format!("[{i}/{num_picks}]");
587                    progress.notify_progress(i, num_picks);
588
589                    let commit_message = original_commit.get_message_raw();
590                    let commit_message = commit_message.to_str().with_context(|| {
591                        eyre::eyre!(
592                            "Could not decode commit message for commit: {:?}",
593                            original_commit_oid
594                        )
595                    })?;
596
597                    let commit_author = original_commit.get_author();
598                    let committer_signature = if *preserve_timestamps {
599                        original_commit.get_committer()
600                    } else {
601                        original_commit.get_committer().update_timestamp(*now)?
602                    };
603                    let mut rebased_commit_oid = None;
604                    let mut rebased_commit = None;
605
606                    for commit_oid in commits_to_apply_oids.iter() {
607                        let commit_to_apply = repo
608                            .find_commit_or_fail(*commit_oid)
609                            .wrap_err("Finding commit to apply")?;
610                        let commit_description = effects
611                            .get_glyphs()
612                            .render(commit_to_apply.friendly_describe(effects.get_glyphs())?)?;
613
614                        if commit_to_apply.get_parent_count() > 1 {
615                            warn!(
616                                ?commit_oid,
617                                "BUG: Merge commit should have been detected during planning phase"
618                            );
619                            return Ok(RebaseInMemoryResult::MergeFailed(
620                                FailedMergeInfo::CannotRebaseMergeInMemory {
621                                    commit_oid: *commit_oid,
622                                },
623                            ));
624                        };
625
626                        progress.notify_status(
627                            OperationIcon::InProgress,
628                            format!("Applying patch for commit: {commit_description}"),
629                        );
630
631                        // Create a commit and then repeatedly amend & re-create it
632                        // FIXME what #perf gains can be had by working directly on a tree?
633                        // Is it even possible to repeatedly amend a tree and then commit
634                        // it once at the end?
635
636                        let maybe_tree = if rebased_commit.is_none() {
637                            repo.cherry_pick_fast(
638                                &commit_to_apply,
639                                &current_commit,
640                                &CherryPickFastOptions {
641                                    reuse_parent_tree_if_possible: true,
642                                },
643                            )
644                        } else {
645                            repo.amend_fast(
646                                &rebased_commit.expect("rebased commit should not be None"),
647                                &AmendFastOptions::FromCommit {
648                                    commit: commit_to_apply,
649                                },
650                            )
651                        };
652                        let commit_tree = match maybe_tree {
653                            Ok(tree) => tree,
654                            Err(CreateCommitFastError::MergeConflict { conflicting_paths }) => {
655                                return Ok(RebaseInMemoryResult::MergeFailed(
656                                    FailedMergeInfo::Conflict {
657                                        commit_oid: *commit_oid,
658                                        conflicting_paths,
659                                    },
660                                ));
661                            }
662                            Err(other) => eyre::bail!(other),
663                        };
664
665                        // this is the description of each fixup commit
666                        // FIXME should we instead be using the description of the base commit?
667                        // or use a different message altogether when squashing multiple commits?
668                        progress.notify_status(
669                            OperationIcon::InProgress,
670                            format!("Committing to repository: {commit_description}"),
671                        );
672                        rebased_commit_oid = Some(
673                            repo.create_commit(
674                                None,
675                                &commit_author,
676                                &committer_signature,
677                                commit_message,
678                                &commit_tree,
679                                vec![&current_commit],
680                            )
681                            .wrap_err("Applying rebased commit")?,
682                        );
683
684                        rebased_commit = repo.find_commit(rebased_commit_oid.unwrap())?;
685                    }
686
687                    let rebased_commit_oid =
688                        rebased_commit_oid.expect("rebased oid should not be None");
689                    let commit_description =
690                        effects
691                            .get_glyphs()
692                            .render(repo.friendly_describe_commit_from_oid(
693                                effects.get_glyphs(),
694                                rebased_commit_oid,
695                            )?)?;
696
697                    if rebased_commit
698                        .expect("rebased commit should not be None")
699                        .is_empty()
700                    {
701                        rewritten_oids.insert(*original_commit_oid, MaybeZeroOid::Zero);
702                        maybe_set_skipped_head_new_oid(*original_commit_oid, current_oid);
703
704                        writeln!(
705                            effects.get_output_stream(),
706                            "{commit_num} Skipped now-empty commit: {commit_description}",
707                        )?;
708                    } else {
709                        rewritten_oids.insert(
710                            *original_commit_oid,
711                            MaybeZeroOid::NonZero(rebased_commit_oid),
712                        );
713                        for commit_oid in commits_to_apply_oids {
714                            rewritten_oids
715                                .insert(*commit_oid, MaybeZeroOid::NonZero(rebased_commit_oid));
716                        }
717
718                        current_oid = rebased_commit_oid;
719
720                        writeln!(
721                            effects.get_output_stream(),
722                            "{commit_num} Committed as: {commit_description}"
723                        )?;
724                    }
725                }
726
727                RebaseCommand::Merge {
728                    commit_oid,
729                    commits_to_merge: _,
730                } => {
731                    warn!(
732                        ?commit_oid,
733                        "BUG: Merge commit without replacement should have been detected when starting in-memory rebase"
734                    );
735                    return Ok(RebaseInMemoryResult::MergeFailed(
736                        FailedMergeInfo::CannotRebaseMergeInMemory {
737                            commit_oid: *commit_oid,
738                        },
739                    ));
740                }
741
742                RebaseCommand::Replace {
743                    commit_oid,
744                    replacement_commit_oid,
745                    parents,
746                } => {
747                    let original_commit = repo
748                        .find_commit_or_fail(*commit_oid)
749                        .wrap_err("Finding current commit")?;
750                    let original_commit_description = effects
751                        .get_glyphs()
752                        .render(original_commit.friendly_describe(effects.get_glyphs())?)?;
753
754                    i += 1;
755                    let commit_num = format!("[{i}/{num_picks}]");
756                    progress.notify_progress(i, num_picks);
757                    progress.notify_status(
758                        OperationIcon::InProgress,
759                        format!("Replacing commit: {original_commit_description}"),
760                    );
761
762                    let replacement_commit = repo.find_commit_or_fail(*replacement_commit_oid)?;
763                    let replacement_tree = replacement_commit.get_tree()?;
764                    let replacement_message = replacement_commit.get_message_raw();
765                    let replacement_commit_message =
766                        replacement_message.to_str().with_context(|| {
767                            eyre::eyre!(
768                                "Could not decode commit message for replacement commit: {:?}",
769                                replacement_commit
770                            )
771                        })?;
772
773                    let replacement_commit_description = effects
774                        .get_glyphs()
775                        .render(replacement_commit.friendly_describe(effects.get_glyphs())?)?;
776                    progress.notify_status(
777                        OperationIcon::InProgress,
778                        format!("Committing to repository: {replacement_commit_description}"),
779                    );
780                    let committer_signature = if *preserve_timestamps {
781                        replacement_commit.get_committer()
782                    } else {
783                        replacement_commit.get_committer().update_timestamp(*now)?
784                    };
785                    let parents = {
786                        let mut result = Vec::new();
787                        for parent in parents {
788                            let parent_oid = match parent {
789                                OidOrLabel::Oid(oid) => *oid,
790                                OidOrLabel::Label(label) => {
791                                    let oid = labels.get(label).ok_or_else(|| {
792                                        eyre::eyre!(
793                                            "Label {label} could not be resolved to a commit"
794                                        )
795                                    })?;
796                                    *oid
797                                }
798                            };
799                            let parent_commit = repo.find_commit_or_fail(parent_oid)?;
800                            result.push(parent_commit);
801                        }
802                        result
803                    };
804                    let rebased_commit_oid = repo
805                        .create_commit(
806                            None,
807                            &replacement_commit.get_author(),
808                            &committer_signature,
809                            replacement_commit_message,
810                            &replacement_tree,
811                            parents.iter().collect(),
812                        )
813                        .wrap_err("Applying rebased commit")?;
814
815                    let commit_description =
816                        effects
817                            .get_glyphs()
818                            .render(repo.friendly_describe_commit_from_oid(
819                                effects.get_glyphs(),
820                                rebased_commit_oid,
821                            )?)?;
822                    rewritten_oids.insert(*commit_oid, MaybeZeroOid::NonZero(rebased_commit_oid));
823                    current_oid = rebased_commit_oid;
824
825                    writeln!(
826                        effects.get_output_stream(),
827                        "{commit_num} Committed as: {commit_description}"
828                    )?;
829                }
830
831                RebaseCommand::Break => {
832                    eyre::bail!("`break` not supported for in-memory rebases");
833                }
834
835                RebaseCommand::SkipUpstreamAppliedCommit { commit_oid } => {
836                    i += 1;
837                    let commit_num = format!("[{i}/{num_picks}]");
838
839                    let commit = repo.find_commit_or_fail(*commit_oid)?;
840                    rewritten_oids.insert(*commit_oid, MaybeZeroOid::Zero);
841                    maybe_set_skipped_head_new_oid(*commit_oid, current_oid);
842
843                    let commit_description = commit.friendly_describe(effects.get_glyphs())?;
844                    let commit_description = effects.get_glyphs().render(commit_description)?;
845                    writeln!(
846                        effects.get_output_stream(),
847                        "{commit_num} Skipped commit (was already applied upstream): {commit_description}"
848                    )?;
849                }
850
851                RebaseCommand::RegisterExtraPostRewriteHook
852                | RebaseCommand::DetectEmptyCommit { .. } => {
853                    // Do nothing. We'll carry out post-rebase operations after the
854                    // in-memory rebase completes.
855                }
856            }
857        }
858
859        let new_head_oid: Option<NonZeroOid> = match head_oid {
860            None => {
861                // `HEAD` is unborn, so keep it that way.
862                None
863            }
864            Some(head_oid) => {
865                match rewritten_oids.get(&head_oid) {
866                    Some(MaybeZeroOid::NonZero(new_head_oid)) => {
867                        // `HEAD` was rewritten to this OID.
868                        Some(*new_head_oid)
869                    }
870                    Some(MaybeZeroOid::Zero) => {
871                        // `HEAD` was rewritten, but its associated commit was
872                        // skipped. Use whatever saved new `HEAD` OID we have.
873                        let new_head_oid = match skipped_head_new_oid {
874                            Some(new_head_oid) => new_head_oid,
875                            None => {
876                                warn!(
877                                    ?head_oid,
878                                    "`HEAD` OID was rewritten to 0, but no skipped `HEAD` OID was set",
879                                );
880                                head_oid
881                            }
882                        };
883                        Some(new_head_oid)
884                    }
885                    None => {
886                        // The `HEAD` OID was not rewritten, so use its current value.
887                        Some(head_oid)
888                    }
889                }
890            }
891        };
892        Ok(RebaseInMemoryResult::Succeeded {
893            rewritten_oids,
894            new_head_oid,
895        })
896    }
897
898    pub fn post_rebase_in_memory(
899        effects: &Effects,
900        git_run_info: &GitRunInfo,
901        repo: &Repo,
902        event_log_db: &EventLogDb,
903        rewritten_oids: &HashMap<NonZeroOid, MaybeZeroOid>,
904        skipped_head_updated_oid: Option<NonZeroOid>,
905        options: &ExecuteRebasePlanOptions,
906    ) -> EyreExitOr<()> {
907        let ExecuteRebasePlanOptions {
908            now: _,
909            event_tx_id,
910            preserve_timestamps: _,
911            force_in_memory: _,
912            force_on_disk: _,
913            dry_run: _,
914            resolve_merge_conflicts: _,
915            check_out_commit_options,
916        } = options;
917
918        for new_oid in rewritten_oids.values() {
919            if let MaybeZeroOid::NonZero(new_oid) = new_oid {
920                mark_commit_reachable(repo, *new_oid)?;
921            }
922        }
923
924        let head_info = repo.get_head_info()?;
925        if head_info.oid.is_some() {
926            // Avoid moving the branch which HEAD points to, or else the index will show
927            // a lot of changes in the working copy.
928            repo.detach_head(&head_info)?;
929        }
930
931        move_branches(effects, git_run_info, repo, *event_tx_id, rewritten_oids)?;
932
933        // Call the `post-rewrite` hook only after moving branches so that we don't
934        // produce a spurious abandoned-branch warning.
935        #[allow(clippy::format_collect)]
936        let post_rewrite_stdin: String = rewritten_oids
937            .iter()
938            .map(|(old_oid, new_oid)| format!("{old_oid} {new_oid}\n"))
939            .collect();
940        let post_rewrite_stdin = BString::from(post_rewrite_stdin);
941        git_run_info.run_hook(
942            effects,
943            repo,
944            "post-rewrite",
945            *event_tx_id,
946            &["rebase"],
947            Some(post_rewrite_stdin),
948        )?;
949
950        let exit_code = check_out_updated_head(
951            effects,
952            git_run_info,
953            repo,
954            event_log_db,
955            *event_tx_id,
956            rewritten_oids,
957            &head_info,
958            skipped_head_updated_oid,
959            check_out_commit_options,
960        )?;
961        Ok(exit_code)
962    }
963}
964
965mod on_disk {
966    use std::fmt::Write;
967
968    use eyre::Context;
969    use tracing::instrument;
970
971    use crate::core::effects::{Effects, OperationType};
972    use crate::core::rewrite::plan::RebaseCommand;
973    use crate::core::rewrite::plan::RebasePlan;
974    use crate::core::rewrite::rewrite_hooks::save_original_head_info;
975    use crate::git::{GitRunInfo, Repo};
976
977    use crate::util::ExitCode;
978
979    use super::ExecuteRebasePlanOptions;
980
981    pub enum Error {
982        ChangedFilesInRepository,
983        OperationAlreadyInProgress { operation_type: String },
984    }
985
986    fn write_rebase_state_to_disk(
987        effects: &Effects,
988        git_run_info: &GitRunInfo,
989        repo: &Repo,
990        rebase_plan: &RebasePlan,
991        options: &ExecuteRebasePlanOptions,
992    ) -> eyre::Result<Result<(), Error>> {
993        let ExecuteRebasePlanOptions {
994            now: _,
995            event_tx_id: _,
996            preserve_timestamps,
997            force_in_memory: _,
998            force_on_disk: _,
999            dry_run: _,
1000            resolve_merge_conflicts: _,
1001            check_out_commit_options: _, // Checkout happens after rebase has concluded.
1002        } = options;
1003
1004        let (effects, _progress) = effects.start_operation(OperationType::InitializeRebase);
1005
1006        let head_info = repo.get_head_info()?;
1007
1008        let current_operation_type = repo.get_current_operation_type();
1009        if let Some(current_operation_type) = current_operation_type {
1010            return Ok(Err(Error::OperationAlreadyInProgress {
1011                operation_type: current_operation_type.to_string(),
1012            }));
1013        }
1014
1015        if repo.has_changed_files(&effects, git_run_info)? {
1016            return Ok(Err(Error::ChangedFilesInRepository));
1017        }
1018
1019        let rebase_state_dir = repo.get_rebase_state_dir_path();
1020        std::fs::create_dir_all(&rebase_state_dir).wrap_err_with(|| {
1021            format!(
1022                "Creating rebase state directory at: {:?}",
1023                &rebase_state_dir
1024            )
1025        })?;
1026
1027        // Mark this rebase as an interactive rebase. For whatever reason, if this
1028        // is not marked as an interactive rebase, then some rebase plans fail with
1029        // this error:
1030        //
1031        // ```
1032        // BUG: builtin/rebase.c:1178: Unhandled rebase type 1
1033        // ```
1034        let interactive_file_path = rebase_state_dir.join("interactive");
1035        std::fs::write(&interactive_file_path, "")
1036            .wrap_err_with(|| format!("Writing interactive to: {:?}", &interactive_file_path))?;
1037
1038        if let Some(head_oid) = head_info.oid {
1039            let orig_head_file_path = repo.get_path().join("ORIG_HEAD");
1040            std::fs::write(&orig_head_file_path, head_oid.to_string())
1041                .wrap_err_with(|| format!("Writing `ORIG_HEAD` to: {:?}", &orig_head_file_path))?;
1042
1043            // Confusingly, there is also a file at
1044            // `.git/rebase-merge/orig-head` (different from `.git/ORIG_HEAD`),
1045            // which seems to store the same thing.
1046            let rebase_orig_head_file_path = rebase_state_dir.join("orig-head");
1047            std::fs::write(&rebase_orig_head_file_path, head_oid.to_string()).wrap_err_with(
1048                || format!("Writing `orig-head` to: {:?}", &rebase_orig_head_file_path),
1049            )?;
1050
1051            // `head-name` contains the name of the branch which will be reset
1052            // to point to the OID contained in `orig-head` when the rebase is
1053            // aborted.
1054            let head_name_file_path = rebase_state_dir.join("head-name");
1055            std::fs::write(
1056                &head_name_file_path,
1057                head_info
1058                    .reference_name
1059                    .as_ref()
1060                    .map(|reference_name| reference_name.as_str())
1061                    .unwrap_or("detached HEAD"),
1062            )
1063            .wrap_err_with(|| format!("Writing head-name to: {:?}", &head_name_file_path))?;
1064
1065            save_original_head_info(repo, &head_info)?;
1066
1067            // Dummy `head` file. We will `reset` to the appropriate commit as soon as
1068            // we start the rebase.
1069            let rebase_merge_head_file_path = rebase_state_dir.join("head");
1070            std::fs::write(
1071                &rebase_merge_head_file_path,
1072                rebase_plan.first_dest_oid.to_string(),
1073            )
1074            .wrap_err_with(|| format!("Writing head to: {:?}", &rebase_merge_head_file_path))?;
1075        }
1076
1077        // Dummy `onto` file. We may be rebasing onto a set of unrelated
1078        // nodes in the same operation, so there may not be a single "onto" node to
1079        // refer to.
1080        let onto_file_path = rebase_state_dir.join("onto");
1081        std::fs::write(&onto_file_path, rebase_plan.first_dest_oid.to_string()).wrap_err_with(
1082            || {
1083                format!(
1084                    "Writing onto {:?} to: {:?}",
1085                    &rebase_plan.first_dest_oid, &onto_file_path
1086                )
1087            },
1088        )?;
1089
1090        if rebase_plan.commands.iter().any(|command| match command {
1091            RebaseCommand::Pick {
1092                original_commit_oid,
1093                commits_to_apply_oids,
1094            } => !commits_to_apply_oids
1095                .iter()
1096                .any(|oid| oid == original_commit_oid),
1097            _ => false,
1098        }) {
1099            eyre::bail!("Not implemented: replacing commits in an on disk rebase");
1100        }
1101
1102        let todo_file_path = rebase_state_dir.join("git-rebase-todo");
1103        #[allow(clippy::format_collect)]
1104        std::fs::write(
1105            &todo_file_path,
1106            rebase_plan
1107                .commands
1108                .iter()
1109                .map(|command| format!("{}\n", command.to_rebase_command()))
1110                .collect::<String>(),
1111        )
1112        .wrap_err_with(|| {
1113            format!(
1114                "Writing `git-rebase-todo` to: {:?}",
1115                todo_file_path.as_path()
1116            )
1117        })?;
1118
1119        let end_file_path = rebase_state_dir.join("end");
1120        std::fs::write(
1121            end_file_path.as_path(),
1122            format!("{}\n", rebase_plan.commands.len()),
1123        )
1124        .wrap_err_with(|| format!("Writing `end` to: {:?}", end_file_path.as_path()))?;
1125
1126        // Corresponds to the `--empty=keep` flag. We'll drop the commits later once
1127        // we find out that they're empty.
1128        let keep_redundant_commits_file_path = rebase_state_dir.join("keep_redundant_commits");
1129        std::fs::write(&keep_redundant_commits_file_path, "").wrap_err_with(|| {
1130            format!(
1131                "Writing `keep_redundant_commits` to: {:?}",
1132                &keep_redundant_commits_file_path
1133            )
1134        })?;
1135
1136        if *preserve_timestamps {
1137            let cdate_is_adate_file_path = rebase_state_dir.join("cdate_is_adate");
1138            std::fs::write(&cdate_is_adate_file_path, "").wrap_err_with(|| {
1139                format!(
1140                    "Writing `cdate_is_adate` option file to: {:?}",
1141                    &cdate_is_adate_file_path
1142                )
1143            })?;
1144        }
1145
1146        // Make sure we don't move around the current branch unintentionally. If it
1147        // actually needs to be moved, then it will be moved as part of the
1148        // post-rebase operations.
1149        if head_info.oid.is_some() {
1150            repo.detach_head(&head_info)?;
1151        }
1152
1153        Ok(Ok(()))
1154    }
1155
1156    /// Rebase on-disk. We don't use `git2`'s `Rebase` machinery because it ends up
1157    /// being too slow.
1158    ///
1159    /// Note that this calls `git rebase`, which may fail (e.g. if there are
1160    /// merge conflicts). The exit code is then propagated to the caller.
1161    #[instrument]
1162    pub fn rebase_on_disk(
1163        effects: &Effects,
1164        git_run_info: &GitRunInfo,
1165        repo: &Repo,
1166        rebase_plan: &RebasePlan,
1167        options: &ExecuteRebasePlanOptions,
1168    ) -> eyre::Result<Result<ExitCode, Error>> {
1169        let ExecuteRebasePlanOptions {
1170            // `git rebase` will make its own timestamp.
1171            now: _,
1172            event_tx_id,
1173            preserve_timestamps: _,
1174            force_in_memory: _,
1175            force_on_disk: _,
1176            dry_run: _,
1177            resolve_merge_conflicts: _,
1178            check_out_commit_options: _, // Checkout happens after rebase has concluded.
1179        } = options;
1180
1181        match write_rebase_state_to_disk(effects, git_run_info, repo, rebase_plan, options)? {
1182            Ok(()) => {}
1183            Err(err) => return Ok(Err(err)),
1184        };
1185
1186        writeln!(
1187            effects.get_output_stream(),
1188            "Calling Git for on-disk rebase..."
1189        )?;
1190        match git_run_info.run(effects, Some(*event_tx_id), &["rebase", "--continue"])? {
1191            Ok(()) => Ok(Ok(ExitCode::success())),
1192            Err(err) => Ok(Ok(err)),
1193        }
1194    }
1195}
1196
1197/// Options to use when executing a `RebasePlan`.
1198#[derive(Clone, Debug)]
1199pub struct ExecuteRebasePlanOptions {
1200    /// The time which should be recorded for this event.
1201    pub now: SystemTime,
1202
1203    /// The transaction ID for this event.
1204    pub event_tx_id: EventTransactionId,
1205
1206    /// If `true`, any rewritten commits will keep the same authored and
1207    /// committed timestamps. If `false`, the committed timestamps will be updated
1208    /// to the current time.
1209    pub preserve_timestamps: bool,
1210
1211    /// Force an in-memory rebase (as opposed to an on-disk rebase).
1212    pub force_in_memory: bool,
1213
1214    /// Force an on-disk rebase (as opposed to an in-memory rebase).
1215    pub force_on_disk: bool,
1216
1217    /// When attempting an in-memory rebase, only report success or failure,
1218    /// discarding the resulting changes.
1219    pub dry_run: bool,
1220
1221    /// Whether or not an attempt should be made to resolve merge conflicts,
1222    /// rather than failing-fast.
1223    pub resolve_merge_conflicts: bool,
1224
1225    /// If `HEAD` was moved, the options for checking out the new `HEAD` commit.
1226    pub check_out_commit_options: CheckOutCommitOptions,
1227}
1228
1229/// The result of executing a rebase plan.
1230#[must_use]
1231#[derive(Debug)]
1232pub enum ExecuteRebasePlanResult {
1233    /// The rebase operation succeeded.
1234    Succeeded {
1235        /// Mapping from old OID to new/rewritten OID. Will always be empty for on disk rebases.
1236        rewritten_oids: Option<HashMap<NonZeroOid, MaybeZeroOid>>,
1237    },
1238
1239    /// The rebase operation is viable, but was not executed to completion.
1240    WouldSucceed,
1241
1242    /// The rebase operation encountered a failure to merge, and it was not
1243    /// requested to try to resolve it.
1244    DeclinedToMerge {
1245        /// Information about the merge failure that occurred.
1246        failed_merge_info: FailedMergeInfo,
1247    },
1248
1249    /// The rebase operation failed.
1250    Failed {
1251        /// The exit code to exit with. (This value may have been obtained from
1252        /// a subcommand invocation.)
1253        exit_code: ExitCode,
1254    },
1255}
1256
1257/// Execute the provided rebase plan. Returns the exit status (zero indicates
1258/// success).
1259pub fn execute_rebase_plan(
1260    effects: &Effects,
1261    git_run_info: &GitRunInfo,
1262    repo: &Repo,
1263    event_log_db: &EventLogDb,
1264    rebase_plan: &RebasePlan,
1265    options: &ExecuteRebasePlanOptions,
1266) -> eyre::Result<ExecuteRebasePlanResult> {
1267    let ExecuteRebasePlanOptions {
1268        now: _,
1269        event_tx_id: _,
1270        preserve_timestamps: _,
1271        force_in_memory,
1272        force_on_disk,
1273        dry_run,
1274        resolve_merge_conflicts,
1275        check_out_commit_options: _,
1276    } = options;
1277
1278    if !force_on_disk {
1279        use in_memory::*;
1280        writeln!(
1281            effects.get_output_stream(),
1282            "Attempting rebase in-memory..."
1283        )?;
1284
1285        let failed_merge_info = match rebase_in_memory(effects, repo, rebase_plan, options)? {
1286            RebaseInMemoryResult::MergeFailed(failed_merge_info) => failed_merge_info,
1287
1288            RebaseInMemoryResult::Succeeded {
1289                rewritten_oids,
1290                new_head_oid,
1291            } => {
1292                if *dry_run {
1293                    writeln!(
1294                        effects.get_output_stream(),
1295                        "In-memory rebase would succeed."
1296                    )?;
1297                    return Ok(ExecuteRebasePlanResult::WouldSucceed);
1298                }
1299
1300                // Ignore the return code, as it probably indicates that the
1301                // checkout failed (which might happen if the user has changes
1302                // which don't merge cleanly). The user can resolve that
1303                // themselves.
1304                match post_rebase_in_memory(
1305                    effects,
1306                    git_run_info,
1307                    repo,
1308                    event_log_db,
1309                    &rewritten_oids,
1310                    new_head_oid,
1311                    options,
1312                )? {
1313                    Ok(()) => {}
1314                    Err(_exit_code) => {
1315                        // FIXME: we may still want to propagate the exit code to the
1316                        // caller.
1317                    }
1318                }
1319
1320                writeln!(effects.get_output_stream(), "In-memory rebase succeeded.")?;
1321                return Ok(ExecuteRebasePlanResult::Succeeded {
1322                    rewritten_oids: Some(rewritten_oids),
1323                });
1324            }
1325        };
1326
1327        if !resolve_merge_conflicts {
1328            return Ok(ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info });
1329        }
1330
1331        // The rebase has failed at this point, decide whether or not to try
1332        // again with an on-disk rebase.
1333        if *force_in_memory {
1334            writeln!(
1335                effects.get_output_stream(),
1336                "Aborting since an in-memory rebase was requested."
1337            )?;
1338            return Ok(ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info });
1339        } else {
1340            writeln!(
1341                effects.get_output_stream(),
1342                "Failed to merge in-memory, trying again on-disk..."
1343            )?;
1344        }
1345    }
1346
1347    if !force_in_memory {
1348        use on_disk::*;
1349        match rebase_on_disk(effects, git_run_info, repo, rebase_plan, options)? {
1350            Ok(exit_code) if exit_code.is_success() => {
1351                return Ok(ExecuteRebasePlanResult::Succeeded {
1352                    rewritten_oids: None,
1353                });
1354            }
1355            Ok(exit_code) => return Ok(ExecuteRebasePlanResult::Failed { exit_code }),
1356            Err(Error::ChangedFilesInRepository) => {
1357                write!(
1358                    effects.get_output_stream(),
1359                    "\
1360This operation would modify the working copy, but you have uncommitted changes
1361in your working copy which might be overwritten as a result.
1362Commit your changes and then try again.
1363"
1364                )?;
1365                return Ok(ExecuteRebasePlanResult::Failed {
1366                    exit_code: ExitCode(1),
1367                });
1368            }
1369            Err(Error::OperationAlreadyInProgress { operation_type }) => {
1370                writeln!(
1371                    effects.get_output_stream(),
1372                    "A {operation_type} operation is already in progress."
1373                )?;
1374                writeln!(
1375                    effects.get_output_stream(),
1376                    "Run git {operation_type} --continue or git {operation_type} --abort to resolve it and proceed."
1377                )?;
1378                return Ok(ExecuteRebasePlanResult::Failed {
1379                    exit_code: ExitCode(1),
1380                });
1381            }
1382        }
1383    }
1384
1385    eyre::bail!("Both force_in_memory and force_on_disk were requested, but these options conflict")
1386}