| @@ -1,19 +1,53 @@ |
| 1 | | −use std::ffi::OsString; |
| 1 | +use std::{ |
| 2 | + collections::{HashMap, HashSet}, |
| 3 | + ffi::OsString, |
| 4 | +}; |
| 2 | 5 | |
| 3 | 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 | 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 | 38 | pub(super) struct Args { |
| 7 | 39 | /// Check out an encountered replay conflict and write its unmerged index. |
| 8 | 40 | #[arg(long)] |
| 9 | 41 | pub(super) materialize_conflicts: bool, |
| 10 | 42 | /// Revision resolving to the commit to visit. |
| 11 | 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 | 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 | 51 | let head = repository.head().context("could not read HEAD before time-travel")?; |
| 18 | 52 | let head_id = head |
| 19 | 53 | .id() |
| @@ -21,6 +55,15 @@ pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { · 1 unmodified line |
| 21 | 55 | .context("cannot time-travel from an unborn HEAD")?; |
| 22 | 56 | let detached = head.is_detached(); |
| 23 | 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 | 67 | if selected == head_id { |
| 25 | 68 | println!("already at {}", crate::change_id::display(&repository, selected, 7)?); |
| 26 | 69 | return Ok(()); |
| @@ -82,6 +125,117 @@ pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { · 55 unmodified lines |
| 82 | 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 | 239 | #[cfg(test)] |
| 86 | 240 | mod tests { |
| 87 | 241 | use std::{path::Path, process::Command}; |
| @@ -98,10 +252,26 @@ mod tests { · 10 unmodified lines |
| 98 | 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 | 262 | fn args(revision: &str) -> Args { |
| 102 | 263 | Args { |
| 103 | 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 | 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 | 523 | #[test] |
| 185 | 524 | fn replay_conflicts_are_unobservable_until_materialization_is_requested() -> gix_testtools::Result { |
| 186 | 525 | let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; |
| @@ -222,7 +561,8 @@ mod tests { · 35 unmodified lines |
| 222 | 561 | crate::test_repository::open(path)?, |
| 223 | 562 | Args { |
| 224 | 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 | 568 | .expect_err("a materialized conflict remains an incomplete command"); |