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_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#[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!(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#[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(¤t_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 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
470pub 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}