1#![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#[derive(Clone, Copy, Debug)]
46pub enum Command {
47 Next,
49
50 Prev,
52}
53
54#[derive(Clone, Copy, Debug)]
56pub enum Distance {
57 NumCommits {
59 amount: usize,
61
62 move_by_branches: bool,
64 },
65
66 AllTheWay {
68 move_by_branches: bool,
70 },
71}
72
73#[derive(Clone, Copy, Debug)]
77pub enum Towards {
78 Newest,
80
81 Oldest,
83
84 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 let parents = parent_commits()?;
176 parents.difference(&public_commits)
177 }
178
179 Distance::AllTheWay {
180 move_by_branches: true,
181 } => {
182 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 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#[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(¤t_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 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
473pub 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 Interactive(String),
520
521 Passthrough(String),
524
525 Revset(Revset),
527
528 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}