Skip to main content

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_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::{CheckOutCommitOptions, CheckoutTarget, check_out_commit};
24use lib::core::repo_ext::RepoExt;
25use lib::util::{ExitCode, EyreExitOr};
26use tracing::{instrument, warn};
27
28use git_branchless_opts::{ResolveRevsetOptions, Revset, SwitchOptions, TraverseCommitsOptions};
29use git_branchless_revset::{resolve_commits, resolve_default_smartlog_commits};
30use git_branchless_smartlog::make_smartlog_graph;
31use lib::core::config::get_next_interactive;
32use lib::core::dag::{CommitSet, Dag, sorted_commit_set, union_all};
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!(
299                    effects.get_output_stream(),
300                    "(Pass --oldest (-o), --newest (-n), or --interactive (-i) to select between ambiguous commits)"
301                )?;
302                return Ok(None);
303            }
304        };
305
306        i += 1;
307    }
308    Ok(Some(current_oid))
309}
310
311/// Go forward or backward a certain number of commits.
312#[instrument]
313pub fn traverse_commits(
314    effects: &Effects,
315    git_run_info: &GitRunInfo,
316    command: Command,
317    options: &TraverseCommitsOptions,
318) -> EyreExitOr<()> {
319    let TraverseCommitsOptions {
320        num_commits,
321        all_the_way,
322        move_by_branches,
323        oldest,
324        newest,
325        interactive,
326        merge,
327        force,
328    } = *options;
329
330    let distance = match (all_the_way, num_commits) {
331        (false, None) => Distance::NumCommits {
332            amount: 1,
333            move_by_branches,
334        },
335
336        (false, Some(amount)) => Distance::NumCommits {
337            amount,
338            move_by_branches,
339        },
340
341        (true, None) => Distance::AllTheWay { move_by_branches },
342
343        (true, Some(_)) => {
344            eyre::bail!("num_commits and --all cannot both be set")
345        }
346    };
347
348    let towards = match (oldest, newest, interactive) {
349        (false, false, false) => None,
350        (true, false, false) => Some(Towards::Oldest),
351        (false, true, false) => Some(Towards::Newest),
352        (false, false, true) => Some(Towards::Interactive),
353        (_, _, _) => {
354            eyre::bail!("Only one of --oldest, --newest, and --interactive can be set")
355        }
356    };
357
358    let now = SystemTime::now();
359    let repo = Repo::from_current_dir()?;
360    let head_info = repo.get_head_info()?;
361    let references_snapshot = repo.get_references_snapshot()?;
362    let conn = repo.get_db_conn()?;
363    let event_log_db = EventLogDb::new(&conn)?;
364    let event_tx_id = event_log_db.make_transaction_id(
365        now,
366        match command {
367            Command::Next => "next",
368            Command::Prev => "prev",
369        },
370    )?;
371    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
372    let event_cursor = event_replayer.make_default_cursor();
373    let dag = Dag::open_and_sync(
374        effects,
375        &repo,
376        &event_replayer,
377        event_cursor,
378        &references_snapshot,
379    )?;
380
381    let head_oid = match references_snapshot.head_oid {
382        Some(head_oid) => head_oid,
383        None => {
384            eyre::bail!("No HEAD present; cannot calculate next commit");
385        }
386    };
387
388    let current_oid = advance(
389        effects,
390        &repo,
391        &dag,
392        &mut [
393            &mut CommitOidDescriptor::new(true)?,
394            &mut RelativeTimeDescriptor::new(&repo, SystemTime::now())?,
395            &mut BranchesDescriptor::new(
396                &repo,
397                &head_info,
398                &references_snapshot,
399                &Redactor::Disabled,
400            )?,
401            &mut DifferentialRevisionDescriptor::new(&repo, &Redactor::Disabled)?,
402            &mut CommitMessageDescriptor::new(&Redactor::Disabled)?,
403        ],
404        head_oid,
405        command,
406        distance,
407        towards,
408    )?;
409    let current_oid = match current_oid {
410        None => return Ok(Err(ExitCode(1))),
411        Some(current_oid) => current_oid,
412    };
413
414    let checkout_target: CheckoutTarget = match distance {
415        Distance::AllTheWay {
416            move_by_branches: false,
417        }
418        | Distance::NumCommits {
419            amount: _,
420            move_by_branches: false,
421        } => CheckoutTarget::Oid(current_oid),
422
423        Distance::AllTheWay {
424            move_by_branches: true,
425        }
426        | Distance::NumCommits {
427            amount: _,
428            move_by_branches: true,
429        } => {
430            let empty = HashSet::new();
431            let branches = references_snapshot
432                .branch_oid_to_names
433                .get(&current_oid)
434                .unwrap_or(&empty);
435
436            if branches.is_empty() {
437                warn!(?current_oid, "No branches attached to commit with OID");
438                CheckoutTarget::Oid(current_oid)
439            } else if branches.len() == 1 {
440                let branch = branches.iter().next().unwrap();
441                CheckoutTarget::Reference(branch.to_owned())
442            } else {
443                // It's ambiguous which branch the user wants; just check out the commit directly.
444                CheckoutTarget::Oid(current_oid)
445            }
446        }
447    };
448
449    let additional_args = {
450        let mut args: Vec<OsString> = Vec::new();
451        if merge {
452            args.push("--merge".into());
453        }
454        if force {
455            args.push("--force".into())
456        }
457        args
458    };
459    check_out_commit(
460        effects,
461        git_run_info,
462        &repo,
463        &event_log_db,
464        event_tx_id,
465        Some(checkout_target),
466        &CheckOutCommitOptions {
467            additional_args,
468            ..Default::default()
469        },
470    )
471}
472
473/// Interactively switch to a commit from the smartlog.
474pub fn switch(
475    effects: &Effects,
476    git_run_info: &GitRunInfo,
477    switch_options: &SwitchOptions,
478) -> EyreExitOr<()> {
479    let SwitchOptions {
480        interactive,
481        branch_name,
482        force,
483        merge,
484        target,
485        detach,
486    } = switch_options;
487
488    let now = SystemTime::now();
489    let repo = Repo::from_current_dir()?;
490    let head_info = repo.get_head_info()?;
491    let references_snapshot = repo.get_references_snapshot()?;
492    let conn = repo.get_db_conn()?;
493    let event_log_db = EventLogDb::new(&conn)?;
494    let event_tx_id = event_log_db.make_transaction_id(now, "checkout")?;
495    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
496    let event_cursor = event_replayer.make_default_cursor();
497    let mut dag = Dag::open_and_sync(
498        effects,
499        &repo,
500        &event_replayer,
501        event_cursor,
502        &references_snapshot,
503    )?;
504
505    let commits = resolve_default_smartlog_commits(effects, &repo, &mut dag)?;
506    let graph = make_smartlog_graph(
507        effects,
508        &repo,
509        &dag,
510        &event_replayer,
511        event_cursor,
512        &commits,
513        false,
514    )?;
515
516    enum Target {
517        /// The (possibly empty) target expression should be used as the initial
518        /// query in the commit selector.
519        Interactive(String),
520
521        /// The target expression is probably a git revision or reference and
522        /// should be passed directly to git for resolution.
523        Passthrough(String),
524
525        /// The target expression should be interpreted as a revset.
526        Revset(Revset),
527
528        /// No target expression was specified.
529        None,
530    }
531    let initial_query = match (interactive, target) {
532        (true, Some(target)) => Target::Interactive(target.to_string()),
533        (true, None) => Target::Interactive(String::new()),
534        (false, Some(target)) => match repo.revparse_single_commit(target.to_string().as_ref()) {
535            Ok(Some(_)) => Target::Passthrough(target.to_string()),
536            Ok(None) | Err(_) => Target::Revset(target.clone()),
537        },
538        (false, None) => Target::None,
539    };
540    let target: Option<CheckoutTarget> = match initial_query {
541        Target::None => None,
542        Target::Passthrough(target) => Some(CheckoutTarget::Unknown(target)),
543        Target::Interactive(initial_query) => {
544            match prompt_select_commit(
545                None,
546                &initial_query,
547                graph.get_commits(),
548                &mut [
549                    &mut CommitOidDescriptor::new(true)?,
550                    &mut RelativeTimeDescriptor::new(&repo, SystemTime::now())?,
551                    &mut BranchesDescriptor::new(
552                        &repo,
553                        &head_info,
554                        &references_snapshot,
555                        &Redactor::Disabled,
556                    )?,
557                    &mut DifferentialRevisionDescriptor::new(&repo, &Redactor::Disabled)?,
558                    &mut CommitMessageDescriptor::new(&Redactor::Disabled)?,
559                ],
560            )? {
561                Some(oid) => Some(CheckoutTarget::Oid(oid)),
562                None => return Ok(Err(ExitCode(1))),
563            }
564        }
565        Target::Revset(target) => {
566            let commit_sets = resolve_commits(
567                effects,
568                &repo,
569                &mut dag,
570                std::slice::from_ref(&target),
571                &ResolveRevsetOptions::default(),
572            )?;
573
574            let commit_set = union_all(&commit_sets);
575            let commit_set = dag.query_heads(commit_set)?;
576            let commits = sorted_commit_set(&repo, &dag, &commit_set)?;
577
578            match commits.as_slice() {
579                [commit] => Some(CheckoutTarget::Unknown(commit.get_oid().to_string())),
580                [] | [..] => {
581                    writeln!(
582                        effects.get_error_stream(),
583                        "Cannot switch to target: expected '{target}' to contain 1 head, but found {}.",
584                        commits.len()
585                    )?;
586                    writeln!(
587                        effects.get_error_stream(),
588                        "Target should be a commit or a set of commits with exactly 1 head. Aborting."
589                    )?;
590                    return Ok(Err(ExitCode(1)));
591                }
592            }
593        }
594    };
595
596    let additional_args = {
597        let mut args: Vec<OsString> = Vec::new();
598        if let Some(branch_name) = branch_name {
599            args.push("-b".into());
600            args.push(branch_name.into());
601        }
602        if *force {
603            args.push("--force".into());
604        }
605        if *merge {
606            args.push("--merge".into());
607        }
608        if *detach {
609            args.push("--detach".into());
610        }
611        args
612    };
613
614    let exit_code = check_out_commit(
615        effects,
616        git_run_info,
617        &repo,
618        &event_log_db,
619        event_tx_id,
620        target,
621        &CheckOutCommitOptions {
622            additional_args,
623            force_detach: false,
624            reset: false,
625            render_smartlog: true,
626        },
627    )?;
628    Ok(exit_code)
629}