Skip to main content

git_branchless_move/
lib.rs

1//! Move commits and subtrees from one place to another.
2//!
3//! Under the hood, this makes use of Git's advanced rebase functionality, which
4//! is also used to preserve merge commits using the `--rebase-merges` option.
5
6#![warn(missing_docs)]
7#![warn(
8    clippy::all,
9    clippy::as_conversions,
10    clippy::clone_on_ref_ptr,
11    clippy::dbg_macro
12)]
13#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)]
14
15use std::collections::HashMap;
16use std::fmt::Write;
17use std::time::SystemTime;
18
19use eden_dag::Vertex;
20use lib::core::repo_ext::RepoExt;
21use lib::util::{ExitCode, EyreExitOr};
22use rayon::ThreadPoolBuilder;
23use tracing::instrument;
24
25use git_branchless_opts::{MoveOptions, ResolveRevsetOptions, Revset};
26use git_branchless_revset::resolve_commits;
27use lib::core::config::{
28    Hint, get_hint_enabled, get_hint_string, get_restack_preserve_timestamps,
29    print_hint_suppression_notice,
30};
31use lib::core::dag::{CommitSet, Dag, sorted_commit_set, union_all};
32use lib::core::effects::Effects;
33use lib::core::eventlog::{EventLogDb, EventReplayer};
34use lib::core::rewrite::{
35    BuildRebasePlanOptions, ExecuteRebasePlanOptions, ExecuteRebasePlanResult,
36    MergeConflictRemediation, RebasePlanBuilder, RebasePlanPermissions, RepoResource,
37    execute_rebase_plan,
38};
39use lib::git::{GitRunInfo, NonZeroOid, Repo};
40
41#[instrument]
42fn resolve_base_commit(
43    dag: &Dag,
44    merge_base_oid: Option<Vertex>,
45    oid: NonZeroOid,
46) -> eyre::Result<NonZeroOid> {
47    let bases = match merge_base_oid {
48        Some(merge_base_oid) => {
49            let range = dag.query_range(CommitSet::from(merge_base_oid), CommitSet::from(oid))?;
50            let roots = dag.query_roots(range.clone())?;
51            dag.query_children(roots)?.intersection(&range)
52        }
53        None => {
54            let ancestors = dag.query_ancestors(CommitSet::from(oid))?;
55            dag.query_roots(ancestors)?
56        }
57    };
58
59    match dag.set_first(&bases)? {
60        Some(base) => NonZeroOid::try_from(base),
61        None => Ok(oid),
62    }
63}
64
65/// Move a subtree from one place to another.
66#[instrument]
67pub fn r#move(
68    effects: &Effects,
69    git_run_info: &GitRunInfo,
70    sources: Vec<Revset>,
71    dest: Option<Revset>,
72    bases: Vec<Revset>,
73    exacts: Vec<Revset>,
74    resolve_revset_options: &ResolveRevsetOptions,
75    move_options: &MoveOptions,
76    fixup: bool,
77    insert: bool,
78    dry_run: bool,
79) -> EyreExitOr<()> {
80    let sources_provided = !sources.is_empty();
81    let bases_provided = !bases.is_empty();
82    let exacts_provided = !exacts.is_empty();
83    let dest_provided = dest.is_some();
84    let should_sources_default_to_head = !sources_provided && !bases_provided && !exacts_provided;
85
86    let repo = Repo::from_current_dir()?;
87    let head_oid = repo.get_head_info()?.oid;
88
89    let dest = match dest {
90        Some(dest) => dest,
91        None => match head_oid {
92            Some(oid) => Revset(oid.to_string()),
93            None => {
94                writeln!(
95                    effects.get_output_stream(),
96                    "No --dest argument was provided, and no OID for HEAD is available as a default"
97                )?;
98                return Ok(Err(ExitCode(1)));
99            }
100        },
101    };
102
103    let references_snapshot = repo.get_references_snapshot()?;
104    let conn = repo.get_db_conn()?;
105    let event_log_db = EventLogDb::new(&conn)?;
106    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
107    let event_cursor = event_replayer.make_default_cursor();
108    let mut dag = Dag::open_and_sync(
109        effects,
110        &repo,
111        &event_replayer,
112        event_cursor,
113        &references_snapshot,
114    )?;
115
116    let source_oids: CommitSet =
117        match resolve_commits(effects, &repo, &mut dag, &sources, resolve_revset_options) {
118            Ok(commit_sets) => union_all(&commit_sets),
119            Err(err) => {
120                err.describe(effects)?;
121                return Ok(Err(ExitCode(1)));
122            }
123        };
124    let base_oids: CommitSet =
125        match resolve_commits(effects, &repo, &mut dag, &bases, resolve_revset_options) {
126            Ok(commit_sets) => union_all(&commit_sets),
127            Err(err) => {
128                err.describe(effects)?;
129                return Ok(Err(ExitCode(1)));
130            }
131        };
132    let exact_components = match resolve_commits(
133        effects,
134        &repo,
135        &mut dag,
136        &exacts,
137        resolve_revset_options,
138    ) {
139        Ok(commit_sets) => {
140            let exact_oids = union_all(&commit_sets);
141            let mut components: HashMap<NonZeroOid, CommitSet> = HashMap::new();
142
143            for component in dag.get_connected_components(&exact_oids)?.into_iter() {
144                let component_roots = dag.query_roots(component.clone())?;
145                let component_root = match dag.commit_set_to_vec(&component_roots)?.as_slice() {
146                    [only_commit_oid] => *only_commit_oid,
147                    _ => {
148                        writeln!(
149                            effects.get_error_stream(),
150                            "The --exact flag can only be used to move ranges with exactly 1 root.\n\
151                             Received range with {} roots: {:?}",
152                            dag.set_count(&component_roots)?,
153                            component_roots
154                        )?;
155                        return Ok(Err(ExitCode(1)));
156                    }
157                };
158
159                let component_parents = dag.query_parents(CommitSet::from(component_root))?;
160                if dag.set_count(&component_parents)? != 1 {
161                    writeln!(
162                        effects.get_output_stream(),
163                        "The --exact flag can only be used to move ranges or commits with exactly 1 parent.\n\
164                         Received range with {} parents: {:?}",
165                        dag.set_count(&component_parents)?,
166                        component_parents
167                    )?;
168                    return Ok(Err(ExitCode(1)));
169                };
170
171                components.insert(component_root, component);
172            }
173
174            components
175        }
176        Err(err) => {
177            err.describe(effects)?;
178            return Ok(Err(ExitCode(1)));
179        }
180    };
181
182    let dest_oid: NonZeroOid = match resolve_commits(
183        effects,
184        &repo,
185        &mut dag,
186        std::slice::from_ref(&dest),
187        resolve_revset_options,
188    ) {
189        Ok(commit_sets) => match dag.commit_set_to_vec(&commit_sets[0])?.as_slice() {
190            [only_commit_oid] => *only_commit_oid,
191            other => {
192                let Revset(expr) = dest;
193                writeln!(
194                    effects.get_error_stream(),
195                    "Expected revset to expand to exactly 1 commit (got {}): {}",
196                    other.len(),
197                    expr,
198                )?;
199                return Ok(Err(ExitCode(1)));
200            }
201        },
202        Err(err) => {
203            err.describe(effects)?;
204            return Ok(Err(ExitCode(1)));
205        }
206    };
207
208    let base_oids = if should_sources_default_to_head {
209        match head_oid {
210            Some(head_oid) => CommitSet::from(head_oid),
211            None => {
212                writeln!(
213                    effects.get_output_stream(),
214                    "No --source or --base arguments were provided, and no OID for HEAD is available as a default"
215                )?;
216                return Ok(Err(ExitCode(1)));
217            }
218        }
219    } else {
220        base_oids
221    };
222    let base_oids = {
223        let mut result = Vec::new();
224        for base_oid in dag.commit_set_to_vec(&base_oids)? {
225            let merge_base_oid =
226                dag.query_gca_one(vec![base_oid, dest_oid].into_iter().collect::<CommitSet>())?;
227            let base_commit_oid = resolve_base_commit(&dag, merge_base_oid, base_oid)?;
228            result.push(CommitSet::from(base_commit_oid))
229        }
230        union_all(&result)
231    };
232    let source_oids = source_oids.union(&base_oids);
233
234    if let Some(head_oid) = head_oid {
235        if get_hint_enabled(&repo, Hint::MoveImplicitHeadArgument)? {
236            let should_warn_base = !sources_provided
237                && bases_provided
238                && dag.set_contains(&base_oids, head_oid)?
239                && dag.set_count(&base_oids)? == 1;
240            if should_warn_base {
241                writeln!(
242                    effects.get_output_stream(),
243                    "{}: you can omit the --base flag in this case, as it defaults to HEAD",
244                    effects.get_glyphs().render(get_hint_string())?,
245                )?;
246            }
247
248            let should_warn_dest = dest_provided && dest_oid == head_oid;
249            if should_warn_dest {
250                writeln!(
251                    effects.get_output_stream(),
252                    "{}: you can omit the --dest flag in this case, as it defaults to HEAD",
253                    effects.get_glyphs().render(get_hint_string())?,
254                )?;
255            }
256
257            if should_warn_base || should_warn_dest {
258                print_hint_suppression_notice(effects, Hint::MoveImplicitHeadArgument)?;
259            }
260        }
261    }
262    drop(base_oids);
263
264    let MoveOptions {
265        force_rewrite_public_commits,
266        force_in_memory,
267        force_on_disk,
268        detect_duplicate_commits_via_patch_id,
269        resolve_merge_conflicts,
270        dump_rebase_constraints,
271        dump_rebase_plan,
272        reparent,
273    } = *move_options;
274    let now = SystemTime::now();
275    let event_tx_id = event_log_db.make_transaction_id(now, "move")?;
276    let pool = ThreadPoolBuilder::new().build()?;
277    let repo_pool = RepoResource::new_pool(&repo)?;
278    let rebase_plan = {
279        let build_options = BuildRebasePlanOptions {
280            force_rewrite_public_commits,
281            dump_rebase_constraints,
282            dump_rebase_plan,
283            detect_duplicate_commits_via_patch_id,
284        };
285        let permissions = {
286            let commits_to_move = &source_oids;
287            let commits_to_move = commits_to_move.union(&union_all(
288                &exact_components.values().cloned().collect::<Vec<_>>(),
289            ));
290            let commits_to_move = if insert || fixup {
291                commits_to_move.union(&dag.query_children(CommitSet::from(dest_oid))?)
292            } else {
293                commits_to_move
294            };
295
296            match RebasePlanPermissions::verify_rewrite_set(&dag, build_options, &commits_to_move)?
297            {
298                Ok(permissions) => permissions,
299                Err(err) => {
300                    err.describe(effects, &repo, &dag)?;
301                    return Ok(Err(ExitCode(1)));
302                }
303            }
304        };
305        let mut builder = RebasePlanBuilder::new(&dag, permissions);
306
307        let source_roots = dag.query_roots(source_oids.clone())?;
308        for source_root in dag.commit_set_to_vec(&source_roots)? {
309            if fixup {
310                let commits = dag.query_descendants(CommitSet::from(source_root))?;
311                let commits = dag.commit_set_to_vec(&commits)?;
312                for commit in commits.iter() {
313                    builder.fixup_commit(*commit, dest_oid)?;
314                }
315            } else {
316                builder.move_subtree(source_root, vec![dest_oid])?;
317            }
318
319            if reparent {
320                builder.reparent_commit(source_root, vec![dest_oid], &repo)?;
321            }
322        }
323
324        let component_roots: CommitSet = exact_components.keys().cloned().collect();
325        let component_roots: Vec<NonZeroOid> = sorted_commit_set(&repo, &dag, &component_roots)?
326            .iter()
327            .map(|commit| commit.get_oid())
328            .collect();
329        for component_root in component_roots.iter().cloned() {
330            let component = exact_components.get(&component_root).unwrap();
331
332            // Find the non-inclusive ancestor components of the current root
333            let mut possible_destinations: Vec<NonZeroOid> = vec![];
334            for root in component_roots.iter().cloned() {
335                let component = exact_components.get(&root).unwrap();
336                if !dag.set_contains(component, component_root)?
337                    && dag.query_is_ancestor(root, component_root)?
338                {
339                    possible_destinations.push(root);
340                }
341            }
342
343            let component_dest_oid = if possible_destinations.is_empty() {
344                dest_oid
345            } else {
346                // If there was a merge commit somewhere outside of the selected
347                // components, then it's possible that the current component
348                // could have multiple possible parents.
349                //
350                // To check for this, we can confirm that the nearest
351                // destination component is an ancestor of the previous (ie next
352                // nearest). This works because possible_destinations is made
353                // from component_roots, which has been sorted topologically; so
354                // each included component should "come after" the previous
355                // component.
356                for i in 1..possible_destinations.len() {
357                    if !dag
358                        .query_is_ancestor(possible_destinations[i - 1], possible_destinations[i])?
359                    {
360                        writeln!(
361                            effects.get_output_stream(),
362                            "This operation cannot be completed because the {} at {}\n\
363                              has multiple possible parents also being moved. Please retry this operation\n\
364                              without this {}, or with only 1 possible parent.",
365                            if dag.set_count(component)? == 1 {
366                                "commit"
367                            } else {
368                                "range of commits rooted"
369                            },
370                            component_root,
371                            if dag.set_count(component)? == 1 {
372                                "commit"
373                            } else {
374                                "range of commits"
375                            },
376                        )?;
377                        return Ok(Err(ExitCode(1)));
378                    }
379                }
380
381                let nearest_component = exact_components
382                    .get(&possible_destinations[possible_destinations.len() - 1])
383                    .unwrap();
384                // The current component could be descended from any commit
385                // in nearest_component, not just it's head.
386                let dest_ancestor = dag
387                    .query_ancestors(CommitSet::from(component_root))?
388                    .intersection(nearest_component);
389                match dag.set_first(&dag.query_heads(dest_ancestor.clone())?)? {
390                    Some(head) => NonZeroOid::try_from(head)?,
391                    None => dest_oid,
392                }
393            };
394
395            // Again, we've already confirmed that each component has but 1 parent
396            let component_parent = NonZeroOid::try_from(
397                dag.set_first(&dag.query_parents(CommitSet::from(component_root))?)?
398                    .unwrap(),
399            )?;
400            let component_children: CommitSet =
401                dag.query_children(component.clone())?.difference(component);
402            let component_children = dag.filter_visible_commits(component_children)?;
403
404            for component_child in dag.commit_set_to_vec(&component_children)? {
405                // If the range being extracted has any child commits, then we
406                // need to move each of those subtrees up to the parent commit
407                // of the range. If, however, we're inserting the range and the
408                // destination commit is in one of those subtrees, then we
409                // should only move the commits from the root of that child
410                // subtree up to (and including) the destination commmit.
411                if insert && dag.query_is_ancestor(component_child, component_dest_oid)? {
412                    builder.move_range(component_child, component_dest_oid, component_parent)?;
413                } else {
414                    builder.move_subtree(component_child, vec![component_parent])?;
415                }
416            }
417
418            if fixup {
419                let commits = dag.commit_set_to_vec(component)?;
420                for commit in commits.iter() {
421                    builder.fixup_commit(*commit, dest_oid)?;
422                }
423            } else {
424                builder.move_subtree(component_root, vec![component_dest_oid])?;
425            }
426
427            if reparent {
428                builder.reparent_commit(component_root, vec![component_dest_oid], &repo)?;
429            }
430        }
431
432        if insert {
433            let source_head = {
434                let exact_head = if component_roots.is_empty() {
435                    CommitSet::empty()
436                } else {
437                    // As long as component_roots has been sorted topologically,
438                    // we only need to compare adjacent elements to confirm a
439                    // single lineage.
440                    for i in 1..component_roots.len() {
441                        if !dag.query_is_ancestor(component_roots[i - 1], component_roots[i])? {
442                            writeln!(
443                                effects.get_output_stream(),
444                                "The --insert and --exact flags can only be used together when moving commits or\n\
445                                 ranges that form a single lineage, but {} is not an ancestor of {}.",
446                                component_roots[i - 1],
447                                component_roots[i]
448                            )?;
449                            return Ok(Err(ExitCode(1)));
450                        }
451                    }
452
453                    let head_component = exact_components
454                        .get(&component_roots[component_roots.len() - 1])
455                        .unwrap()
456                        .clone();
457                    dag.query_heads(head_component)?
458                };
459                let source_heads: CommitSet = dag
460                    .query_heads(dag.query_descendants(source_oids.clone())?)?
461                    .union(&exact_head);
462                match dag.commit_set_to_vec(&source_heads)?.as_slice() {
463                    [oid] => *oid,
464                    _ => {
465                        writeln!(
466                            effects.get_output_stream(),
467                            "The --insert flag cannot be used when moving subtrees or ranges with multiple heads."
468                        )?;
469                        return Ok(Err(ExitCode(1)));
470                    }
471                }
472            };
473
474            let exact_components = exact_components
475                .values()
476                .cloned()
477                .collect::<Vec<CommitSet>>();
478            let exact_oids = union_all(&exact_components);
479            // Children of dest_oid that are not themselves being moved.
480            let dest_children: CommitSet = dag
481                .query_children(CommitSet::from(dest_oid))?
482                .difference(&source_oids)
483                .difference(&exact_oids);
484            let dest_children = dag.filter_visible_commits(dest_children)?;
485
486            for dest_child in dag.commit_set_to_vec(&dest_children)? {
487                builder.move_subtree(dest_child, vec![source_head])?;
488                if reparent {
489                    builder.reparent_commit(dest_child, vec![source_head], &repo)?;
490                }
491            }
492        }
493        builder.build(effects, &pool, &repo_pool)?
494    };
495    let result = match rebase_plan {
496        Ok(None) => {
497            writeln!(effects.get_output_stream(), "Nothing to do.")?;
498            return Ok(Ok(()));
499        }
500        Ok(Some(rebase_plan)) => {
501            let options = ExecuteRebasePlanOptions {
502                now,
503                event_tx_id,
504                preserve_timestamps: get_restack_preserve_timestamps(&repo)?,
505                force_in_memory,
506                force_on_disk,
507                dry_run,
508                resolve_merge_conflicts,
509                check_out_commit_options: Default::default(),
510            };
511            execute_rebase_plan(
512                effects,
513                git_run_info,
514                &repo,
515                &event_log_db,
516                &rebase_plan,
517                &options,
518            )?
519        }
520        Err(err) => {
521            err.describe(effects, &repo, &dag)?;
522            return Ok(Err(ExitCode(1)));
523        }
524    };
525
526    match result {
527        ExecuteRebasePlanResult::Succeeded { rewritten_oids: _ } => Ok(Ok(())),
528
529        ExecuteRebasePlanResult::WouldSucceed if dry_run => {
530            writeln!(
531                effects.get_output_stream(),
532                "(This was a dry-run; no commits were moved. Re-run without --dry-run to actually move commits.)"
533            )?;
534            Ok(Ok(()))
535        }
536
537        ExecuteRebasePlanResult::WouldSucceed => {
538            unreachable!("WouldSucceed should only apply to dry runs")
539        }
540
541        ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info } => {
542            failed_merge_info.describe(effects, &repo, MergeConflictRemediation::Retry)?;
543            Ok(Err(ExitCode(1)))
544        }
545
546        ExecuteRebasePlanResult::Failed { exit_code } => Ok(Err(exit_code)),
547    }
548}