History

fix: avoid duplicate checkout when returning from Tix travel

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.

Codex committed 2 months ago

Commit 32eb3eb1 parent c9875ff2 files · +149 −45Browse files

Unified · Split

2 files
  1. gix-tix/spec.md +7 −4
  2. gix-tix/src/edit/time_travel.rs +142 −41
gix-tix/spec.md+7−4
@@ -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 symbolically461- 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 pin462 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 tix464 Further detached travel preserves the singleton. Landing on an available
465 checkout, landing on the remembered branch tip reattaches `HEAD` to that branch465 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 pin468 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 pin470 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 and780 ancestry through that destination. Every later descendant is reparented and
780 becomes or remains lazy and unsigned, including ordinary commits created above781 becomes or remains lazy and unsigned, including ordinary commits created above
781 pending history; traveling toward a non-pending ancestor leaves the entire782 pending history; traveling toward a non-pending ancestor leaves the entire
782 pending region untouched. A conflict retains the ours tree, exact merge-result783 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 changing786 tree, conflict stages, prepared commits, and in-memory objects without changing
784 the repository. The actual conflicting row is selected and centered with normal787 the repository. The actual conflicting row is selected and centered with normal
785 history-boundary clamping and shows a blinking red `C`; `<enter>` persists788 history-boundary clamping and shows a blinking red `C`; `<enter>` persists
gix-tix/src/edit/time_travel.rs+142−41
@@ -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_pin339 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_ref353 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 = outcome819 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 = repository837 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
980fn selected_pin(repository: &gix::Repository, selected: ObjectId) -> Result<Option<history::Pin>> {1005fn 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.target1010 .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") {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t