Reuse an available symbolic HEAD pin as the checkout target when returning to its branch, avoiding an intermediate detached checkout and its extra worktree traversal. Complete pending rebases using only the affected rewritten path so unrelated history cannot delay or block time travel.
Unified · Split
| @@ -461,9 +461,10 @@ The Enter key is written as `<enter>` throughout. · 460 unmodified lines | |||
| 461 | - When tix detaches an attached local branch, it records that branch symbolically | 461 | - When tix detaches an attached local branch, it records that branch symbolically |
| 462 | in the singleton `refs/worktree/tix/pins/HEAD` ref. Git stores this HEAD pin | 462 | in the singleton `refs/worktree/tix/pins/HEAD` ref. Git stores this HEAD pin |
| 463 | privately for the current worktree, and later branch advances move its tip. | 463 | privately for the current worktree, and later branch advances move its tip. |
| 464 | Further detached travel preserves the singleton. After each successful tix | 464 | Further detached travel preserves the singleton. Landing on an available |
| 465 | checkout, landing on the remembered branch tip reattaches `HEAD` to that branch | 465 | remembered branch tip uses that branch as the checkout target directly, |
| 466 | and removes the HEAD pin; explicitly attaching another branch also removes it. | 466 | reattaches `HEAD` in one checkout, and removes the HEAD pin; explicitly |
| 467 | attaching another branch also removes it. | ||
| 467 | Attach is the exception: it deliberately retains the symbolic HEAD pin | 468 | Attach is the exception: it deliberately retains the symbolic HEAD pin |
| 468 | after attaching its remembered branch. | 469 | after attaching its remembered branch. |
| 469 | A failed automatic reattachment leaves both detached `HEAD` and the HEAD pin | 470 | A failed automatic reattachment leaves both detached `HEAD` and the HEAD pin |
| @@ -779,7 +780,9 @@ space first; changes blocks adapt within the remaining history width. · 309 unmodified lines | |||
| 779 | ancestry through that destination. Every later descendant is reparented and | 780 | ancestry through that destination. Every later descendant is reparented and |
| 780 | becomes or remains lazy and unsigned, including ordinary commits created above | 781 | becomes or remains lazy and unsigned, including ordinary commits created above |
| 781 | pending history; traveling toward a non-pending ancestor leaves the entire | 782 | pending history; traveling toward a non-pending ancestor leaves the entire |
| 782 | pending region untouched. A conflict retains the ours tree, exact merge-result | 783 | pending region untouched. A completed final replay does not reload history; |
| 784 | another pass loads only the rewritten path and never unrelated references. | ||
| 785 | A conflict retains the ours tree, exact merge-result | ||
| 783 | tree, conflict stages, prepared commits, and in-memory objects without changing | 786 | tree, conflict stages, prepared commits, and in-memory objects without changing |
| 784 | the repository. The actual conflicting row is selected and centered with normal | 787 | the repository. The actual conflicting row is selected and centered with normal |
| 785 | history-boundary clamping and shows a blinking red `C`; `<enter>` persists | 788 | history-boundary clamping and shows a blinking red `C`; `<enter>` persists |
| @@ -334,11 +334,22 @@ where · 333 unmodified lines | |||
| 334 | return Ok((None, Vec::new())); | 334 | return Ok((None, Vec::new())); |
| 335 | } | 335 | } |
| 336 | let mut ref_changes = Vec::new(); | 336 | let mut ref_changes = Vec::new(); |
| 337 | let destination_pin = selected_pin(&repository, selected)?; | 337 | let pins = history::all_pins(&repository)?; |
| 338 | let checkout_detaches = reference.is_none() | 338 | let destination_pin = selected_pin(&pins, selected); |
| 339 | && destination_pin | 339 | let direct_head_pin = if reference.is_none() { |
| 340 | .as_ref() | 340 | pins.into_iter().find(|pin| { |
| 341 | .is_none_or(|pin| pin.target.try_name().is_none()); | 341 | pin.is_head() |
| 342 | && pin.id == selected | ||
| 343 | && pin.target.try_name().is_some_and(|branch| { | ||
| 344 | branch.as_bstr().starts_with(b"refs/heads/") | ||
| 345 | && ensure_branch_is_available(&repository, branch).is_ok() | ||
| 346 | }) | ||
| 347 | }) | ||
| 348 | } else { | ||
| 349 | None | ||
| 350 | }; | ||
| 351 | let pin_for_checkout = direct_head_pin.as_ref().or(destination_pin.as_ref()); | ||
| 352 | let checkout_detaches = reference.is_none() && pin_for_checkout.is_none_or(|pin| pin.target.try_name().is_none()); | ||
| 342 | let head_pin = match head_ref | 353 | let head_pin = match head_ref |
| 343 | .as_ref() | 354 | .as_ref() |
| 344 | .filter(|name| checkout_detaches && name.as_bstr().starts_with(b"refs/heads/")) | 355 | .filter(|name| checkout_detaches && name.as_bstr().starts_with(b"refs/heads/")) |
| @@ -367,7 +378,7 @@ where · 22 unmodified lines | |||
| 367 | None => None, | 378 | None => None, |
| 368 | }; | 379 | }; |
| 369 | let head_after_checkout = reference.map_or_else( | 380 | let head_after_checkout = reference.map_or_else( |
| 370 | || match destination_pin.as_ref().map(|pin| &pin.target) { | 381 | || match pin_for_checkout.map(|pin| &pin.target) { |
| 371 | Some(Target::Symbolic(name)) => super::undo::State::Symbolic(name.clone()), | 382 | Some(Target::Symbolic(name)) => super::undo::State::Symbolic(name.clone()), |
| 372 | Some(Target::Object(id)) => super::undo::State::Object(*id), | 383 | Some(Target::Object(id)) => super::undo::State::Object(*id), |
| 373 | None => super::undo::State::Object(selected), | 384 | None => super::undo::State::Object(selected), |
| @@ -375,7 +386,7 @@ where · 1 unmodified line | |||
| 375 | |name| super::undo::State::Symbolic(name.clone()), | 386 | |name| super::undo::State::Symbolic(name.clone()), |
| 376 | ); | 387 | ); |
| 377 | drop(repository); | 388 | drop(repository); |
| 378 | let checkout = match (reference, &destination_pin) { | 389 | let checkout = match (reference, pin_for_checkout) { |
| 379 | (Some(reference), _) if reference.as_bstr().starts_with(b"refs/heads/") => { | 390 | (Some(reference), _) if reference.as_bstr().starts_with(b"refs/heads/") => { |
| 380 | checkout_branch(&workdir, reference.as_ref()) | 391 | checkout_branch(&workdir, reference.as_ref()) |
| 381 | } | 392 | } |
| @@ -399,15 +410,18 @@ where · 17 unmodified lines | |||
| 399 | after: head_after_checkout, | 410 | after: head_after_checkout, |
| 400 | }); | 411 | }); |
| 401 | } | 412 | } |
| 402 | let mut notice = reference.map_or_else( | 413 | let mut notice = match (reference, direct_head_pin.as_ref(), destination_pin.as_ref()) { |
| 403 | || { | 414 | (Some(reference), _, _) => format!("checked out {}", reference.shorten()), |
| 404 | destination_pin.as_ref().map_or_else( | 415 | (None, Some(pin), _) => format!( |
| 405 | || format!("time-travelled to {}", selected.to_hex_with_len(7)), | 416 | "returned to {}", |
| 406 | |pin| format!("returned from {}", pin_label(pin)), | 417 | pin.target |
| 407 | ) | 418 | .try_name() |
| 408 | }, | 419 | .expect("a direct HEAD return is symbolic") |
| 409 | |reference| format!("checked out {}", reference.shorten()), | 420 | .shorten() |
| 410 | ); | 421 | ), |
| 422 | (None, None, Some(pin)) => format!("returned from {}", pin_label(pin)), | ||
| 423 | (None, None, None) => format!("time-travelled to {}", selected.to_hex_with_len(7)), | ||
| 424 | }; | ||
| 411 | let repository = match open_repository(repository_path, bare, false) { | 425 | let repository = match open_repository(repository_path, bare, false) { |
| 412 | Ok(repository) => repository, | 426 | Ok(repository) => repository, |
| 413 | Err(err) => { | 427 | Err(err) => { |
| @@ -537,7 +551,8 @@ pub(crate) fn attach_reporting( · 123 unmodified lines | |||
| 537 | let remembered = remembered_branch(&repository)?; | 551 | let remembered = remembered_branch(&repository)?; |
| 538 | validate_attach(&repository, head_id, &remembered)?; | 552 | validate_attach(&repository, head_id, &remembered)?; |
| 539 | 553 | ||
| 540 | let destination_pin = selected_pin(&repository, head_id)?; | 554 | let pins = history::all_pins(&repository)?; |
| 555 | let destination_pin = selected_pin(&pins, head_id); | ||
| 541 | let mut ref_changes = Vec::new(); | 556 | let mut ref_changes = Vec::new(); |
| 542 | let provisional = if remembered.branch_tip != head_id && !contains(&repository, remembered.branch_tip, head_id) { | 557 | let provisional = if remembered.branch_tip != head_id && !contains(&repository, remembered.branch_tip, head_id) { |
| 543 | let (pin, created, mut changes) = create_or_reuse_pin_reporting( | 558 | let (pin, created, mut changes) = create_or_reuse_pin_reporting( |
| @@ -750,16 +765,21 @@ pub(crate) fn perform_reporting_rebased( · 206 unmodified lines | |||
| 750 | { | 765 | { |
| 751 | anyhow::bail!("cannot time-travel with unresolved index conflicts"); | 766 | anyhow::bail!("cannot time-travel with unresolved index conflicts"); |
| 752 | } | 767 | } |
| 768 | let source_review = review_tree(&repository, graph, review_roots, head_id)?; | ||
| 769 | let destination_review = review_tree(&repository, graph, review_roots, selected)?; | ||
| 770 | let crosses_review_boundary = | ||
| 771 | source_review.as_ref().map(|review| review.root) != destination_review.as_ref().map(|review| review.root); | ||
| 753 | let mut completed_graph = None; | 772 | let mut completed_graph = None; |
| 754 | let mut original_ids = HashMap::new(); | 773 | let mut original_ids = HashMap::new(); |
| 755 | let mut review_roots = review_roots.to_vec(); | ||
| 756 | let mut ref_rewrites = Vec::new(); | 774 | let mut ref_rewrites = Vec::new(); |
| 757 | let mut ref_changes = Vec::new(); | 775 | let mut ref_changes = Vec::new(); |
| 758 | while let Some(base) = pending_base(&repository, selected)? { | 776 | let mut pending = pending_base(&repository, selected)?; |
| 777 | while let Some(base) = pending { | ||
| 778 | let graph = completed_graph.as_ref().unwrap_or(graph); | ||
| 759 | let mut rebased = Vec::new(); | 779 | let mut rebased = Vec::new(); |
| 760 | let outcome = super::rebase::perform_reporting_rebased( | 780 | let outcome = super::rebase::perform_reporting_rebased( |
| 761 | &repository, | 781 | &repository, |
| 762 | completed_graph.as_ref().unwrap_or(graph), | 782 | graph, |
| 763 | super::rebase::Edit::Repeat { | 783 | super::rebase::Edit::Repeat { |
| 764 | base, | 784 | base, |
| 765 | checkout: selected, | 785 | checkout: selected, |
| @@ -788,7 +808,7 @@ pub(crate) fn perform_reporting_rebased( · 22 unmodified lines | |||
| 788 | }; | 808 | }; |
| 789 | ref_rewrites.extend(outcome.ref_rewrites.iter().cloned()); | 809 | ref_rewrites.extend(outcome.ref_rewrites.iter().cloned()); |
| 790 | ref_changes.extend(outcome.ref_changes.iter().cloned()); | 810 | ref_changes.extend(outcome.ref_changes.iter().cloned()); |
| 791 | for (old, original) in rebased { | 811 | for &(old, original) in &rebased { |
| 792 | if let Some(new) = outcome.map(old) { | 812 | if let Some(new) = outcome.map(old) { |
| 793 | original_ids.insert(new, original); | 813 | original_ids.insert(new, original); |
| 794 | } | 814 | } |
| @@ -799,16 +819,21 @@ pub(crate) fn perform_reporting_rebased( · 4 unmodified lines | |||
| 799 | head_id = outcome | 819 | head_id = outcome |
| 800 | .map(head_id) | 820 | .map(head_id) |
| 801 | .context("HEAD disappeared while completing its rebase")?; | 821 | .context("HEAD disappeared while completing its rebase")?; |
| 802 | review_roots = review_roots.into_iter().filter_map(|id| outcome.map(id)).collect(); | ||
| 803 | repository = open_repository(repository_path, bare, false) | 822 | repository = open_repository(repository_path, bare, false) |
| 804 | .context("could not reopen repository after completing a pending rebase")?; | 823 | .context("could not reopen repository after completing a pending rebase")?; |
| 805 | completed_graph = Some(super::loaded_graph(&repository)?); | 824 | pending = pending_base(&repository, selected)?; |
| 825 | if pending.is_some() { | ||
| 826 | let affected = rebased | ||
| 827 | .into_iter() | ||
| 828 | .map(|(id, _original)| { | ||
| 829 | outcome | ||
| 830 | .map(id) | ||
| 831 | .context("a pending rebase commit disappeared while completing time-travel") | ||
| 832 | }) | ||
| 833 | .collect::<Result<Vec<_>>>()?; | ||
| 834 | completed_graph = Some(history::HistoryGraph::for_commits(&repository, &affected)?); | ||
| 835 | } | ||
| 806 | } | 836 | } |
| 807 | let graph = completed_graph.as_ref().unwrap_or(graph); | ||
| 808 | let source_review = review_tree(&repository, graph, &review_roots, head_id)?; | ||
| 809 | let destination_review = review_tree(&repository, graph, &review_roots, selected)?; | ||
| 810 | let crosses_review_boundary = | ||
| 811 | source_review.as_ref().map(|review| review.root) != destination_review.as_ref().map(|review| review.root); | ||
| 812 | let workdir = repository | 837 | let workdir = repository |
| 813 | .workdir() | 838 | .workdir() |
| 814 | .context("time-travel requires a worktree")? | 839 | .context("time-travel requires a worktree")? |
| @@ -977,19 +1002,17 @@ fn pending_base(repository: &gix::Repository, selected: ObjectId) -> Result<Opti · 162 unmodified lines | |||
| 977 | Ok(base) | 1002 | Ok(base) |
| 978 | } | 1003 | } |
| 979 | 1004 | ||
| 980 | fn selected_pin(repository: &gix::Repository, selected: ObjectId) -> Result<Option<history::Pin>> { | 1005 | fn selected_pin(pins: &[history::Pin], selected: ObjectId) -> Option<history::Pin> { |
| 981 | let mut pins: Vec<_> = history::all_pins(repository)? | 1006 | pins.iter() |
| 982 | .into_iter() | ||
| 983 | .filter(|pin| !pin.is_head() && pin.id == selected) | 1007 | .filter(|pin| !pin.is_head() && pin.id == selected) |
| 984 | .collect(); | 1008 | .min_by(|a, b| { |
| 985 | pins.sort_by(|a, b| { | 1009 | a.target |
| 986 | a.target | 1010 | .try_name() |
| 987 | .try_name() | 1011 | .is_none() |
| 988 | .is_none() | 1012 | .cmp(&b.target.try_name().is_none()) |
| 989 | .cmp(&b.target.try_name().is_none()) | 1013 | .then_with(|| a.name.cmp(&b.name)) |
| 990 | .then_with(|| a.name.cmp(&b.name)) | 1014 | }) |
| 991 | }); | 1015 | .cloned() |
| 992 | Ok(pins.into_iter().next()) | ||
| 993 | } | 1016 | } |
| 994 | 1017 | ||
| 995 | #[cfg(test)] | 1018 | #[cfg(test)] |
| @@ -1747,12 +1770,27 @@ mod tests { · 751 unmodified lines | |||
| 1747 | .set_target_id(topic, "advance pinned branch")?; | 1770 | .set_target_id(topic, "advance pinned branch")?; |
| 1748 | let advanced = history::snapshot(&repository, &[], &[], false)?; | 1771 | let advanced = history::snapshot(&repository, &[], &[], false)?; |
| 1749 | assert!(advanced.view_tips.contains(&topic), "a symbolic pin follows its branch"); | 1772 | assert!(advanced.view_tips.contains(&topic), "a symbolic pin follows its branch"); |
| 1773 | create_pin(&repository, Target::Object(topic), topic, "overlap with the HEAD pin")?; | ||
| 1774 | assert_eq!(history::all_pins(&repository)?.len(), 2, "both return paths coexist"); | ||
| 1750 | drop(repository); | 1775 | drop(repository); |
| 1751 | 1776 | ||
| 1777 | let reflog_entries = git(fixture.path(), &["reflog", "show", "--format=%H", "HEAD"])? | ||
| 1778 | .split(|byte| *byte == b'\n') | ||
| 1779 | .filter(|line| !line.is_empty()) | ||
| 1780 | .count(); | ||
| 1752 | let returned = perform(&repository_path, false, topic, &graph, &[], &[], false)?; | 1781 | let returned = perform(&repository_path, false, topic, &graph, &[], &[], false)?; |
| 1753 | let Perform::Complete { ref_changes, .. } = returned else { | 1782 | let Perform::Complete { ref_changes, .. } = returned else { |
| 1754 | return Err("returning through the HEAD pin must complete".into()); | 1783 | return Err("returning through the HEAD pin must complete".into()); |
| 1755 | }; | 1784 | }; |
| 1785 | let returned_reflog_entries = git(fixture.path(), &["reflog", "show", "--format=%H", "HEAD"])? | ||
| 1786 | .split(|byte| *byte == b'\n') | ||
| 1787 | .filter(|line| !line.is_empty()) | ||
| 1788 | .count(); | ||
| 1789 | assert_eq!( | ||
| 1790 | returned_reflog_entries, | ||
| 1791 | reflog_entries + 1, | ||
| 1792 | "returning through the HEAD pin performs one checkout" | ||
| 1793 | ); | ||
| 1756 | let repository = crate::test_repository::open(fixture.path())?; | 1794 | let repository = crate::test_repository::open(fixture.path())?; |
| 1757 | assert_eq!( | 1795 | assert_eq!( |
| 1758 | repository.head_name()?.map(|name| name.as_bstr().to_owned()), | 1796 | repository.head_name()?.map(|name| name.as_bstr().to_owned()), |
| @@ -1766,8 +1804,10 @@ mod tests { · 7 unmodified lines | |||
| 1766 | .apply_with_worktrees(&repository)?; | 1804 | .apply_with_worktrees(&repository)?; |
| 1767 | assert!(repository.head()?.is_detached(), "undo restores the detached HEAD"); | 1805 | assert!(repository.head()?.is_detached(), "undo restores the detached HEAD"); |
| 1768 | assert_eq!(repository.head_id()?.detach(), middle); | 1806 | assert_eq!(repository.head_id()?.detach(), middle); |
| 1807 | let pins = history::all_pins(&repository)?; | ||
| 1808 | assert_eq!(pins.len(), 2, "undo restores both return paths"); | ||
| 1769 | assert!( | 1809 | assert!( |
| 1770 | history::all_pins(&repository)?.iter().any(history::Pin::is_head), | 1810 | pins.iter().any(history::Pin::is_head), |
| 1771 | "undo restores the symbolic HEAD pin" | 1811 | "undo restores the symbolic HEAD pin" |
| 1772 | ); | 1812 | ); |
| 1773 | super::super::undo::plan_redo(&repository)? | 1813 | super::super::undo::plan_redo(&repository)? |
| @@ -2333,6 +2373,67 @@ mod tests { · 559 unmodified lines | |||
| 2333 | Ok(()) | 2373 | Ok(()) |
| 2334 | } | 2374 | } |
| 2335 | 2375 | ||
| 2376 | #[test] | ||
| 2377 | fn pending_time_travel_does_not_load_unrelated_ref_history() -> gix_testtools::Result { | ||
| 2378 | let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; | ||
| 2379 | let repository = crate::test_repository::open(fixture.path())?; | ||
| 2380 | let repository_path = repository.git_dir().to_owned(); | ||
| 2381 | let graph = super::super::loaded_graph(&repository)?; | ||
| 2382 | let middle = repository.rev_parse_single("HEAD~1")?.detach(); | ||
| 2383 | let old_tip = repository.head_id()?.detach(); | ||
| 2384 | let mut commit = repository.find_commit(middle)?.decode()?.into_owned()?; | ||
| 2385 | commit.tree = repository | ||
| 2386 | .find_commit(repository.rev_parse_single("HEAD~2")?)? | ||
| 2387 | .tree_id()? | ||
| 2388 | .detach(); | ||
| 2389 | let marked = super::super::rebase::perform( | ||
| 2390 | &repository, | ||
| 2391 | &graph, | ||
| 2392 | super::super::rebase::Edit::Replace { target: middle, commit }, | ||
| 2393 | super::super::rebase::Signature::InvalidateExisting, | ||
| 2394 | super::super::rebase::Tree::LeaveAsIsAndMark, | ||
| 2395 | )? | ||
| 2396 | .complete()?; | ||
| 2397 | let pending_tip = marked.map(old_tip).context("the marked tip is retained")?; | ||
| 2398 | assert!(super::super::rebase::is_pending( | ||
| 2399 | &repository.find_commit(pending_tip)?.decode()?.into_owned()? | ||
| 2400 | )); | ||
| 2401 | |||
| 2402 | let missing_parent = ObjectId::Sha1([0x42; 20]); | ||
| 2403 | let mut unrelated = repository.find_commit(pending_tip)?.decode()?.into_owned()?; | ||
| 2404 | unrelated.parents = [missing_parent].into_iter().collect(); | ||
| 2405 | unrelated.message = "unrelated incomplete history".into(); | ||
| 2406 | let unrelated = repository.write_object(&unrelated)?.detach(); | ||
| 2407 | drop(repository); | ||
| 2408 | git( | ||
| 2409 | fixture.path(), | ||
| 2410 | &["update-ref", "refs/heads/unrelated", &unrelated.to_string()], | ||
| 2411 | )?; | ||
| 2412 | |||
| 2413 | let repository = crate::test_repository::open(fixture.path())?; | ||
| 2414 | let graph = loaded_graph(&repository, &[OsString::from("main")])?; | ||
| 2415 | drop(repository); | ||
| 2416 | let mut rebased = Vec::new(); | ||
| 2417 | let outcome = perform_reporting_rebased(&repository_path, false, pending_tip, &graph, &[], &[], false, |id| { | ||
| 2418 | rebased.push(id); | ||
| 2419 | })?; | ||
| 2420 | let Perform::Complete { selected, .. } = outcome else { | ||
| 2421 | return Err("the pending rebase must complete".into()); | ||
| 2422 | }; | ||
| 2423 | assert_eq!(rebased, [pending_tip], "only the selected pending path is replayed"); | ||
| 2424 | |||
| 2425 | let repository = crate::test_repository::open(fixture.path())?; | ||
| 2426 | assert!(!super::super::rebase::is_pending( | ||
| 2427 | &repository.find_commit(selected)?.decode()?.into_owned()? | ||
| 2428 | )); | ||
| 2429 | assert_eq!( | ||
| 2430 | repository.find_reference("refs/heads/unrelated")?.id().detach(), | ||
| 2431 | unrelated, | ||
| 2432 | "time-travel leaves the unrelated incomplete history untouched" | ||
| 2433 | ); | ||
| 2434 | Ok(()) | ||
| 2435 | } | ||
| 2436 | |||
| 2336 | #[test] | 2437 | #[test] |
| 2337 | fn time_travel_materializes_only_the_pending_path_to_the_destination() -> gix_testtools::Result { | 2438 | fn time_travel_materializes_only_the_pending_path_to_the_destination() -> gix_testtools::Result { |
| 2338 | if !gix_testtools::signature::program_available("ssh-keygen") { | 2439 | if !gix_testtools::signature::program_available("ssh-keygen") { |