git_branchless_navigation/
lib.rs

1//! Convenience commands to help the user move through a stack of commits.
2
3#![warn(missing_docs)]
4#![warn(
5    clippy::all,
6    clippy::as_conversions,
7    clippy::clone_on_ref_ptr,
8    clippy::dbg_macro
9)]
10#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)]
11
12pub mod prompt;
13
14use std::collections::HashSet;
15
16use std::ffi::OsString;
17use std::fmt::Write;
18use std::time::SystemTime;
19
20use cursive::theme::BaseColor;
21use cursive::utils::markup::StyledString;
22
23use lib::core::check_out::{check_out_commit, CheckOutCommitOptions, CheckoutTarget};
24use lib::core::repo_ext::RepoExt;
25use lib::util::{ExitCode, EyreExitOr};
26use tracing::{instrument, warn};
27
28use git_branchless_opts::{SwitchOptions, TraverseCommitsOptions};
29use git_branchless_revset::resolve_default_smartlog_commits;
30use git_branchless_smartlog::make_smartlog_graph;
31use lib::core::config::get_next_interactive;
32use lib::core::dag::{sorted_commit_set, CommitSet, Dag};
33use lib::core::effects::Effects;
34use lib::core::eventlog::{EventLogDb, EventReplayer};
35use lib::core::formatting::Pluralize;
36use lib::core::node_descriptors::{
37    BranchesDescriptor, CommitMessageDescriptor, CommitOidDescriptor,
38    DifferentialRevisionDescriptor, NodeDescriptor, Redactor, RelativeTimeDescriptor,
39};
40use lib::git::{GitRunInfo, NonZeroOid, Repo};
41
42use crate::prompt::prompt_select_commit;
43
44/// The command being invoked, indicating which direction to traverse commits.
45#[derive(Clone, Copy, Debug)]
46pub enum Command {
47    /// Traverse child commits.
48    Next,
49
50    /// Traverse parent commits.
51    Prev,
52}
53
54/// The number of commits to traverse.
55#[derive(Clone, Copy, Debug)]
56pub enum Distance {
57    /// Traverse this number of commits or branches.
58    NumCommits {
59        /// The number of commits or branches to traverse.
60        amount: usize,
61
62        /// If `true`, count the number of branches traversed, not commits.
63        move_by_branches: bool,
64    },
65
66    /// Traverse as many commits as possible.
67    AllTheWay {
68        /// If `true`, find the farthest commit with a branch attached to it.
69        move_by_branches: bool,
70    },
71}
72
73/// Some commits have multiple children, which makes `next` ambiguous. These
74/// values disambiguate which child commit to go to, according to the committed
75/// date.
76#[derive(Clone, Copy, Debug)]
77pub enum Towards {
78    /// When encountering multiple children, select the newest one.
79    Newest,
80
81    /// When encountering multiple children, select the oldest one.
82    Oldest,
83
84    /// When encountering multiple children, interactively prompt for
85    /// which one to advance to.
86    Interactive,
87}
88
89#[instrument(skip(commit_descriptors))]
90fn advance(
91    effects: &Effects,
92    repo: &Repo,
93    dag: &Dag,
94    commit_descriptors: &mut [&mut dyn NodeDescriptor],
95    current_oid: NonZeroOid,
96    command: Command,
97    distance: Distance,
98    towards: Option<Towards>,
99) -> eyre::Result<Option<NonZeroOid>> {
100    let towards = match towards {
101        Some(towards) => Some(towards),
102        None => {
103            if get_next_interactive(repo)? {
104                Some(Towards::Interactive)
105            } else {
106                None
107            }
108        }
109    };
110
111    let public_commits = dag.query_ancestors(dag.main_branch_commit.clone())?;
112
113    let glyphs = effects.get_glyphs();
114    let mut current_oid = current_oid;
115    let mut i = 0;
116    loop {
117        let candidate_commits = match command {
118            Command::Next => {
119                let child_commits = || -> eyre::Result<CommitSet> {
120                    let result = dag.query_children(CommitSet::from(current_oid))?;
121                    let result = dag.filter_visible_commits(result)?;
122                    Ok(result)
123                };
124
125                let descendant_branches = || -> eyre::Result<CommitSet> {
126                    let descendant_commits = dag.query_descendants(child_commits()?)?;
127                    let descendant_branches = dag.branch_commits.intersection(&descendant_commits);
128                    let descendants = dag.query_descendants(descendant_branches)?;
129                    let nearest_descendant_branches = dag.query_roots(descendants)?;
130                    Ok(nearest_descendant_branches)
131                };
132
133                let children = match distance {
134                    Distance::AllTheWay {
135                        move_by_branches: false,
136                    }
137                    | Distance::NumCommits {
138                        amount: _,
139                        move_by_branches: false,
140                    } => child_commits()?,
141
142                    Distance::AllTheWay {
143                        move_by_branches: true,
144                    }
145                    | Distance::NumCommits {
146                        amount: _,
147                        move_by_branches: true,
148                    } => descendant_branches()?,
149                };
150
151                sorted_commit_set(repo, dag, &children)?
152            }
153
154            Command::Prev => {
155                let parent_commits = || -> eyre::Result<CommitSet> {
156                    let result = dag.query_parents(CommitSet::from(current_oid))?;
157                    Ok(result)
158                };
159                let ancestor_branches = || -> eyre::Result<CommitSet> {
160                    let ancestor_commits = dag.query_ancestors(parent_commits()?)?;
161                    let ancestor_branches = dag.branch_commits.intersection(&ancestor_commits);
162                    let nearest_ancestor_branches = dag.query_heads_ancestors(ancestor_branches)?;
163                    Ok(nearest_ancestor_branches)
164                };
165
166                let parents = match distance {
167                    Distance::AllTheWay {
168                        move_by_branches: false,
169                    } => {
170                        // The `--all` flag for `git prev` isn't useful if all it does
171                        // is take you to the root commit for the repository.  Instead,
172                        // we assume that the user wanted to get to the root commit for
173                        // their current *commit stack*. We filter out commits which
174                        // aren't part of the commit stack so that we stop early here.
175                        let parents = parent_commits()?;
176                        parents.difference(&public_commits)
177                    }
178
179                    Distance::AllTheWay {
180                        move_by_branches: true,
181                    } => {
182                        // See above case.
183                        let parents = ancestor_branches()?;
184                        parents.difference(&public_commits)
185                    }
186
187                    Distance::NumCommits {
188                        amount: _,
189                        move_by_branches: false,
190                    } => parent_commits()?,
191
192                    Distance::NumCommits {
193                        amount: _,
194                        move_by_branches: true,
195                    } => ancestor_branches()?,
196                };
197
198                sorted_commit_set(repo, dag, &parents)?
199            }
200        };
201
202        match distance {
203            Distance::NumCommits {
204                amount,
205                move_by_branches: _,
206            } => {
207                if i == amount {
208                    break;
209                }
210            }
211
212            Distance::AllTheWay {
213                move_by_branches: _,
214            } => {
215                if candidate_commits.is_empty() {
216                    break;
217                }
218            }
219        }
220
221        let pluralize = match command {
222            Command::Next => Pluralize {
223                determiner: None,
224                amount: i,
225                unit: ("child", "children"),
226            },
227
228            Command::Prev => Pluralize {
229                determiner: None,
230                amount: i,
231                unit: ("parent", "parents"),
232            },
233        };
234        let header = format!(
235            "Found multiple possible {} commits to go to after traversing {}:",
236            pluralize.unit.0, pluralize,
237        );
238
239        current_oid = match (towards, candidate_commits.as_slice()) {
240            (_, []) => {
241                writeln!(
242                    effects.get_output_stream(),
243                    "{}",
244                    glyphs.render(StyledString::styled(
245                        format!(
246                            "No more {} commits to go to after traversing {}.",
247                            pluralize.unit.0, pluralize,
248                        ),
249                        BaseColor::Yellow.light()
250                    ))?
251                )?;
252
253                if i == 0 {
254                    // If we didn't succeed in traversing any commits, then
255                    // treat the operation as a failure. Otherwise, assume that
256                    // the user just meant to go as many commits as possible.
257                    return Ok(None);
258                } else {
259                    break;
260                }
261            }
262
263            (_, [only_child]) => only_child.get_oid(),
264            (Some(Towards::Newest), [.., newest_child]) => newest_child.get_oid(),
265            (Some(Towards::Oldest), [oldest_child, ..]) => oldest_child.get_oid(),
266            (Some(Towards::Interactive), [_, _, ..]) => {
267                match prompt_select_commit(
268                    Some(&header),
269                    "",
270                    candidate_commits,
271                    commit_descriptors,
272                )? {
273                    Some(oid) => oid,
274                    None => {
275                        return Ok(None);
276                    }
277                }
278            }
279            (None, [_, _, ..]) => {
280                writeln!(effects.get_output_stream(), "{header}")?;
281                for (j, child) in (0..).zip(candidate_commits.iter()) {
282                    let descriptor = if j == 0 {
283                        " (oldest)"
284                    } else if j + 1 == candidate_commits.len() {
285                        " (newest)"
286                    } else {
287                        ""
288                    };
289
290                    writeln!(
291                        effects.get_output_stream(),
292                        "  {} {}{}",
293                        glyphs.bullet_point,
294                        glyphs.render(child.friendly_describe(glyphs)?)?,
295                        descriptor
296                    )?;
297                }
298                writeln!(effects.get_output_stream(), "(Pass --oldest (-o), --newest (-n), or --interactive (-i) to select between ambiguous commits)")?;
299                return Ok(None);
300            }
301        };
302
303        i += 1;
304    }
305    Ok(Some(current_oid))
306}
307
308/// Go forward or backward a certain number of commits.
309#[instrument]
310pub fn traverse_commits(
311    effects: &Effects,
312    git_run_info: &GitRunInfo,
313    command: Command,
314    options: &TraverseCommitsOptions,
315) -> EyreExitOr<()> {
316    let TraverseCommitsOptions {
317        num_commits,
318        all_the_way,
319        move_by_branches,
320        oldest,
321        newest,
322        interactive,
323        merge,
324        force,
325    } = *options;
326
327    let distance = match (all_the_way, num_commits) {
328        (false, None) => Distance::NumCommits {
329            amount: 1,
330            move_by_branches,
331        },
332
333        (false, Some(amount)) => Distance::NumCommits {
334            amount,
335            move_by_branches,
336        },
337
338        (true, None) => Distance::AllTheWay { move_by_branches },
339
340        (true, Some(_)) => {
341            eyre::bail!("num_commits and --all cannot both be set")
342        }
343    };
344
345    let towards = match (oldest, newest, interactive) {
346        (false, false, false) => None,
347        (true, false, false) => Some(Towards::Oldest),
348        (false, true, false) => Some(Towards::Newest),
349        (false, false, true) => Some(Towards::Interactive),
350        (_, _, _) => {
351            eyre::bail!("Only one of --oldest, --newest, and --interactive can be set")
352        }
353    };
354
355    let now = SystemTime::now();
356    let repo = Repo::from_current_dir()?;
357    let head_info = repo.get_head_info()?;
358    let references_snapshot = repo.get_references_snapshot()?;
359    let conn = repo.get_db_conn()?;
360    let event_log_db = EventLogDb::new(&conn)?;
361    let event_tx_id = event_log_db.make_transaction_id(
362        now,
363        match command {
364            Command::Next => "next",
365            Command::Prev => "prev",
366        },
367    )?;
368    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
369    let event_cursor = event_replayer.make_default_cursor();
370    let dag = Dag::open_and_sync(
371        effects,
372        &repo,
373        &event_replayer,
374        event_cursor,
375        &references_snapshot,
376    )?;
377
378    let head_oid = match references_snapshot.head_oid {
379        Some(head_oid) => head_oid,
380        None => {
381            eyre::bail!("No HEAD present; cannot calculate next commit");
382        }
383    };
384
385    let current_oid = advance(
386        effects,
387        &repo,
388        &dag,
389        &mut [
390            &mut CommitOidDescriptor::new(true)?,
391            &mut RelativeTimeDescriptor::new(&repo, SystemTime::now())?,
392            &mut BranchesDescriptor::new(
393                &repo,
394                &head_info,
395                &references_snapshot,
396                &Redactor::Disabled,
397            )?,
398            &mut DifferentialRevisionDescriptor::new(&repo, &Redactor::Disabled)?,
399            &mut CommitMessageDescriptor::new(&Redactor::Disabled)?,
400        ],
401        head_oid,
402        command,
403        distance,
404        towards,
405    )?;
406    let current_oid = match current_oid {
407        None => return Ok(Err(ExitCode(1))),
408        Some(current_oid) => current_oid,
409    };
410
411    let checkout_target: CheckoutTarget = match distance {
412        Distance::AllTheWay {
413            move_by_branches: false,
414        }
415        | Distance::NumCommits {
416            amount: _,
417            move_by_branches: false,
418        } => CheckoutTarget::Oid(current_oid),
419
420        Distance::AllTheWay {
421            move_by_branches: true,
422        }
423        | Distance::NumCommits {
424            amount: _,
425            move_by_branches: true,
426        } => {
427            let empty = HashSet::new();
428            let branches = references_snapshot
429                .branch_oid_to_names
430                .get(&current_oid)
431                .unwrap_or(&empty);
432
433            if branches.is_empty() {
434                warn!(?current_oid, "No branches attached to commit with OID");
435                CheckoutTarget::Oid(current_oid)
436            } else if branches.len() == 1 {
437                let branch = branches.iter().next().unwrap();
438                CheckoutTarget::Reference(branch.to_owned())
439            } else {
440                // It's ambiguous which branch the user wants; just check out the commit directly.
441                CheckoutTarget::Oid(current_oid)
442            }
443        }
444    };
445
446    let additional_args = {
447        let mut args: Vec<OsString> = Vec::new();
448        if merge {
449            args.push("--merge".into());
450        }
451        if force {
452            args.push("--force".into())
453        }
454        args
455    };
456    check_out_commit(
457        effects,
458        git_run_info,
459        &repo,
460        &event_log_db,
461        event_tx_id,
462        Some(checkout_target),
463        &CheckOutCommitOptions {
464            additional_args,
465            ..Default::default()
466        },
467    )
468}
469
470/// Interactively switch to a commit from the smartlog.
471pub fn switch(
472    effects: &Effects,
473    git_run_info: &GitRunInfo,
474    switch_options: &SwitchOptions,
475) -> EyreExitOr<()> {
476    let SwitchOptions {
477        interactive: _,
478        branch_name,
479        force,
480        merge,
481        target,
482        detach,
483    } = switch_options;
484
485    let now = SystemTime::now();
486    let repo = Repo::from_current_dir()?;
487    let head_info = repo.get_head_info()?;
488    let references_snapshot = repo.get_references_snapshot()?;
489    let conn = repo.get_db_conn()?;
490    let event_log_db = EventLogDb::new(&conn)?;
491    let event_tx_id = event_log_db.make_transaction_id(now, "checkout")?;
492    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
493    let event_cursor = event_replayer.make_default_cursor();
494    let mut dag = Dag::open_and_sync(
495        effects,
496        &repo,
497        &event_replayer,
498        event_cursor,
499        &references_snapshot,
500    )?;
501
502    let commits = resolve_default_smartlog_commits(effects, &repo, &mut dag)?;
503    let graph = make_smartlog_graph(
504        effects,
505        &repo,
506        &dag,
507        &event_replayer,
508        event_cursor,
509        &commits,
510        false,
511    )?;
512
513    let initial_query = match switch_options {
514        SwitchOptions {
515            interactive: true,
516            branch_name: _,
517            force: _,
518            merge: _,
519            detach: _,
520            target,
521        } => Some(target.clone().unwrap_or_default()),
522        SwitchOptions {
523            interactive: false,
524            branch_name: _,
525            force: _,
526            merge: _,
527            detach: _,
528            target: _,
529        } => None,
530    };
531    let target: Option<CheckoutTarget> = match initial_query {
532        None => target.clone().map(CheckoutTarget::Unknown),
533        Some(initial_query) => {
534            match prompt_select_commit(
535                None,
536                &initial_query,
537                graph.get_commits(),
538                &mut [
539                    &mut CommitOidDescriptor::new(true)?,
540                    &mut RelativeTimeDescriptor::new(&repo, SystemTime::now())?,
541                    &mut BranchesDescriptor::new(
542                        &repo,
543                        &head_info,
544                        &references_snapshot,
545                        &Redactor::Disabled,
546                    )?,
547                    &mut DifferentialRevisionDescriptor::new(&repo, &Redactor::Disabled)?,
548                    &mut CommitMessageDescriptor::new(&Redactor::Disabled)?,
549                ],
550            )? {
551                Some(oid) => Some(CheckoutTarget::Oid(oid)),
552                None => return Ok(Err(ExitCode(1))),
553            }
554        }
555    };
556
557    let additional_args = {
558        let mut args: Vec<OsString> = Vec::new();
559        if let Some(branch_name) = branch_name {
560            args.push("-b".into());
561            args.push(branch_name.into());
562        }
563        if *force {
564            args.push("--force".into());
565        }
566        if *merge {
567            args.push("--merge".into());
568        }
569        if *detach {
570            args.push("--detach".into());
571        }
572        args
573    };
574
575    let exit_code = check_out_commit(
576        effects,
577        git_run_info,
578        &repo,
579        &event_log_db,
580        event_tx_id,
581        target,
582        &CheckOutCommitOptions {
583            additional_args,
584            reset: false,
585            render_smartlog: true,
586        },
587    )?;
588    Ok(exit_code)
589}