History

feat: visualize worktrees in the tix tree

Read each worktree's symbolic HEAD or private HEAD pin so remembered branch labels stay at the branch's actual tip while detached checkout commits receive their own marker. Attached and fallback labels retain the history view's current- and linked-worktree forms. Remove tree pin creation and removal along with ordinary pin anchors. The tree and its ASCII diagnostics now reserve the pin marker for detached worktrees, making detached checkout state visible without exposing internal pin references.

Codex committed 3 months ago

Commit 0bf7b831 parent 4c5be526 files · +194 −236Browse files

Unified · Split

6 files
  1. gix-tix/spec.md +16 −14
  2. gix-tix/src/edit/time_travel.rs +0 −74
  3. gix-tix/src/history.rs +117 −20
  4. gix-tix/src/lib.rs +0 −34
  5. gix-tix/src/tree.rs +51 −91
  6. gix-tix/src/ui.rs +10 −3
gix-tix/spec.md+16−14
@@ -136,11 +136,12 @@ without trading responsiveness for metadata that is not visible. · 135 unmodified lines
136 visible. If it has visible descendants, its unselected non-whitespace content136 visible. If it has visible descendants, its unselected non-whitespace content
137 is underlined and `@` is bold; a selected row keeps only bold `@`.137 is underlined and `@` is bold; a selected row keeps only bold `@`.
138- Local branches checked out in other worktrees are displayed as `short-name@`138- Local branches checked out in other worktrees are displayed as `short-name@`
139 in light blue instead of their plain branch decoration. Other detached139 in light blue instead of their plain branch decoration. The current worktree's
140 worktrees use their checkout directory basename. The current worktree's140 symbolic branch is displayed as `@short-name` in the local-reference color.
141 symbolic branch is displayed as `@short-name` in the local-reference color;141 A detached worktree with a symbolic HEAD pin labels the remembered branch at
142 if its HEAD is detached, a branch that happens to point at the same commit142 that branch's actual tip, even when the checkout is elsewhere; otherwise its
143 remains an ordinary branch label. Identical labels are deduplicated.143 checkout directory basename labels the detached commit. Identical labels are
144 deduplicated.
144- When reference labels are hidden, worktree labels are visible only on the145- When reference labels are hidden, worktree labels are visible only on the
145 selected row. Stale, malformed, unborn, and otherwise unreadable worktree146 selected row. Stale, malformed, unborn, and otherwise unreadable worktree
146 entries are skipped without failing history loading.147 entries are skipped without failing history loading.
@@ -228,19 +229,20 @@ without trading responsiveness for metadata that is not visible. · 81 unmodified lines
228 map uniquely through a named remote's fetch refspecs; it deletes every resolved229 map uniquely through a named remote's fetch refspecs; it deletes every resolved
229 remote reference, grouped into one Git push per remote. Pushes continue after230 remote reference, grouped into one Git push per remote. Pushes continue after
230 individual failures and run with the terminal suspended for output and authentication.231 individual failures and run with the terminal suspended for output and authentication.
231- Ordinary pins use the history view's single blue `📌`, without exposing their232- Worktree branch labels keep the history view's `@branch`, `branch@`, and
232 internal ref names. `p` removes all ordinary pins at a pinned node or creates one233 `★branch` forms at the branch's actual tip. Every detached worktree checkout is
233 at a visible reference/raw tip. A sole visible reference produces a symbolic pin;234 additionally shown with one `📌`; ordinary tix pins neither anchor nor decorate
234 ambiguous or unnamed targets produce a direct pin. Hidden tags do not participate.235 the tree. A detached worktree without a usable remembered branch falls back to
235- After deleting selected branches or pins, refresh keeps the commit when it remains236 its checkout directory basename.
236 a tree node, otherwise selects the next surviving node row or the nearest previous237- After deleting selected local or remote references, refresh keeps the commit
237 row when nothing below survives.238 when it remains a tree node, otherwise selects the next surviving node row or
239 the nearest previous row when nothing below survives.
238 240
239### Tree diagnostics241### Tree diagnostics
240 242
241- `tix tree` prints the entire reference projection to standard output without243- `tix tree` prints the entire reference projection to standard output without
242 terminal colors, selection state, counts, or viewport clipping. Pins render as244 terminal colors, selection state, counts, or viewport clipping. Detached
243 `[pin]` in ASCII output and `📌` with `--unicode`. It traverses245 worktrees render as `[pin]` in ASCII output and `📌` with `--unicode`. It traverses
244 all normal references by default; positional revisions scope traversal.246 all normal references by default; positional revisions scope traversal.
245- `--no-tags`, `--worktrees`, and repeatable `--hide <revision>` match the247- `--no-tags`, `--worktrees`, and repeatable `--hide <revision>` match the
246 corresponding tree inputs. Output uses ASCII lines and `o` nodes by default;248 corresponding tree inputs. Output uses ASCII lines and `o` nodes by default;
gix-tix/src/edit/time_travel.rs+0−74
@@ -653,42 +653,6 @@ pub(crate) fn create_or_reuse_pin( · 652 unmodified lines
653 Ok((history::Pin { name, target, id }, true))653 Ok((history::Pin { name, target, id }, true))
654}654}
655 655
656pub(crate) fn create_tree_pin(
657 repository: &gix::Repository,
658 id: ObjectId,
659 include_tags: bool,
660) -> Result<(history::Pin, bool)> {
661 let mut references = Vec::new();
662 for reference in repository
663 .references()
664 .context("could not open references for tree pinning")?
665 .all()
666 .context("could not iterate references for tree pinning")?
667 {
668 let mut reference =
669 reference.map_err(|err| anyhow::anyhow!("could not read a reference for tree pinning: {err}"))?;
670 let name = reference.name().to_owned();
671 let kind = history::decoration_kind(name.as_bstr());
672 if matches!(
673 kind,
674 history::DecorationKind::Pin | history::DecorationKind::Stash | history::DecorationKind::Special
675 ) || !include_tags && matches!(kind, history::DecorationKind::Tag)
676 || name.as_bstr().starts_with(history::REVIEW_STASH_PREFIX)
677 {
678 continue;
679 }
680 let Ok(target) = reference.peel_to_id() else { continue };
681 if target.detach() == id && repository.find_header(target)?.kind() == gix::object::Kind::Commit {
682 references.push(name);
683 }
684 }
685 let target = match references.as_slice() {
686 [name] => Target::Symbolic(name.clone()),
687 _ => Target::Object(id),
688 };
689 create_or_reuse_pin(repository, target, id, "tix tree pin")
690}
691
692pub(super) fn delete_pin(repository: &gix::Repository, pin: &history::Pin) -> Result<()> {656pub(super) fn delete_pin(repository: &gix::Repository, pin: &history::Pin) -> Result<()> {
693 repository657 repository
694 .edit_references([delete_pin_edit(pin)])658 .edit_references([delete_pin_edit(pin)])
@@ -1320,44 +1284,6 @@ mod tests { · 625 unmodified lines
1320 Ok(())1284 Ok(())
1321 }1285 }
1322 1286
1323 #[test]
1324 fn tree_pins_follow_one_visible_reference_and_freeze_ambiguous_targets() -> gix_testtools::Result {
1325 let fixture = gix_testtools::scripted_fixture_writable("history.sh")?;
1326 let repository = crate::test_repository::open(fixture.path())?;
1327 let main = repository.rev_parse_single("main")?.detach();
1328 let (pin, created) = create_tree_pin(&repository, main, true)?;
1329 assert!(created, "the first tree pin is created");
1330 assert_eq!(
1331 pin.target.try_name().map(gix::refs::FullNameRef::as_bstr),
1332 Some(b"refs/heads/main".as_bstr()),
1333 "one visible reference produces a symbolic pin"
1334 );
1335 remove_pins(repository.git_dir(), repository.is_bare(), main)?;
1336
1337 repository.reference(
1338 "refs/tags/also-main",
1339 main,
1340 PreviousValue::MustNotExist,
1341 "test ambiguous tree pin",
1342 )?;
1343 let (pin, created) = create_tree_pin(&repository, main, true)?;
1344 assert!(created);
1345 assert_eq!(
1346 pin.target,
1347 Target::Object(main),
1348 "multiple visible refs produce a direct pin"
1349 );
1350 remove_pins(repository.git_dir(), repository.is_bare(), main)?;
1351
1352 let (pin, created) = create_tree_pin(&repository, main, false)?;
1353 assert!(created);
1354 assert!(
1355 pin.target.try_name().is_some(),
1356 "a hidden tag does not make the branch target ambiguous"
1357 );
1358 Ok(())
1359 }
1360
1361 #[test]1287 #[test]
1362 fn sideways_travel_preserves_an_unreferenced_departure() -> gix_testtools::Result {1288 fn sideways_travel_preserves_an_unreferenced_departure() -> gix_testtools::Result {
1363 let fixture = gix_testtools::scripted_fixture_writable("history.sh")?;1289 let fixture = gix_testtools::scripted_fixture_writable("history.sh")?;
gix-tix/src/history.rs+117−20
@@ -34,6 +34,7 @@ pub(crate) enum DecorationKind { · 33 unmodified lines
34 Stash,34 Stash,
35 Review,35 Review,
36 CurrentWorktreeBranch,36 CurrentWorktreeBranch,
37 CurrentWorktreeDetached,
37 WorktreeBranch,38 WorktreeBranch,
38 WorktreeDetached,39 WorktreeDetached,
39 Local,40 Local,
@@ -67,9 +68,11 @@ impl Pin { · 27 unmodified lines
67#[derive(Clone, Debug, Eq, PartialEq)]68#[derive(Clone, Debug, Eq, PartialEq)]
68pub(crate) struct WorktreeCheckout {69pub(crate) struct WorktreeCheckout {
69 pub id: ObjectId,70 pub id: ObjectId,
71 pub label_id: ObjectId,
70 pub name: BString,72 pub name: BString,
71 pub reference: Option<gix::refs::FullName>,73 pub reference: Option<gix::refs::FullName>,
72 pub is_current: bool,74 pub is_current: bool,
75 pub is_detached: bool,
73}76}
74 77
75#[derive(Clone, Debug, Eq, PartialEq)]78#[derive(Clone, Debug, Eq, PartialEq)]
@@ -1213,7 +1216,7 @@ pub(crate) fn tree_revisions(repo: &gix::Repository, include_tags: bool) -> Resu · 1137 unmodified lines
1213 };1216 };
1214 let name = reference.name().as_bstr().to_owned();1217 let name = reference.name().as_bstr().to_owned();
1215 let kind = decoration_kind(&name);1218 let kind = decoration_kind(&name);
1216 if kind == DecorationKind::Special1219 if matches!(kind, DecorationKind::Special | DecorationKind::Pin)
1217 || matches!(kind, DecorationKind::Tag) && !include_tags1220 || matches!(kind, DecorationKind::Tag) && !include_tags
1218 || name.starts_with(STASH_PREFIX)1221 || name.starts_with(STASH_PREFIX)
1219 || name.starts_with(REVIEW_STASH_PREFIX)1222 || name.starts_with(REVIEW_STASH_PREFIX)
@@ -1299,6 +1302,7 @@ pub(crate) fn worktree_checkouts(repo: &gix::Repository) -> Vec<WorktreeCheckout · 79 unmodified lines
1299 }1302 }
1300 out.sort_by(|a, b| {1303 out.sort_by(|a, b| {
1301 a.id.cmp(&b.id)1304 a.id.cmp(&b.id)
1305 .then_with(|| a.label_id.cmp(&b.label_id))
1302 .then_with(|| a.name.cmp(&b.name))1306 .then_with(|| a.name.cmp(&b.name))
1303 .then_with(|| a.reference.cmp(&b.reference))1307 .then_with(|| a.reference.cmp(&b.reference))
1304 });1308 });
@@ -1320,7 +1324,8 @@ fn add_worktree_checkout( · 15 unmodified lines
1320 return;1324 return;
1321 }1325 }
1322 };1326 };
1323 let reference = head.referent_name().map(ToOwned::to_owned);1327 let is_detached = head.is_detached();
1328 let head_reference = head.referent_name().map(ToOwned::to_owned);
1324 let id = match head.try_peel_to_id() {1329 let id = match head.try_peel_to_id() {
1325 Ok(Some(id)) => id.detach(),1330 Ok(Some(id)) => id.detach(),
1326 Ok(None) => return,1331 Ok(None) => return,
@@ -1329,6 +1334,12 @@ fn add_worktree_checkout( · 2 unmodified lines
1329 return;1334 return;
1330 }1335 }
1331 };1336 };
1337 let remembered = is_detached
1338 .then(|| remembered_worktree_branch(repo, worktree))
1339 .flatten();
1340 let (reference, label_id) = remembered
1341 .or_else(|| head_reference.map(|reference| (reference, id)))
1342 .map_or((None, id), |(reference, id)| (Some(reference), id));
1332 let name = match &reference {1343 let name = match &reference {
1333 Some(reference) => reference.shorten().to_owned(),1344 Some(reference) => reference.shorten().to_owned(),
1334 None => match detached_name {1345 None => match detached_name {
@@ -1341,12 +1352,51 @@ fn add_worktree_checkout( · 6 unmodified lines
1341 };1352 };
1342 out.push(WorktreeCheckout {1353 out.push(WorktreeCheckout {
1343 id,1354 id,
1355 label_id,
1344 name,1356 name,
1345 reference,1357 reference,
1346 is_current,1358 is_current,
1359 is_detached,
1347 });1360 });
1348}1361}
1349 1362
1363fn remembered_worktree_branch(repo: &gix::Repository, worktree: &BStr) -> Option<(gix::refs::FullName, ObjectId)> {
1364 let mut pin = match repo.try_find_reference(HEAD_PIN_NAME.as_bstr()) {
1365 Ok(Some(pin)) => pin,
1366 Ok(None) => return None,
1367 Err(err) => {
1368 tracing::warn!(%worktree, error = %err, "ignoring unreadable worktree HEAD pin");
1369 return None;
1370 }
1371 };
1372 let Some(reference) = pin.target().try_name().map(ToOwned::to_owned) else {
1373 tracing::warn!(%worktree, "ignoring direct worktree HEAD pin");
1374 return None;
1375 };
1376 if !reference.as_bstr().starts_with(b"refs/heads/") {
1377 tracing::warn!(%worktree, name = %reference, "ignoring non-branch worktree HEAD pin");
1378 return None;
1379 }
1380 let id = match pin.peel_to_id() {
1381 Ok(id) => id.detach(),
1382 Err(err) => {
1383 tracing::warn!(%worktree, name = %reference, error = %err, "ignoring unresolved worktree HEAD pin");
1384 return None;
1385 }
1386 };
1387 match repo.find_header(id) {
1388 Ok(header) if header.kind() == gix::object::Kind::Commit => Some((reference, id)),
1389 Ok(_) => {
1390 tracing::warn!(%worktree, name = %reference, "ignoring worktree HEAD pin with a non-commit target");
1391 None
1392 }
1393 Err(err) => {
1394 tracing::warn!(%worktree, name = %reference, error = %err, "ignoring unreadable worktree HEAD pin target");
1395 None
1396 }
1397 }
1398}
1399
1350fn worktree_basename(path: &std::path::Path) -> Option<BString> {1400fn worktree_basename(path: &std::path::Path) -> Option<BString> {
1351 path.file_name()1401 path.file_name()
1352 .and_then(|name| gix::path::os_str_into_bstr(name).ok())1402 .and_then(|name| gix::path::os_str_into_bstr(name).ok())
@@ -1742,13 +1792,12 @@ pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin], worktrees: &[Wor · 389 unmodified lines
1742 continue;1792 continue;
1743 };1793 };
1744 let id = id.detach();1794 let id = id.detach();
1745 if worktrees1795 if worktrees.iter().any(|worktree| {
1746 .iter()1796 worktree.is_current && worktree.label_id == id && worktree.reference.as_ref() == Some(&full_name)
1747 .any(|worktree| worktree.is_current && worktree.id == id && worktree.reference.as_ref() == Some(&full_name))1797 }) {
1748 {
1749 kind = DecorationKind::CurrentWorktreeBranch;1798 kind = DecorationKind::CurrentWorktreeBranch;
1750 } else if worktrees.iter().any(|worktree| {1799 } else if worktrees.iter().any(|worktree| {
1751 !worktree.is_current && worktree.id == id && worktree.reference.as_ref() == Some(&full_name)1800 !worktree.is_current && worktree.label_id == id && worktree.reference.as_ref() == Some(&full_name)
1752 }) {1801 }) {
1753 kind = DecorationKind::WorktreeBranch;1802 kind = DecorationKind::WorktreeBranch;
1754 }1803 }
@@ -1775,13 +1824,14 @@ pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin], worktrees: &[Wor · 20 unmodified lines
1775 }1824 }
1776 out.entry(id).or_default().push(Decoration { name, kind });1825 out.entry(id).or_default().push(Decoration { name, kind });
1777 }1826 }
1778 for worktree in worktrees.iter().filter(|worktree| !worktree.is_current) {1827 for worktree in worktrees.iter().filter(|worktree| worktree.reference.is_none()) {
1779 let kind = if worktree.reference.is_some() {1828 let kind = match (worktree.is_current, worktree.is_detached) {
1780 DecorationKind::WorktreeBranch1829 (true, true) => DecorationKind::CurrentWorktreeDetached,
1781 } else {1830 (true, false) => DecorationKind::CurrentWorktreeBranch,
1782 DecorationKind::WorktreeDetached1831 (false, true) => DecorationKind::WorktreeDetached,
1832 (false, false) => DecorationKind::WorktreeBranch,
1783 };1833 };
1784 let decorations = out.entry(worktree.id).or_default();1834 let decorations = out.entry(worktree.label_id).or_default();
1785 if !decorations1835 if !decorations
1786 .iter()1836 .iter()
1787 .any(|decoration| decoration.kind == kind && decoration.name == worktree.name)1837 .any(|decoration| decoration.kind == kind && decoration.name == worktree.name)
@@ -2094,8 +2144,10 @@ mod tests { · 306 unmodified lines
2094 let worktrees = worktree_checkouts(&repo);2144 let worktrees = worktree_checkouts(&repo);
2095 assert!(worktrees.iter().any(|worktree| {2145 assert!(worktrees.iter().any(|worktree| {
2096 worktree.id == main2146 worktree.id == main
2147 && worktree.label_id == main
2097 && worktree.name == "main"2148 && worktree.name == "main"
2098 && worktree.is_current2149 && worktree.is_current
2150 && !worktree.is_detached
2099 && worktree2151 && worktree
2100 .reference2152 .reference
2101 .as_ref()2153 .as_ref()
@@ -2103,8 +2155,10 @@ mod tests { · 1 unmodified line
2103 }));2155 }));
2104 assert!(worktrees.iter().any(|worktree| {2156 assert!(worktrees.iter().any(|worktree| {
2105 worktree.id == topic2157 worktree.id == topic
2158 && worktree.label_id == topic
2106 && worktree.name == "topic"2159 && worktree.name == "topic"
2107 && !worktree.is_current2160 && !worktree.is_current
2161 && !worktree.is_detached
2108 && worktree2162 && worktree
2109 .reference2163 .reference
2110 .as_ref()2164 .as_ref()
@@ -2112,9 +2166,11 @@ mod tests { · 1 unmodified line
2112 }));2166 }));
2113 assert!(worktrees.iter().any(|worktree| {2167 assert!(worktrees.iter().any(|worktree| {
2114 worktree.id == root2168 worktree.id == root
2169 && worktree.label_id == root
2115 && worktree.name == "detached-wt"2170 && worktree.name == "detached-wt"
2116 && worktree.reference.is_none()2171 && worktree.reference.is_none()
2117 && !worktree.is_current2172 && !worktree.is_current
2173 && worktree.is_detached
2118 }));2174 }));
2119 assert_eq!(worktrees.len(), 3, "the malformed worktree is ignored");2175 assert_eq!(worktrees.len(), 3, "the malformed worktree is ignored");
2120 2176
@@ -2181,14 +2237,43 @@ mod tests { · 60 unmodified lines
2181 .find(|worktree| worktree.is_current)2237 .find(|worktree| worktree.is_current)
2182 .expect("the current detached worktree is discovered");2238 .expect("the current detached worktree is discovered");
2183 assert!(current.reference.is_none());2239 assert!(current.reference.is_none());
2240 assert!(current.is_detached);
2241 assert_eq!(current.label_id, current.id);
2184 let detached_decorations = decorations(&detached_repo, &[], &detached_worktrees)?;2242 let detached_decorations = decorations(&detached_repo, &[], &detached_worktrees)?;
2243 assert!(detached_decorations.get(&current.id).is_some_and(|decorations| {
2244 decorations.iter().any(|decoration| {
2245 decoration.kind == DecorationKind::CurrentWorktreeDetached && decoration.name == current.name
2246 })
2247 }));
2248
2249 let symbolic = Command::new("git")
2250 .current_dir(&linked_path)
2251 .args(["symbolic-ref", "refs/worktree/tix/pins/HEAD", "refs/heads/topic"])
2252 .status()?;
2253 assert!(symbolic.success(), "git remembers the detached worktree's branch");
2254 let remembered_repo = crate::test_repository::open(&linked_path)?;
2255 let remembered_worktrees = worktree_checkouts(&remembered_repo);
2256 let current = remembered_worktrees
2257 .iter()
2258 .find(|worktree| worktree.is_current)
2259 .expect("the remembered detached worktree is discovered");
2260 assert_ne!(current.id, topic, "the worktree remains detached away from its branch");
2261 assert_eq!(current.label_id, topic, "the label follows the remembered branch tip");
2262 assert_eq!(current.name, "topic");
2263 assert!(current.is_detached);
2185 assert!(2264 assert!(
2186 !detached_decorations2265 current
2187 .get(&current.id)2266 .reference
2188 .is_some_and(|decorations| decorations.iter().any(|decoration| {2267 .as_ref()
2189 decoration.kind == DecorationKind::WorktreeDetached && decoration.name == current.name2268 .is_some_and(|name| name.as_bstr() == b"refs/heads/topic")
2190 }))
2191 );2269 );
2270 let remembered_pins = applicable_pins(&remembered_repo)?;
2271 let remembered_decorations = decorations(&remembered_repo, &remembered_pins, &remembered_worktrees)?;
2272 assert!(remembered_decorations.get(&topic).is_some_and(|decorations| {
2273 decorations
2274 .iter()
2275 .any(|decoration| decoration.kind == DecorationKind::HeadPinBranch && decoration.name == "topic")
2276 }));
2192 Ok(())2277 Ok(())
2193 }2278 }
2194 2279
@@ -2531,8 +2616,14 @@ mod tests { · 336 unmodified lines
2531 2616
2532 #[test]2617 #[test]
2533 fn tree_revisions_cover_normal_refs_and_optionally_tags() -> gix_testtools::Result {2618 fn tree_revisions_cover_normal_refs_and_optionally_tags() -> gix_testtools::Result {
2534 let fixture = fixture()?;2619 let fixture = gix_testtools::scripted_fixture_writable("history.sh")?;
2535 let repo = crate::test_repository::open(&fixture)?;2620 let repo = crate::test_repository::open(fixture.path())?;
2621 repo.reference(
2622 "refs/worktree/tix/pins/test",
2623 repo.head_id()?,
2624 gix::refs::transaction::PreviousValue::MustNotExist,
2625 "test tree revisions",
2626 )?;
2536 let names = |include_tags| {2627 let names = |include_tags| {
2537 tree_revisions(&repo, include_tags).map(|revisions| {2628 tree_revisions(&repo, include_tags).map(|revisions| {
2538 revisions2629 revisions
@@ -2556,6 +2647,12 @@ mod tests { · 17 unmodified lines
2556 without_tags.iter().all(|name| !name.starts_with("refs/tags/")),2647 without_tags.iter().all(|name| !name.starts_with("refs/tags/")),
2557 "tagless rendering does not traverse tag-only history"2648 "tagless rendering does not traverse tag-only history"
2558 );2649 );
2650 assert!(
2651 with_tags
2652 .iter()
2653 .all(|name| !name.starts_with("refs/worktree/tix/pins/")),
2654 "ordinary pins never become tree traversal tips"
2655 );
2559 Ok(())2656 Ok(())
2560 }2657 }
2561 2658
gix-tix/src/lib.rs+0−34
@@ -1571,40 +1571,6 @@ fn event_loop( · 1570 unmodified lines
1571 urgent = true;1571 urgent = true;
1572 continue;1572 continue;
1573 }1573 }
1574 tree::Input::Pin { id, include_tags } => {
1575 match open_repository(&repository_path, repository_is_bare, false) {
1576 Ok(repository) => match edit::time_travel::create_tree_pin(&repository, id, include_tags) {
1577 Ok((pin, created)) => {
1578 let label = edit::time_travel::pin_label(&pin);
1579 tree.leave_message(if created {
1580 format!("created {label}")
1581 } else {
1582 format!("already pinned as {label}")
1583 });
1584 refresh_pending |= created;
1585 }
1586 Err(err) => tree.leave_message(format!("pin: {err:#}")),
1587 },
1588 Err(err) => tree.leave_message(format!("pin: {err:#}")),
1589 }
1590 dirty = true;
1591 urgent = true;
1592 continue;
1593 }
1594 tree::Input::Unpin { id, fallback } => {
1595 match edit::time_travel::remove_pins(&repository_path, repository_is_bare, id) {
1596 Ok(0) => tree.leave_message("no pins removed"),
1597 Ok(count) => {
1598 tree.leave_message(format!("removed {count} pin{}", if count == 1 { "" } else { "s" }));
1599 tree.select_after_reference_deletion(fallback);
1600 refresh_pending = true;
1601 }
1602 Err(err) => tree.leave_message(format!("unpin: {err:#}")),
1603 }
1604 dirty = true;
1605 urgent = true;
1606 continue;
1607 }
1608 tree::Input::Quit => return Ok(None),1574 tree::Input::Quit => return Ok(None),
1609 },1575 },
1610 TerminalEvent::Mouse(mouse) if tree.handle_mouse(mouse.kind, 1) => {1576 TerminalEvent::Mouse(mouse) if tree.handle_mouse(mouse.kind, 1) => {
gix-tix/src/tree.rs+51−91
@@ -46,8 +46,7 @@ struct Node { · 45 unmodified lines
46 decorations: Vec<Decoration>,46 decorations: Vec<Decoration>,
47 is_head: bool,47 is_head: bool,
48 is_anchor: bool,48 is_anchor: bool,
49 is_pinned: bool,49 is_detached_worktree: bool,
50 can_pin: bool,
51 raw_tip: bool,50 raw_tip: bool,
52 sort_key: String,51 sort_key: String,
53}52}
@@ -129,14 +128,6 @@ pub(crate) enum Input { · 75 unmodified lines
129 groups: Vec<RemoteDeletion>,128 groups: Vec<RemoteDeletion>,
130 fallback: SelectionFallback,129 fallback: SelectionFallback,
131 },130 },
132 Pin {
133 id: ObjectId,
134 include_tags: bool,
135 },
136 Unpin {
137 id: ObjectId,
138 fallback: SelectionFallback,
139 },
140 Quit,131 Quit,
141}132}
142 133
@@ -314,30 +305,6 @@ impl Tree { · 171 unmodified lines
314 self.toggle_count_anchor();305 self.toggle_count_anchor();
315 return Input::Handled;306 return Input::Handled;
316 }307 }
317 if key.code == KeyCode::Char('p') && key.modifiers.is_empty() {
318 let Some(node) = self
319 .selected
320 .and_then(|selected| self.overview.as_ref()?.nodes.get(selected))
321 else {
322 return Input::Handled;
323 };
324 if node.is_pinned {
325 return Input::Unpin {
326 id: node.id,
327 fallback: self
328 .reference_deletion_fallback()
329 .expect("a selected pin has a deletion fallback"),
330 };
331 }
332 if !node.can_pin {
333 self.message = Some("no reference at the selected node".into());
334 return Input::Handled;
335 }
336 return Input::Pin {
337 id: node.id,
338 include_tags: !self.hide_tags,
339 };
340 }
341 if key.code == KeyCode::Char('G')308 if key.code == KeyCode::Char('G')
342 || key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::SHIFT)309 || key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::SHIFT)
343 {310 {
@@ -514,21 +481,8 @@ impl Tree { · 170 unmodified lines
514 || "Space counts:auto".into(),481 || "Space counts:auto".into(),
515 |id| format!("Space counts:{}", self.node_label(id)),482 |id| format!("Space counts:{}", self.node_label(id)),
516 );483 );
517 let pin = self
518 .selected
519 .and_then(|selected| self.overview.as_ref()?.nodes.get(selected))
520 .and_then(|node| {
521 if node.is_pinned {
522 Some(" · p unpin")
523 } else if node.can_pin {
524 Some(" · p pin")
525 } else {
526 None
527 }
528 })
529 .unwrap_or_default();
530 format!(484 format!(
531 "tree · {motion} · {counts}{pin} · g top · G root · T tags:{tags} · Shift+directions pan · e edit · t/Esc history"485 "tree · {motion} · {counts} · g top · G root · T tags:{tags} · Shift+directions pan · e edit · t/Esc history"
532 )486 )
533 };487 };
534 frame.render_widget(488 frame.render_widget(
@@ -751,8 +705,7 @@ impl Overview { · 216 unmodified lines
751 let mut labels = HashMap::<CommitIndex, Vec<Decoration>>::new();705 let mut labels = HashMap::<CommitIndex, Vec<Decoration>>::new();
752 let mut heads = HashSet::new();706 let mut heads = HashSet::new();
753 let mut anchors = HashSet::new();707 let mut anchors = HashSet::new();
754 let mut pinned = HashSet::new();708 let mut detached_worktrees = HashSet::new();
755 let mut pinnable = HashSet::new();
756 for (id, decorations) in decorations {709 for (id, decorations) in decorations {
757 let Some(index) = graph.index(*id) else { continue };710 let Some(index) = graph.index(*id) else { continue };
758 for decoration in decorations {711 for decoration in decorations {
@@ -762,27 +715,30 @@ impl Overview { · 3 unmodified lines
762 if decoration.kind == DecorationKind::Head {715 if decoration.kind == DecorationKind::Head {
763 heads.insert(index);716 heads.insert(index);
764 anchors.insert(index);717 anchors.insert(index);
765 pinnable.insert(index);
766 } else if decoration.kind == DecorationKind::Pin {718 } else if decoration.kind == DecorationKind::Pin {
767 pinned.insert(index);719 continue;
768 anchors.insert(index);
769 } else if decoration.kind != DecorationKind::Special {720 } else if decoration.kind != DecorationKind::Special {
770 labels.entry(index).or_default().push(decoration.clone());721 labels.entry(index).or_default().push(decoration.clone());
771 anchors.insert(index);722 anchors.insert(index);
772 if decoration.kind != DecorationKind::Stash {
773 pinnable.insert(index);
774 }
775 }723 }
776 }724 }
777 }725 }
726 for worktree in refs.worktrees.iter().filter(|worktree| worktree.is_detached) {
727 let Some(index) = graph.index(worktree.id) else {
728 continue;
729 };
730 detached_worktrees.insert(index);
731 anchors.insert(index);
732 }
733 let pin_tips: HashSet<_> = refs.pins.iter().map(|pin| pin.id).collect();
778 let raw: HashSet<_> = refs734 let raw: HashSet<_> = refs
779 .view_tips735 .view_tips
780 .iter()736 .iter()
781 .chain(&refs.hidden_tips)737 .chain(&refs.hidden_tips)
738 .filter(|id| !pin_tips.contains(*id))
782 .filter_map(|id| graph.index(*id))739 .filter_map(|id| graph.index(*id))
783 .inspect(|index| {740 .inspect(|index| {
784 anchors.insert(*index);741 anchors.insert(*index);
785 pinnable.insert(*index);
786 })742 })
787 .collect();743 .collect();
788 if anchors.is_empty() {744 if anchors.is_empty() {
@@ -849,8 +805,7 @@ impl Overview { · 60 unmodified lines
849 decorations,805 decorations,
850 is_head: heads.contains(&commit),806 is_head: heads.contains(&commit),
851 is_anchor: anchors.contains(&commit),807 is_anchor: anchors.contains(&commit),
852 is_pinned: pinned.contains(&commit),808 is_detached_worktree: detached_worktrees.contains(&commit),
853 can_pin: pinnable.contains(&commit),
854 raw_tip: raw.contains(&commit),809 raw_tip: raw.contains(&commit),
855 sort_key,810 sort_key,
856 }811 }
@@ -1249,7 +1204,7 @@ fn rail_label(node: &Node, count: Option<usize>) -> String { · 392 unmodified lines
1249 out.push(' ');1204 out.push(' ');
1250 }1205 }
1251 out.push_str(&suffix);1206 out.push_str(&suffix);
1252 if node.is_pinned {1207 if node.is_detached_worktree {
1253 if !out.is_empty() {1208 if !out.is_empty() {
1254 out.push(' ');1209 out.push(' ');
1255 }1210 }
@@ -1262,7 +1217,17 @@ fn node_label(node: &Node) -> String { · 6 unmodified lines
1262 let labels: Vec<_> = node1217 let labels: Vec<_> = node
1263 .decorations1218 .decorations
1264 .iter()1219 .iter()
1265 .map(|decoration| decoration.name.to_str_lossy())1220 .map(|decoration| {
1221 let name = decoration.name.to_str_lossy();
1222 match decoration.kind {
1223 DecorationKind::CurrentWorktreeBranch | DecorationKind::CurrentWorktreeDetached => {
1224 format!("@{name}")
1225 }
1226 DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached => format!("{name}@"),
1227 DecorationKind::HeadPinBranch => format!("★{name}"),
1228 _ => name.into_owned(),
1229 }
1230 })
1266 .collect();1231 .collect();
1267 if !labels.is_empty() {1232 if !labels.is_empty() {
1268 labels.join(", ")1233 labels.join(", ")
@@ -1854,8 +1819,8 @@ mod tests { · 585 unmodified lines
1854 }1819 }
1855 1820
1856 #[test]1821 #[test]
1857 fn pins_are_markers_and_p_toggles_only_reference_nodes() {1822 fn detached_worktrees_are_pin_markers_while_ordinary_pins_and_p_are_hidden() {
1858 let (graph, refs, mut decorations) = fixture();1823 let (graph, mut refs, mut decorations) = fixture();
1859 *decorations.get_mut(&id(5)).expect("topic is decorated") = vec![1824 *decorations.get_mut(&id(5)).expect("topic is decorated") = vec![
1860 Decoration {1825 Decoration {
1861 name: "pin:first".into(),1826 name: "pin:first".into(),
@@ -1866,46 +1831,41 @@ mod tests { · 4 unmodified lines
1866 kind: DecorationKind::Pin,1831 kind: DecorationKind::Pin,
1867 },1832 },
1868 ];1833 ];
1834 refs.pins.push(crate::history::Pin {
1835 name: "refs/worktree/tix/pins/first".try_into().expect("valid"),
1836 target: gix::refs::Target::Object(id(5)),
1837 id: id(5),
1838 });
1839 refs.worktrees.push(crate::history::WorktreeCheckout {
1840 id: id(3),
1841 label_id: id(6),
1842 name: "main".into(),
1843 reference: Some("refs/heads/main".try_into().expect("valid")),
1844 is_current: true,
1845 is_detached: true,
1846 });
1847 decorations.get_mut(&id(6)).expect("main is decorated")[0].kind = DecorationKind::HeadPinBranch;
1869 let mut tree = Tree::default();1848 let mut tree = Tree::default();
1870 tree.rebuild(&graph, &refs, &decorations);1849 tree.rebuild(&graph, &refs, &decorations);
1871 let topic =
1872 tree.overview.as_ref().expect("overview exists").by_commit[&graph.index(id(5)).expect("topic exists")];
1873 tree.selected = Some(topic);
1874
1875 assert!(matches!(
1876 tree.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)),
1877 Input::Unpin { id: selected, .. } if selected == id(5)
1878 ));
1879 let overview = tree.overview.as_ref().expect("overview exists");1850 let overview = tree.overview.as_ref().expect("overview exists");
1880 assert!(1851 assert!(
1881 overview.nodes[topic].decorations.is_empty(),1852 !overview.nodes.iter().any(|node| node.id == id(5)),
1882 "pin-only tips need no label"1853 "ordinary pin-only tips are absent"
1883 );1854 );
1884 let unicode = render_overview(overview, true);1855 let unicode = render_overview(overview, true);
1885 let ascii = render_overview(overview, false);1856 let ascii = render_overview(overview, false);
1886 assert_eq!(unicode.matches('📌').count(), 1, "multiple pins share one marker");1857 assert_eq!(unicode.matches('📌').count(), 1, "detached HEAD has one marker");
1887 assert!(ascii.contains("[pin]"), "ASCII diagnostics retain the pin marker");1858 assert!(
1888 assert!(!unicode.contains("pin:"), "internal pin names are hidden");1859 unicode.contains("★main"),
1889 1860 "the remembered branch stays at its actual tip"
1890 let main = overview.by_commit[&graph.index(id(6)).expect("main exists")];
1891 tree.selected = Some(main);
1892 assert_eq!(
1893 tree.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)),
1894 Input::Pin {
1895 id: id(6),
1896 include_tags: true,
1897 },
1898 "an unpinned reference offers pin creation"
1899 );1861 );
1900 let fork =1862 assert!(ascii.contains("[pin]"), "ASCII diagnostics retain detached state");
1901 tree.overview.as_ref().expect("overview exists").by_commit[&graph.index(id(2)).expect("fork exists")];1863 assert!(!unicode.contains("pin:"), "ordinary pin names remain hidden");
1902 tree.selected = Some(fork);
1903 assert_eq!(1864 assert_eq!(
1904 tree.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)),1865 tree.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)),
1905 Input::Handled,1866 Input::Handled,
1906 "structural-only nodes cannot be pinned"1867 "the tree has no pin action"
1907 );1868 );
1908 assert_eq!(tree.message.as_deref(), Some("no reference at the selected node"));
1909 }1869 }
1910 1870
1911 #[test]1871 #[test]
gix-tix/src/ui.rs+10−3
@@ -1753,7 +1753,9 @@ fn metadata_line<'a>( · 1752 unmodified lines
1753 selected1753 selected
1754 && matches!(1754 && matches!(
1755 decoration.kind,1755 decoration.kind,
1756 DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached1756 DecorationKind::WorktreeBranch
1757 | DecorationKind::WorktreeDetached
1758 | DecorationKind::CurrentWorktreeDetached
1757 )1759 )
1758 }1760 }
1759 })1761 })
@@ -1773,7 +1775,10 @@ fn metadata_line<'a>( · 13 unmodified lines
1773 format!("{name}@").into()1775 format!("{name}@").into()
1774 } else if decoration.kind == DecorationKind::HeadPinBranch {1776 } else if decoration.kind == DecorationKind::HeadPinBranch {
1775 format!("★{name}").into()1777 format!("★{name}").into()
1776 } else if decoration.kind == DecorationKind::CurrentWorktreeBranch {1778 } else if matches!(
1779 decoration.kind,
1780 DecorationKind::CurrentWorktreeBranch | DecorationKind::CurrentWorktreeDetached
1781 ) {
1777 format!("@{name}").into()1782 format!("@{name}").into()
1778 } else {1783 } else {
1779 name1784 name
@@ -1951,7 +1956,9 @@ pub(crate) fn decoration_style(kind: DecorationKind) -> Style { · 171 unmodified lines
1951 DecorationKind::HeadPinBranch => Style::default().fg(Color::Cyan),1956 DecorationKind::HeadPinBranch => Style::default().fg(Color::Cyan),
1952 DecorationKind::Stash => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),1957 DecorationKind::Stash => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),
1953 DecorationKind::Review => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),1958 DecorationKind::Review => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),
1954 DecorationKind::CurrentWorktreeBranch => Style::default().fg(Color::Cyan),1959 DecorationKind::CurrentWorktreeBranch | DecorationKind::CurrentWorktreeDetached => {
1960 Style::default().fg(Color::Cyan)
1961 }
1955 DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached => Style::default().fg(Color::LightBlue),1962 DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached => Style::default().fg(Color::LightBlue),
1956 DecorationKind::Local => Style::default().fg(Color::Cyan),1963 DecorationKind::Local => Style::default().fg(Color::Cyan),
1957 DecorationKind::Remote => Style::default().fg(Color::Yellow),1964 DecorationKind::Remote => Style::default().fg(Color::Yellow),

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