History

feat: add relative tix travel destinations

Add first, parent, child, and tip destinations resolved within HEAD's visible default-view component. Report ambiguous endpoints with directly usable commit and change IDs while preserving the existing travel pipeline.

Codex committed 2 months ago

Commit 86367391 parent 8a900924 files · +408 −10Browse files

Unified · Split

4 files
  1. gix-tix/spec.md +9 −4
  2. gix-tix/src/command.rs +50 −1
  3. gix-tix/src/command/travel.rs +345 −5
  4. gix-tix/src/history.rs +4 −0
gix-tix/spec.md+9−4
@@ -24,10 +24,15 @@ without trading responsiveness for metadata that is not visible. · 23 unmodified lines
2424 seven-character commit hash is followed by its seven-character reverse-hex
2525 change ID. Colliding or duplicated prefixes remain visible and receive a `💥`
2626 gutter marker.
27−- `tix travel [--materialize-conflicts] REVSPEC` performs the same detached
28− checkout, pending-rebase replay, stash handling, and pin reconciliation as
29− TUI time travel. Its target may also be an unambiguous reverse-hex change-ID
30− prefix from the default Tix view. Travelling to the current `HEAD` is a no-op.
27+- `tix travel [--materialize-conflicts] (REVSPEC | --to first|parent|child|tip)`
28+ performs the same detached checkout, pending-rebase replay, stash handling,
29+ and pin reconciliation as TUI time travel. Its target may also be an
30+ unambiguous reverse-hex change-ID prefix from the default Tix view. `parent`
31+ and `child` move one edge from `HEAD`; `first` selects its oldest reachable
32+ root and `tip` its reachable leaf, considering only commits visible in the
33+ default view. Multiple direct or terminal candidates are reported with their
34+ commit and change IDs and must be selected with a direct `tix travel REVSPEC`.
35+ Travelling to the current `HEAD` is a no-op.
3136 A detached source may travel to a descendant without a pin, but travelling to
3237 an ancestor or unrelated commit requires an existing current-worktree pin at
3338 `HEAD` or a descendant. An attached source is preserved through the singleton
gix-tix/src/command.rs+50−1
@@ -823,7 +823,56 @@ mod tests { · 822 unmodified lines
823823 panic!("travel was expected")
824824 };
825825 assert!(travel.materialize_conflicts);
826− assert_eq!(travel.revision, "HEAD~1");
826+ assert_eq!(travel.revision.as_deref(), Some(std::ffi::OsStr::new("HEAD~1")));
827+ assert_eq!(travel.to, None);
828+ for (value, expected) in [
829+ ("first", travel::To::First),
830+ ("parent", travel::To::Parent),
831+ ("child", travel::To::Child),
832+ ("tip", travel::To::Tip),
833+ ] {
834+ let parsed = Cli::try_parse_from(["tix", "travel", "--to", value])
835+ .expect("relative travel parses")
836+ .platform
837+ .command;
838+ let Some(Command::Travel(travel)) = parsed else {
839+ panic!("travel was expected")
840+ };
841+ assert_eq!(travel.revision, None);
842+ assert_eq!(travel.to, Some(expected));
843+ }
844+ assert_eq!(
845+ Cli::try_parse_from(["tix", "travel"])
846+ .expect_err("travel requires one destination")
847+ .kind(),
848+ ErrorKind::MissingRequiredArgument
849+ );
850+ assert_eq!(
851+ Cli::try_parse_from(["tix", "travel", "HEAD", "--to", "tip"])
852+ .expect_err("relative and explicit travel are mutually exclusive")
853+ .kind(),
854+ ErrorKind::ArgumentConflict
855+ );
856+ assert_eq!(
857+ Cli::try_parse_from(["tix", "travel", "--to", "last"])
858+ .expect_err("tip is the sole name for the upper endpoint")
859+ .kind(),
860+ ErrorKind::InvalidValue
861+ );
862+ let travel_help = Cli::try_parse_from(["tix", "travel", "--help"])
863+ .expect_err("help exits through clap")
864+ .to_string();
865+ for description in [
866+ "Visit the oldest reachable root",
867+ "Visit HEAD's direct visible parent",
868+ "Visit HEAD's direct visible child",
869+ "Visit the reachable leaf",
870+ ] {
871+ assert!(
872+ travel_help.contains(description),
873+ "travel help describes every relative destination: {travel_help}"
874+ );
875+ }
827876 let reword = Cli::try_parse_from(["tix", "reword", "HEAD~2"])
828877 .expect("reword parses")
829878 .platform
gix-tix/src/command/travel.rs+345−5
@@ -1,19 +1,53 @@
1−use std::ffi::OsString;
1+use std::{
2+ collections::{HashMap, HashSet},
3+ ffi::OsString,
4+};
25
36 use anyhow::{Context, Result};
47
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+
531 #[derive(Debug, clap::Args)]
32+#[command(group(
33+ clap::ArgGroup::new("destination")
34+ .required(true)
35+ .multiple(false)
36+ .args(["revision", "to"])
37+))]
638 pub(super) struct Args {
739 /// Check out an encountered replay conflict and write its unmerged index.
840 #[arg(long)]
941 pub(super) materialize_conflicts: bool,
1042 /// Revision resolving to the commit to visit.
1143 #[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>,
1348 }
1449
1550 pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> {
16− let (selected, resolved_graph) = super::resolve_commit(&repository, &args.revision, "time-travel destination")?;
1751 let head = repository.head().context("could not read HEAD before time-travel")?;
1852 let head_id = head
1953 .id()
@@ -21,6 +55,15 @@ pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { · 1 unmodified line
2155 .context("cannot time-travel from an unborn HEAD")?;
2256 let detached = head.is_detached();
2357 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+ };
2467 if selected == head_id {
2568 println!("already at {}", crate::change_id::display(&repository, selected, 7)?);
2669 return Ok(());
@@ -82,6 +125,117 @@ pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { · 55 unmodified lines
82125 Ok(())
83126 }
84127
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+
85239 #[cfg(test)]
86240 mod tests {
87241 use std::{path::Path, process::Command};
@@ -98,10 +252,26 @@ mod tests { · 10 unmodified lines
98252 Ok(output.stdout)
99253 }
100254
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+
101262 fn args(revision: &str) -> Args {
102263 Args {
103264 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),
105275 }
106276 }
107277
@@ -181,6 +351,175 @@ mod tests { · 73 unmodified lines
181351 Ok(())
182352 }
183353
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+
184523 #[test]
185524 fn replay_conflicts_are_unobservable_until_materialization_is_requested() -> gix_testtools::Result {
186525 let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?;
@@ -222,7 +561,8 @@ mod tests { · 35 unmodified lines
222561 crate::test_repository::open(path)?,
223562 Args {
224563 materialize_conflicts: true,
225− revision: tip.to_string().into(),
564+ revision: Some(tip.to_string().into()),
565+ to: None,
226566 },
227567 )
228568 .expect_err("a materialized conflict remains an incomplete command");
gix-tix/src/history.rs+4−0
@@ -241,6 +241,10 @@ impl HistoryGraph { · 240 unmodified lines
241241 self.commits.iter().map(|commit| commit.id)
242242 }
243243
244+ pub(crate) fn stored_commit_ids(&self) -> impl Iterator<Item = ObjectId> + '_ {
245+ self.stored_order.iter().map(|index| self.id(*index))
246+ }
247+
244248 pub(crate) fn is_ancestor(&self, ancestor: ObjectId, descendant: ObjectId) -> bool {
245249 let (Some(ancestor), Some(descendant)) = (self.index(ancestor), self.index(descendant)) else {
246250 return false;

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