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
24 seven-character commit hash is followed by its seven-character reverse-hex24 seven-character commit hash is followed by its seven-character reverse-hex
25 change ID. Colliding or duplicated prefixes remain visible and receive a `💥`25 change ID. Colliding or duplicated prefixes remain visible and receive a `💥`
26 gutter marker.26 gutter marker.
27- `tix travel [--materialize-conflicts] REVSPEC` performs the same detached27- `tix travel [--materialize-conflicts] (REVSPEC | --to first|parent|child|tip)`
28 checkout, pending-rebase replay, stash handling, and pin reconciliation as28 performs the same detached checkout, pending-rebase replay, stash handling,
29 TUI time travel. Its target may also be an unambiguous reverse-hex change-ID29 and pin reconciliation as TUI time travel. Its target may also be an
30 prefix from the default Tix view. Travelling to the current `HEAD` is a no-op.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.
31 A detached source may travel to a descendant without a pin, but travelling to36 A detached source may travel to a descendant without a pin, but travelling to
32 an ancestor or unrelated commit requires an existing current-worktree pin at37 an ancestor or unrelated commit requires an existing current-worktree pin at
33 `HEAD` or a descendant. An attached source is preserved through the singleton38 `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
823 panic!("travel was expected")823 panic!("travel was expected")
824 };824 };
825 assert!(travel.materialize_conflicts);825 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 }
827 let reword = Cli::try_parse_from(["tix", "reword", "HEAD~2"])876 let reword = Cli::try_parse_from(["tix", "reword", "HEAD~2"])
828 .expect("reword parses")877 .expect("reword parses")
829 .platform878 .platform
gix-tix/src/command/travel.rs+345−5
@@ -1,19 +1,53 @@
1use std::ffi::OsString;1use std::{
2 collections::{HashMap, HashSet},
3 ffi::OsString,
4};
2 5
3use anyhow::{Context, Result};6use anyhow::{Context, Result};
4 7
8#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
9pub(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
20impl 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
5#[derive(Debug, clap::Args)]31#[derive(Debug, clap::Args)]
32#[command(group(
33 clap::ArgGroup::new("destination")
34 .required(true)
35 .multiple(false)
36 .args(["revision", "to"])
37))]
6pub(super) struct Args {38pub(super) struct Args {
7 /// Check out an encountered replay conflict and write its unmerged index.39 /// Check out an encountered replay conflict and write its unmerged index.
8 #[arg(long)]40 #[arg(long)]
9 pub(super) materialize_conflicts: bool,41 pub(super) materialize_conflicts: bool,
10 /// Revision resolving to the commit to visit.42 /// Revision resolving to the commit to visit.
11 #[arg(value_name = "REVSPEC")]43 #[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>,
13}48}
14 49
15pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> {50pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> {
16 let (selected, resolved_graph) = super::resolve_commit(&repository, &args.revision, "time-travel destination")?;
17 let head = repository.head().context("could not read HEAD before time-travel")?;51 let head = repository.head().context("could not read HEAD before time-travel")?;
18 let head_id = head52 let head_id = head
19 .id()53 .id()
@@ -21,6 +55,15 @@ pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { · 1 unmodified line
21 .context("cannot time-travel from an unborn HEAD")?;55 .context("cannot time-travel from an unborn HEAD")?;
22 let detached = head.is_detached();56 let detached = head.is_detached();
23 drop(head);57 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 };
24 if selected == head_id {67 if selected == head_id {
25 println!("already at {}", crate::change_id::display(&repository, selected, 7)?);68 println!("already at {}", crate::change_id::display(&repository, selected, 7)?);
26 return Ok(());69 return Ok(());
@@ -82,6 +125,117 @@ pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { · 55 unmodified lines
82 Ok(())125 Ok(())
83}126}
84 127
128fn 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
167fn 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
180fn 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
203fn 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
217fn 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
85#[cfg(test)]239#[cfg(test)]
86mod tests {240mod tests {
87 use std::{path::Path, process::Command};241 use std::{path::Path, process::Command};
@@ -98,10 +252,26 @@ mod tests { · 10 unmodified lines
98 Ok(output.stdout)252 Ok(output.stdout)
99 }253 }
100 254
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
101 fn args(revision: &str) -> Args {262 fn args(revision: &str) -> Args {
102 Args {263 Args {
103 materialize_conflicts: false,264 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),
105 }275 }
106 }276 }
107 277
@@ -181,6 +351,175 @@ mod tests { · 73 unmodified lines
181 Ok(())351 Ok(())
182 }352 }
183 353
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
184 #[test]523 #[test]
185 fn replay_conflicts_are_unobservable_until_materialization_is_requested() -> gix_testtools::Result {524 fn replay_conflicts_are_unobservable_until_materialization_is_requested() -> gix_testtools::Result {
186 let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?;525 let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?;
@@ -222,7 +561,8 @@ mod tests { · 35 unmodified lines
222 crate::test_repository::open(path)?,561 crate::test_repository::open(path)?,
223 Args {562 Args {
224 materialize_conflicts: true,563 materialize_conflicts: true,
225 revision: tip.to_string().into(),564 revision: Some(tip.to_string().into()),
565 to: None,
226 },566 },
227 )567 )
228 .expect_err("a materialized conflict remains an incomplete command");568 .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
241 self.commits.iter().map(|commit| commit.id)241 self.commits.iter().map(|commit| commit.id)
242 }242 }
243 243
244 pub(crate) fn stored_commit_ids(&self) -> impl Iterator<Item = ObjectId> + '_ {
245 self.stored_order.iter().map(|index| self.id(*index))
246 }
247
244 pub(crate) fn is_ancestor(&self, ancestor: ObjectId, descendant: ObjectId) -> bool {248 pub(crate) fn is_ancestor(&self, ancestor: ObjectId, descendant: ObjectId) -> bool {
245 let (Some(ancestor), Some(descendant)) = (self.index(ancestor), self.index(descendant)) else {249 let (Some(ancestor), Some(descendant)) = (self.index(ancestor), self.index(descendant)) else {
246 return false;250 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