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
461461 - When tix detaches an attached local branch, it records that branch symbolically
462462 in the singleton `refs/worktree/tix/pins/HEAD` ref. Git stores this HEAD pin
463463 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.
467468 Attach is the exception: it deliberately retains the symbolic HEAD pin
468469 after attaching its remembered branch.
469470 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
779780 ancestry through that destination. Every later descendant is reparented and
780781 becomes or remains lazy and unsigned, including ordinary commits created above
781782 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
783786 tree, conflict stages, prepared commits, and in-memory objects without changing
784787 the repository. The actual conflicting row is selected and centered with normal
785788 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
334334 return Ok((None, Vec::new()));
335335 }
336336 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());
342353 let head_pin = match head_ref
343354 .as_ref()
344355 .filter(|name| checkout_detaches && name.as_bstr().starts_with(b"refs/heads/"))
@@ -367,7 +378,7 @@ where · 22 unmodified lines
367378 None => None,
368379 };
369380 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) {
371382 Some(Target::Symbolic(name)) => super::undo::State::Symbolic(name.clone()),
372383 Some(Target::Object(id)) => super::undo::State::Object(*id),
373384 None => super::undo::State::Object(selected),
@@ -375,7 +386,7 @@ where · 1 unmodified line
375386 |name| super::undo::State::Symbolic(name.clone()),
376387 );
377388 drop(repository);
378− let checkout = match (reference, &destination_pin) {
389+ let checkout = match (reference, pin_for_checkout) {
379390 (Some(reference), _) if reference.as_bstr().starts_with(b"refs/heads/") => {
380391 checkout_branch(&workdir, reference.as_ref())
381392 }
@@ -399,15 +410,18 @@ where · 17 unmodified lines
399410 after: head_after_checkout,
400411 });
401412 }
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+ };
411425 let repository = match open_repository(repository_path, bare, false) {
412426 Ok(repository) => repository,
413427 Err(err) => {
@@ -537,7 +551,8 @@ pub(crate) fn attach_reporting( · 123 unmodified lines
537551 let remembered = remembered_branch(&repository)?;
538552 validate_attach(&repository, head_id, &remembered)?;
539553
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);
541556 let mut ref_changes = Vec::new();
542557 let provisional = if remembered.branch_tip != head_id && !contains(&repository, remembered.branch_tip, head_id) {
543558 let (pin, created, mut changes) = create_or_reuse_pin_reporting(
@@ -750,16 +765,21 @@ pub(crate) fn perform_reporting_rebased( · 206 unmodified lines
750765 {
751766 anyhow::bail!("cannot time-travel with unresolved index conflicts");
752767 }
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);
753772 let mut completed_graph = None;
754773 let mut original_ids = HashMap::new();
755− let mut review_roots = review_roots.to_vec();
756774 let mut ref_rewrites = Vec::new();
757775 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);
759779 let mut rebased = Vec::new();
760780 let outcome = super::rebase::perform_reporting_rebased(
761781 &repository,
762− completed_graph.as_ref().unwrap_or(graph),
782+ graph,
763783 super::rebase::Edit::Repeat {
764784 base,
765785 checkout: selected,
@@ -788,7 +808,7 @@ pub(crate) fn perform_reporting_rebased( · 22 unmodified lines
788808 };
789809 ref_rewrites.extend(outcome.ref_rewrites.iter().cloned());
790810 ref_changes.extend(outcome.ref_changes.iter().cloned());
791− for (old, original) in rebased {
811+ for &(old, original) in &rebased {
792812 if let Some(new) = outcome.map(old) {
793813 original_ids.insert(new, original);
794814 }
@@ -799,16 +819,21 @@ pub(crate) fn perform_reporting_rebased( · 4 unmodified lines
799819 head_id = outcome
800820 .map(head_id)
801821 .context("HEAD disappeared while completing its rebase")?;
802− review_roots = review_roots.into_iter().filter_map(|id| outcome.map(id)).collect();
803822 repository = open_repository(repository_path, bare, false)
804823 .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+ }
806836 }
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);
812837 let workdir = repository
813838 .workdir()
814839 .context("time-travel requires a worktree")?
@@ -977,19 +1002,17 @@ fn pending_base(repository: &gix::Repository, selected: ObjectId) -> Result<Opti · 162 unmodified lines
9771002 Ok(base)
9781003 }
9791004
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()
9831007 .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()
9931016 }
9941017
9951018 #[cfg(test)]
@@ -1747,12 +1770,27 @@ mod tests { · 751 unmodified lines
17471770 .set_target_id(topic, "advance pinned branch")?;
17481771 let advanced = history::snapshot(&repository, &[], &[], false)?;
17491772 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");
17501775 drop(repository);
17511776
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();
17521781 let returned = perform(&repository_path, false, topic, &graph, &[], &[], false)?;
17531782 let Perform::Complete { ref_changes, .. } = returned else {
17541783 return Err("returning through the HEAD pin must complete".into());
17551784 };
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+ );
17561794 let repository = crate::test_repository::open(fixture.path())?;
17571795 assert_eq!(
17581796 repository.head_name()?.map(|name| name.as_bstr().to_owned()),
@@ -1766,8 +1804,10 @@ mod tests { · 7 unmodified lines
17661804 .apply_with_worktrees(&repository)?;
17671805 assert!(repository.head()?.is_detached(), "undo restores the detached HEAD");
17681806 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");
17691809 assert!(
1770− history::all_pins(&repository)?.iter().any(history::Pin::is_head),
1810+ pins.iter().any(history::Pin::is_head),
17711811 "undo restores the symbolic HEAD pin"
17721812 );
17731813 super::super::undo::plan_redo(&repository)?
@@ -2333,6 +2373,67 @@ mod tests { · 559 unmodified lines
23332373 Ok(())
23342374 }
23352375
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+
23362437 #[test]
23372438 fn time_travel_materializes_only_the_pending_path_to_the_destination() -> gix_testtools::Result {
23382439 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