| @@ -1,19 +1,53 @@ |
| 1 | use std::ffi::OsString; | 1 | use std::{ |
| | 2 | collections::{HashMap, HashSet}, |
| | 3 | ffi::OsString, |
| | 4 | }; |
| 2 | | 5 | |
| 3 | use anyhow::{Context, Result}; | 6 | use anyhow::{Context, Result}; |
| 4 | | 7 | |
| | 8 | #[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] |
| | 9 | pub(super) enum To { |
| | 10 | /// Visit the oldest reachable root in HEAD's visible history. |
| | 11 | First, |
| | 12 | /// Visit HEAD's direct visible parent. |
| | 13 | Parent, |
| | 14 | /// Visit HEAD's direct visible child. |
| | 15 | Child, |
| | 16 | /// Visit the reachable leaf in HEAD's visible history. |
| | 17 | Tip, |
| | 18 | } |
| | 19 | |
| | 20 | impl To { |
| | 21 | fn name(self) -> &'static str { |
| | 22 | match self { |
| | 23 | To::First => "first", |
| | 24 | To::Parent => "parent", |
| | 25 | To::Child => "child", |
| | 26 | To::Tip => "tip", |
| | 27 | } |
| | 28 | } |
| | 29 | } |
| | 30 | |
| 5 | #[derive(Debug, clap::Args)] | 31 | #[derive(Debug, clap::Args)] |
| | 32 | #[command(group( |
| | 33 | clap::ArgGroup::new("destination") |
| | 34 | .required(true) |
| | 35 | .multiple(false) |
| | 36 | .args(["revision", "to"]) |
| | 37 | ))] |
| 6 | pub(super) struct Args { | 38 | pub(super) struct Args { |
| 7 | /// Check out an encountered replay conflict and write its unmerged index. | 39 | /// Check out an encountered replay conflict and write its unmerged index. |
| 8 | #[arg(long)] | 40 | #[arg(long)] |
| 9 | pub(super) materialize_conflicts: bool, | 41 | pub(super) materialize_conflicts: bool, |
| 10 | /// Revision resolving to the commit to visit. | 42 | /// Revision resolving to the commit to visit. |
| 11 | #[arg(value_name = "REVSPEC")] | 43 | #[arg(value_name = "REVSPEC")] |
| 12 | pub(super) revision: OsString, | 44 | pub(super) revision: Option<OsString>, |
| | 45 | /// Visit a commit relative to HEAD in the default Tix view. |
| | 46 | #[arg(long, value_enum, value_name = "DESTINATION")] |
| | 47 | pub(super) to: Option<To>, |
| 13 | } | 48 | } |
| 14 | | 49 | |
| 15 | pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { | 50 | pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { |
| 16 | let (selected, resolved_graph) = super::resolve_commit(&repository, &args.revision, "time-travel destination")?; | | |
| 17 | let head = repository.head().context("could not read HEAD before time-travel")?; | 51 | let head = repository.head().context("could not read HEAD before time-travel")?; |
| 18 | let head_id = head | 52 | let head_id = head |
| 19 | .id() | 53 | .id() |
| @@ -21,6 +55,15 @@ pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { · 1 unmodified line |
| 21 | .context("cannot time-travel from an unborn HEAD")?; | 55 | .context("cannot time-travel from an unborn HEAD")?; |
| 22 | let detached = head.is_detached(); | 56 | let detached = head.is_detached(); |
| 23 | drop(head); | 57 | drop(head); |
| | 58 | let (selected, resolved_graph) = match (&args.revision, args.to) { |
| | 59 | (Some(revision), None) => super::resolve_commit(&repository, revision, "time-travel destination")?, |
| | 60 | (None, Some(to)) => { |
| | 61 | let graph = crate::edit::loaded_view_graph(&repository)?; |
| | 62 | let selected = relative_destination(&repository, &graph, head_id, to)?; |
| | 63 | (selected, Some(graph)) |
| | 64 | } |
| | 65 | _ => anyhow::bail!("exactly one time-travel destination is required"), |
| | 66 | }; |
| 24 | if selected == head_id { | 67 | if selected == head_id { |
| 25 | println!("already at {}", crate::change_id::display(&repository, selected, 7)?); | 68 | println!("already at {}", crate::change_id::display(&repository, selected, 7)?); |
| 26 | return Ok(()); | 69 | return Ok(()); |
| @@ -82,6 +125,117 @@ pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { · 55 unmodified lines |
| 82 | Ok(()) | 125 | Ok(()) |
| 83 | } | 126 | } |
| 84 | | 127 | |
| | 128 | fn relative_destination( |
| | 129 | repository: &gix::Repository, |
| | 130 | graph: &crate::history::HistoryGraph, |
| | 131 | head: gix::ObjectId, |
| | 132 | to: To, |
| | 133 | ) -> Result<gix::ObjectId> { |
| | 134 | let order = graph.stored_commit_ids().collect::<Vec<_>>(); |
| | 135 | let stored = order.iter().copied().collect::<HashSet<_>>(); |
| | 136 | if !stored.contains(&head) { |
| | 137 | anyhow::bail!("HEAD is not present in the default Tix view"); |
| | 138 | } |
| | 139 | |
| | 140 | let candidates = match to { |
| | 141 | To::Parent => visible_parents(graph, head, &stored), |
| | 142 | To::Child => order |
| | 143 | .iter() |
| | 144 | .copied() |
| | 145 | .filter(|id| graph.parents_of(*id).is_some_and(|parents| parents.contains(&head))) |
| | 146 | .collect(), |
| | 147 | To::First => first_candidates(graph, head, &stored, &order), |
| | 148 | To::Tip => terminal_candidates(head, &children_by_parent(graph, &stored, &order), &order), |
| | 149 | }; |
| | 150 | match candidates.as_slice() { |
| | 151 | [candidate] => Ok(*candidate), |
| | 152 | [] => anyhow::bail!("HEAD has no {} in the default Tix view", to.name()), |
| | 153 | candidates => { |
| | 154 | let candidates = candidates |
| | 155 | .iter() |
| | 156 | .map(|id| crate::change_id::display_short(repository, *id)) |
| | 157 | .collect::<Result<Vec<_>>>()? |
| | 158 | .join("\n "); |
| | 159 | anyhow::bail!( |
| | 160 | "--to {} is ambiguous; candidates:\n {candidates}\ntravel to one directly with `tix travel REVSPEC`", |
| | 161 | to.name() |
| | 162 | ) |
| | 163 | } |
| | 164 | } |
| | 165 | } |
| | 166 | |
| | 167 | fn visible_parents( |
| | 168 | graph: &crate::history::HistoryGraph, |
| | 169 | id: gix::ObjectId, |
| | 170 | stored: &HashSet<gix::ObjectId>, |
| | 171 | ) -> Vec<gix::ObjectId> { |
| | 172 | graph |
| | 173 | .parents_of(id) |
| | 174 | .unwrap_or_default() |
| | 175 | .into_iter() |
| | 176 | .filter(|parent| stored.contains(parent)) |
| | 177 | .collect() |
| | 178 | } |
| | 179 | |
| | 180 | fn first_candidates( |
| | 181 | graph: &crate::history::HistoryGraph, |
| | 182 | start: gix::ObjectId, |
| | 183 | stored: &HashSet<gix::ObjectId>, |
| | 184 | order: &[gix::ObjectId], |
| | 185 | ) -> Vec<gix::ObjectId> { |
| | 186 | let mut pending = vec![start]; |
| | 187 | let mut seen = HashSet::new(); |
| | 188 | let mut terminals = HashSet::new(); |
| | 189 | while let Some(id) = pending.pop() { |
| | 190 | if !seen.insert(id) { |
| | 191 | continue; |
| | 192 | } |
| | 193 | let parents = visible_parents(graph, id, stored); |
| | 194 | if parents.is_empty() { |
| | 195 | terminals.insert(id); |
| | 196 | } else { |
| | 197 | pending.extend(parents); |
| | 198 | } |
| | 199 | } |
| | 200 | order.iter().copied().filter(|id| terminals.contains(id)).collect() |
| | 201 | } |
| | 202 | |
| | 203 | fn children_by_parent( |
| | 204 | graph: &crate::history::HistoryGraph, |
| | 205 | stored: &HashSet<gix::ObjectId>, |
| | 206 | order: &[gix::ObjectId], |
| | 207 | ) -> HashMap<gix::ObjectId, Vec<gix::ObjectId>> { |
| | 208 | let mut children = HashMap::<_, Vec<_>>::new(); |
| | 209 | for &id in order { |
| | 210 | for parent in visible_parents(graph, id, stored) { |
| | 211 | children.entry(parent).or_default().push(id); |
| | 212 | } |
| | 213 | } |
| | 214 | children |
| | 215 | } |
| | 216 | |
| | 217 | fn terminal_candidates( |
| | 218 | start: gix::ObjectId, |
| | 219 | adjacent: &HashMap<gix::ObjectId, Vec<gix::ObjectId>>, |
| | 220 | order: &[gix::ObjectId], |
| | 221 | ) -> Vec<gix::ObjectId> { |
| | 222 | let mut pending = vec![start]; |
| | 223 | let mut seen = HashSet::new(); |
| | 224 | let mut terminals = HashSet::new(); |
| | 225 | while let Some(id) = pending.pop() { |
| | 226 | if !seen.insert(id) { |
| | 227 | continue; |
| | 228 | } |
| | 229 | match adjacent.get(&id).filter(|next| !next.is_empty()) { |
| | 230 | Some(next) => pending.extend(next), |
| | 231 | None => { |
| | 232 | terminals.insert(id); |
| | 233 | } |
| | 234 | } |
| | 235 | } |
| | 236 | order.iter().copied().filter(|id| terminals.contains(id)).collect() |
| | 237 | } |
| | 238 | |
| 85 | #[cfg(test)] | 239 | #[cfg(test)] |
| 86 | mod tests { | 240 | mod tests { |
| 87 | use std::{path::Path, process::Command}; | 241 | use std::{path::Path, process::Command}; |
| @@ -98,10 +252,26 @@ mod tests { · 10 unmodified lines |
| 98 | Ok(output.stdout) | 252 | Ok(output.stdout) |
| 99 | } | 253 | } |
| 100 | | 254 | |
| | 255 | fn observable_state(path: &Path) -> gix_testtools::Result<(Vec<u8>, Vec<u8>)> { |
| | 256 | Ok(( |
| | 257 | git(path, &["status", "--porcelain=v2", "--branch"])?, |
| | 258 | git(path, &["show-ref"])?, |
| | 259 | )) |
| | 260 | } |
| | 261 | |
| 101 | fn args(revision: &str) -> Args { | 262 | fn args(revision: &str) -> Args { |
| 102 | Args { | 263 | Args { |
| 103 | materialize_conflicts: false, | 264 | materialize_conflicts: false, |
| 104 | revision: revision.into(), | 265 | revision: Some(revision.into()), |
| | 266 | to: None, |
| | 267 | } |
| | 268 | } |
| | 269 | |
| | 270 | fn relative_args(to: To) -> Args { |
| | 271 | Args { |
| | 272 | materialize_conflicts: false, |
| | 273 | revision: None, |
| | 274 | to: Some(to), |
| 105 | } | 275 | } |
| 106 | } | 276 | } |
| 107 | | 277 | |
| @@ -181,6 +351,175 @@ mod tests { · 73 unmodified lines |
| 181 | Ok(()) | 351 | Ok(()) |
| 182 | } | 352 | } |
| 183 | | 353 | |
| | 354 | #[test] |
| | 355 | fn relative_targets_follow_the_current_default_view_component() -> gix_testtools::Result { |
| | 356 | let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; |
| | 357 | let path = fixture.path(); |
| | 358 | let repository = crate::test_repository::open(path)?; |
| | 359 | let tip = repository.head_id()?.detach(); |
| | 360 | let middle = repository.rev_parse_single("HEAD~1")?.detach(); |
| | 361 | let root = repository.rev_parse_single("HEAD~2")?.detach(); |
| | 362 | drop(repository); |
| | 363 | |
| | 364 | let orphan = gix::ObjectId::from_hex(git(path, &["commit-tree", "HEAD^{tree}", "-m", "orphan"])?.trim())?; |
| | 365 | let orphan = orphan.to_string(); |
| | 366 | git(path, &["update-ref", "refs/worktree/tix/pins/unrelated", &orphan])?; |
| | 367 | let tip_hex = tip.to_string(); |
| | 368 | let upstream = gix::ObjectId::from_hex( |
| | 369 | git(path, &["commit-tree", "HEAD^{tree}", "-p", &tip_hex, "-m", "upstream"])?.trim(), |
| | 370 | )? |
| | 371 | .to_string(); |
| | 372 | git(path, &["update-ref", "refs/remotes/origin/main", &upstream])?; |
| | 373 | git(path, &["config", "branch.main.remote", "origin"])?; |
| | 374 | git(path, &["config", "branch.main.merge", "refs/heads/main"])?; |
| | 375 | |
| | 376 | run(crate::test_repository::open(path)?, relative_args(To::Tip))?; |
| | 377 | assert_eq!( |
| | 378 | crate::test_repository::open(path)?.head_id()?.detach(), |
| | 379 | tip, |
| | 380 | "tip travel at the attached tip is a no-op" |
| | 381 | ); |
| | 382 | let before = observable_state(path)?; |
| | 383 | let err = run(crate::test_repository::open(path)?, relative_args(To::Child)) |
| | 384 | .expect_err("the visible tip has no child"); |
| | 385 | assert!(format!("{err:#}").contains("no child")); |
| | 386 | assert_eq!( |
| | 387 | observable_state(path)?, |
| | 388 | before, |
| | 389 | "a missing child leaves the repository unchanged" |
| | 390 | ); |
| | 391 | |
| | 392 | run(crate::test_repository::open(path)?, relative_args(To::Parent))?; |
| | 393 | assert_eq!( |
| | 394 | crate::test_repository::open(path)?.head_id()?.detach(), |
| | 395 | middle, |
| | 396 | "parent travels down by one commit" |
| | 397 | ); |
| | 398 | run(crate::test_repository::open(path)?, relative_args(To::First))?; |
| | 399 | assert_eq!( |
| | 400 | crate::test_repository::open(path)?.head_id()?.detach(), |
| | 401 | root, |
| | 402 | "first reaches this component's oldest commit" |
| | 403 | ); |
| | 404 | run(crate::test_repository::open(path)?, relative_args(To::First))?; |
| | 405 | assert_eq!( |
| | 406 | crate::test_repository::open(path)?.head_id()?.detach(), |
| | 407 | root, |
| | 408 | "first travel at the oldest commit is a no-op" |
| | 409 | ); |
| | 410 | |
| | 411 | let before = observable_state(path)?; |
| | 412 | let err = run(crate::test_repository::open(path)?, relative_args(To::Parent)) |
| | 413 | .expect_err("the visible root has no parent"); |
| | 414 | assert!(format!("{err:#}").contains("no parent")); |
| | 415 | assert_eq!( |
| | 416 | observable_state(path)?, |
| | 417 | before, |
| | 418 | "a missing parent leaves the repository unchanged" |
| | 419 | ); |
| | 420 | |
| | 421 | run(crate::test_repository::open(path)?, relative_args(To::Child))?; |
| | 422 | assert_eq!( |
| | 423 | crate::test_repository::open(path)?.head_id()?.detach(), |
| | 424 | middle, |
| | 425 | "child travels up by one commit" |
| | 426 | ); |
| | 427 | run(crate::test_repository::open(path)?, relative_args(To::Tip))?; |
| | 428 | let repository = crate::test_repository::open(path)?; |
| | 429 | assert_eq!(repository.head_id()?.detach(), tip, "tip reaches this component's leaf"); |
| | 430 | assert_eq!( |
| | 431 | repository.head()?.referent_name().map(|name| name.as_bstr().to_owned()), |
| | 432 | Some(b"refs/heads/main".as_bstr().to_owned()), |
| | 433 | "travelling to the branch tip reattaches HEAD" |
| | 434 | ); |
| | 435 | Ok(()) |
| | 436 | } |
| | 437 | |
| | 438 | #[test] |
| | 439 | fn relative_target_ambiguity_lists_every_candidate() -> gix_testtools::Result { |
| | 440 | let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; |
| | 441 | let path = fixture.path(); |
| | 442 | let repository = crate::test_repository::open(path)?; |
| | 443 | let old_tip = repository.head_id()?.detach(); |
| | 444 | let root = repository.rev_parse_single("HEAD~2")?.detach(); |
| | 445 | drop(repository); |
| | 446 | |
| | 447 | let orphan = gix::ObjectId::from_hex(git(path, &["commit-tree", "HEAD^{tree}", "-m", "orphan"])?.trim())?; |
| | 448 | let old_tip_hex = old_tip.to_string(); |
| | 449 | let orphan_hex = orphan.to_string(); |
| | 450 | let merge = gix::ObjectId::from_hex( |
| | 451 | git( |
| | 452 | path, |
| | 453 | &[ |
| | 454 | "commit-tree", |
| | 455 | "HEAD^{tree}", |
| | 456 | "-p", |
| | 457 | &old_tip_hex, |
| | 458 | "-p", |
| | 459 | &orphan_hex, |
| | 460 | "-m", |
| | 461 | "merge", |
| | 462 | ], |
| | 463 | )? |
| | 464 | .trim(), |
| | 465 | )?; |
| | 466 | let merge_hex = merge.to_string(); |
| | 467 | let left = gix::ObjectId::from_hex( |
| | 468 | git(path, &["commit-tree", "HEAD^{tree}", "-p", &merge_hex, "-m", "left"])?.trim(), |
| | 469 | )?; |
| | 470 | let right = gix::ObjectId::from_hex( |
| | 471 | git(path, &["commit-tree", "HEAD^{tree}", "-p", &merge_hex, "-m", "right"])?.trim(), |
| | 472 | )?; |
| | 473 | git(path, &["update-ref", "refs/heads/main", &merge_hex, &old_tip_hex])?; |
| | 474 | let left_hex = left.to_string(); |
| | 475 | let right_hex = right.to_string(); |
| | 476 | git(path, &["update-ref", "refs/worktree/tix/pins/left", &left_hex])?; |
| | 477 | git(path, &["update-ref", "refs/worktree/tix/pins/right", &right_hex])?; |
| | 478 | |
| | 479 | let repository = crate::test_repository::open(path)?; |
| | 480 | let cases = [ |
| | 481 | (To::First, vec![root, orphan]), |
| | 482 | (To::Parent, vec![old_tip, orphan]), |
| | 483 | (To::Child, vec![left, right]), |
| | 484 | (To::Tip, vec![left, right]), |
| | 485 | ]; |
| | 486 | let expected = cases |
| | 487 | .iter() |
| | 488 | .map(|(to, candidates)| { |
| | 489 | Ok::<_, anyhow::Error>(( |
| | 490 | *to, |
| | 491 | candidates |
| | 492 | .iter() |
| | 493 | .map(|id| crate::change_id::display_short(&repository, *id)) |
| | 494 | .collect::<Result<Vec<_>>>()?, |
| | 495 | )) |
| | 496 | }) |
| | 497 | .collect::<Result<Vec<_>>>()?; |
| | 498 | drop(repository); |
| | 499 | let before = observable_state(path)?; |
| | 500 | for (to, candidates) in expected { |
| | 501 | let err = run(crate::test_repository::open(path)?, relative_args(to)) |
| | 502 | .expect_err("multiple relative destinations are ambiguous"); |
| | 503 | let message = format!("{err:#}"); |
| | 504 | for candidate in candidates { |
| | 505 | assert!( |
| | 506 | message.contains(&candidate), |
| | 507 | "{to:?} ambiguity lists candidate {candidate}: {message}" |
| | 508 | ); |
| | 509 | } |
| | 510 | assert!( |
| | 511 | message.contains("tix travel REVSPEC"), |
| | 512 | "ambiguity suggests direct travel: {message}" |
| | 513 | ); |
| | 514 | assert_eq!( |
| | 515 | observable_state(path)?, |
| | 516 | before, |
| | 517 | "ambiguity leaves the repository unchanged" |
| | 518 | ); |
| | 519 | } |
| | 520 | Ok(()) |
| | 521 | } |
| | 522 | |
| 184 | #[test] | 523 | #[test] |
| 185 | fn replay_conflicts_are_unobservable_until_materialization_is_requested() -> gix_testtools::Result { | 524 | fn replay_conflicts_are_unobservable_until_materialization_is_requested() -> gix_testtools::Result { |
| 186 | let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; | 525 | let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; |
| @@ -222,7 +561,8 @@ mod tests { · 35 unmodified lines |
| 222 | crate::test_repository::open(path)?, | 561 | crate::test_repository::open(path)?, |
| 223 | Args { | 562 | Args { |
| 224 | materialize_conflicts: true, | 563 | materialize_conflicts: true, |
| 225 | revision: tip.to_string().into(), | 564 | revision: Some(tip.to_string().into()), |
| | 565 | to: None, |
| 226 | }, | 566 | }, |
| 227 | ) | 567 | ) |
| 228 | .expect_err("a materialized conflict remains an incomplete command"); | 568 | .expect_err("a materialized conflict remains an incomplete command"); |