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 | 461 | - When tix detaches an attached local branch, it records that branch symbolically |
| 462 | 462 | in the singleton `refs/worktree/tix/pins/HEAD` ref. Git stores this HEAD pin |
| 463 | 463 | privately for the current worktree, and later branch advances move its tip. |
| 464 | − Further detached travel preserves the singleton. After each successful tix | |
| 465 | − checkout, landing on the remembered branch tip reattaches `HEAD` to that branch | |
| 466 | − and removes the HEAD pin; explicitly attaching another branch also removes it. | |
| 464 | + Further detached travel preserves the singleton. Landing on an available | |
| 465 | + remembered branch tip uses that branch as the checkout target directly, | |
| 466 | + reattaches `HEAD` in one checkout, and removes the HEAD pin; explicitly | |
| 467 | + attaching another branch also removes it. | |
| 467 | 468 | Attach is the exception: it deliberately retains the symbolic HEAD pin |
| 468 | 469 | after attaching its remembered branch. |
| 469 | 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 | 780 | ancestry through that destination. Every later descendant is reparented and |
| 780 | 781 | becomes or remains lazy and unsigned, including ordinary commits created above |
| 781 | 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 | 786 | tree, conflict stages, prepared commits, and in-memory objects without changing |
| 784 | 787 | the repository. The actual conflicting row is selected and centered with normal |
| 785 | 788 | history-boundary clamping and shows a blinking red `C`; `<enter>` persists |
| @@ -334,11 +334,22 @@ where · 333 unmodified lines | ||
| 334 | 334 | return Ok((None, Vec::new())); |
| 335 | 335 | } |
| 336 | 336 | let mut ref_changes = Vec::new(); |
| 337 | − let destination_pin = selected_pin(&repository, selected)?; | |
| 338 | − let checkout_detaches = reference.is_none() | |
| 339 | − && destination_pin | |
| 340 | − .as_ref() | |
| 341 | − .is_none_or(|pin| pin.target.try_name().is_none()); | |
| 337 | + let pins = history::all_pins(&repository)?; | |
| 338 | + let destination_pin = selected_pin(&pins, selected); | |
| 339 | + let direct_head_pin = if reference.is_none() { | |
| 340 | + pins.into_iter().find(|pin| { | |
| 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 | 353 | let head_pin = match head_ref |
| 343 | 354 | .as_ref() |
| 344 | 355 | .filter(|name| checkout_detaches && name.as_bstr().starts_with(b"refs/heads/")) |
| @@ -367,7 +378,7 @@ where · 22 unmodified lines | ||
| 367 | 378 | None => None, |
| 368 | 379 | }; |
| 369 | 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 | 382 | Some(Target::Symbolic(name)) => super::undo::State::Symbolic(name.clone()), |
| 372 | 383 | Some(Target::Object(id)) => super::undo::State::Object(*id), |
| 373 | 384 | None => super::undo::State::Object(selected), |
| @@ -375,7 +386,7 @@ where · 1 unmodified line | ||
| 375 | 386 | |name| super::undo::State::Symbolic(name.clone()), |
| 376 | 387 | ); |
| 377 | 388 | drop(repository); |
| 378 | − let checkout = match (reference, &destination_pin) { | |
| 389 | + let checkout = match (reference, pin_for_checkout) { | |
| 379 | 390 | (Some(reference), _) if reference.as_bstr().starts_with(b"refs/heads/") => { |
| 380 | 391 | checkout_branch(&workdir, reference.as_ref()) |
| 381 | 392 | } |
| @@ -399,15 +410,18 @@ where · 17 unmodified lines | ||
| 399 | 410 | after: head_after_checkout, |
| 400 | 411 | }); |
| 401 | 412 | } |
| 402 | − let mut notice = reference.map_or_else( | |
| 403 | − || { | |
| 404 | − destination_pin.as_ref().map_or_else( | |
| 405 | − || format!("time-travelled to {}", selected.to_hex_with_len(7)), | |
| 406 | − |pin| format!("returned from {}", pin_label(pin)), | |
| 407 | − ) | |
| 408 | − }, | |
| 409 | − |reference| format!("checked out {}", reference.shorten()), | |
| 410 | − ); | |
| 413 | + let mut notice = match (reference, direct_head_pin.as_ref(), destination_pin.as_ref()) { | |
| 414 | + (Some(reference), _, _) => format!("checked out {}", reference.shorten()), | |
| 415 | + (None, Some(pin), _) => format!( | |
| 416 | + "returned to {}", | |
| 417 | + pin.target | |
| 418 | + .try_name() | |
| 419 | + .expect("a direct HEAD return is symbolic") | |
| 420 | + .shorten() | |
| 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 | 425 | let repository = match open_repository(repository_path, bare, false) { |
| 412 | 426 | Ok(repository) => repository, |
| 413 | 427 | Err(err) => { |
| @@ -537,7 +551,8 @@ pub(crate) fn attach_reporting( · 123 unmodified lines | ||
| 537 | 551 | let remembered = remembered_branch(&repository)?; |
| 538 | 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 | 556 | let mut ref_changes = Vec::new(); |
| 542 | 557 | let provisional = if remembered.branch_tip != head_id && !contains(&repository, remembered.branch_tip, head_id) { |
| 543 | 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 | 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 | 772 | let mut completed_graph = None; |
| 754 | 773 | let mut original_ids = HashMap::new(); |
| 755 | − let mut review_roots = review_roots.to_vec(); | |
| 756 | 774 | let mut ref_rewrites = Vec::new(); |
| 757 | 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 | 779 | let mut rebased = Vec::new(); |
| 760 | 780 | let outcome = super::rebase::perform_reporting_rebased( |
| 761 | 781 | &repository, |
| 762 | − completed_graph.as_ref().unwrap_or(graph), | |
| 782 | + graph, | |
| 763 | 783 | super::rebase::Edit::Repeat { |
| 764 | 784 | base, |
| 765 | 785 | checkout: selected, |
| @@ -788,7 +808,7 @@ pub(crate) fn perform_reporting_rebased( · 22 unmodified lines | ||
| 788 | 808 | }; |
| 789 | 809 | ref_rewrites.extend(outcome.ref_rewrites.iter().cloned()); |
| 790 | 810 | ref_changes.extend(outcome.ref_changes.iter().cloned()); |
| 791 | − for (old, original) in rebased { | |
| 811 | + for &(old, original) in &rebased { | |
| 792 | 812 | if let Some(new) = outcome.map(old) { |
| 793 | 813 | original_ids.insert(new, original); |
| 794 | 814 | } |
| @@ -799,16 +819,21 @@ pub(crate) fn perform_reporting_rebased( · 4 unmodified lines | ||
| 799 | 819 | head_id = outcome |
| 800 | 820 | .map(head_id) |
| 801 | 821 | .context("HEAD disappeared while completing its rebase")?; |
| 802 | − review_roots = review_roots.into_iter().filter_map(|id| outcome.map(id)).collect(); | |
| 803 | 822 | repository = open_repository(repository_path, bare, false) |
| 804 | 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 | 837 | let workdir = repository |
| 813 | 838 | .workdir() |
| 814 | 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 | 1002 | Ok(base) |
| 978 | 1003 | } |
| 979 | 1004 | |
| 980 | −fn selected_pin(repository: &gix::Repository, selected: ObjectId) -> Result<Option<history::Pin>> { | |
| 981 | − let mut pins: Vec<_> = history::all_pins(repository)? | |
| 982 | − .into_iter() | |
| 1005 | +fn selected_pin(pins: &[history::Pin], selected: ObjectId) -> Option<history::Pin> { | |
| 1006 | + pins.iter() | |
| 983 | 1007 | .filter(|pin| !pin.is_head() && pin.id == selected) |
| 984 | − .collect(); | |
| 985 | − pins.sort_by(|a, b| { | |
| 986 | − a.target | |
| 987 | − .try_name() | |
| 988 | − .is_none() | |
| 989 | − .cmp(&b.target.try_name().is_none()) | |
| 990 | − .then_with(|| a.name.cmp(&b.name)) | |
| 991 | − }); | |
| 992 | − Ok(pins.into_iter().next()) | |
| 1008 | + .min_by(|a, b| { | |
| 1009 | + a.target | |
| 1010 | + .try_name() | |
| 1011 | + .is_none() | |
| 1012 | + .cmp(&b.target.try_name().is_none()) | |
| 1013 | + .then_with(|| a.name.cmp(&b.name)) | |
| 1014 | + }) | |
| 1015 | + .cloned() | |
| 993 | 1016 | } |
| 994 | 1017 | |
| 995 | 1018 | #[cfg(test)] |
| @@ -1747,12 +1770,27 @@ mod tests { · 751 unmodified lines | ||
| 1747 | 1770 | .set_target_id(topic, "advance pinned branch")?; |
| 1748 | 1771 | let advanced = history::snapshot(&repository, &[], &[], false)?; |
| 1749 | 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 | 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 | 1781 | let returned = perform(&repository_path, false, topic, &graph, &[], &[], false)?; |
| 1753 | 1782 | let Perform::Complete { ref_changes, .. } = returned else { |
| 1754 | 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 | 1794 | let repository = crate::test_repository::open(fixture.path())?; |
| 1757 | 1795 | assert_eq!( |
| 1758 | 1796 | repository.head_name()?.map(|name| name.as_bstr().to_owned()), |
| @@ -1766,8 +1804,10 @@ mod tests { · 7 unmodified lines | ||
| 1766 | 1804 | .apply_with_worktrees(&repository)?; |
| 1767 | 1805 | assert!(repository.head()?.is_detached(), "undo restores the detached HEAD"); |
| 1768 | 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 | 1809 | assert!( |
| 1770 | − history::all_pins(&repository)?.iter().any(history::Pin::is_head), | |
| 1810 | + pins.iter().any(history::Pin::is_head), | |
| 1771 | 1811 | "undo restores the symbolic HEAD pin" |
| 1772 | 1812 | ); |
| 1773 | 1813 | super::super::undo::plan_redo(&repository)? |
| @@ -2333,6 +2373,67 @@ mod tests { · 559 unmodified lines | ||
| 2333 | 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 | 2437 | #[test] |
| 2337 | 2438 | fn time_travel_materializes_only_the_pending_path_to_the_destination() -> gix_testtools::Result { |
| 2338 | 2439 | if !gix_testtools::signature::program_available("ssh-keygen") { |