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
136136 visible. If it has visible descendants, its unselected non-whitespace content
137137 is underlined and `@` is bold; a selected row keeps only bold `@`.
138138 - Local branches checked out in other worktrees are displayed as `short-name@`
139− in light blue instead of their plain branch decoration. Other detached
140− worktrees use their checkout directory basename. The current worktree's
141− symbolic branch is displayed as `@short-name` in the local-reference color;
142− if its HEAD is detached, a branch that happens to point at the same commit
143− remains an ordinary branch label. Identical labels are deduplicated.
139+ in light blue instead of their plain branch decoration. The current worktree's
140+ 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+ that branch's actual tip, even when the checkout is elsewhere; otherwise its
143+ checkout directory basename labels the detached commit. Identical labels are
144+ deduplicated.
144145 - When reference labels are hidden, worktree labels are visible only on the
145146 selected row. Stale, malformed, unborn, and otherwise unreadable worktree
146147 entries are skipped without failing history loading.
@@ -228,19 +229,20 @@ without trading responsiveness for metadata that is not visible. · 81 unmodified lines
228229 map uniquely through a named remote's fetch refspecs; it deletes every resolved
229230 remote reference, grouped into one Git push per remote. Pushes continue after
230231 individual failures and run with the terminal suspended for output and authentication.
231−- Ordinary pins use the history view's single blue `📌`, without exposing their
232− internal ref names. `p` removes all ordinary pins at a pinned node or creates one
233− at a visible reference/raw tip. A sole visible reference produces a symbolic pin;
234− ambiguous or unnamed targets produce a direct pin. Hidden tags do not participate.
235−- After deleting selected branches or pins, refresh keeps the commit when it remains
236− a tree node, otherwise selects the next surviving node row or the nearest previous
237− row when nothing below survives.
232+- Worktree branch labels keep the history view's `@branch`, `branch@`, and
233+ `★branch` forms at the branch's actual tip. Every detached worktree checkout is
234+ additionally shown with one `📌`; ordinary tix pins neither anchor nor decorate
235+ the tree. A detached worktree without a usable remembered branch falls back to
236+ its checkout directory basename.
237+- After deleting selected local or remote references, refresh keeps the commit
238+ when it remains a tree node, otherwise selects the next surviving node row or
239+ the nearest previous row when nothing below survives.
238240
239241 ### Tree diagnostics
240242
241243 - `tix tree` prints the entire reference projection to standard output without
242− terminal colors, selection state, counts, or viewport clipping. Pins render as
243− `[pin]` in ASCII output and `📌` with `--unicode`. It traverses
244+ terminal colors, selection state, counts, or viewport clipping. Detached
245+ worktrees render as `[pin]` in ASCII output and `📌` with `--unicode`. It traverses
244246 all normal references by default; positional revisions scope traversal.
245247 - `--no-tags`, `--worktrees`, and repeatable `--hide <revision>` match the
246248 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
653653 Ok((history::Pin { name, target, id }, true))
654654 }
655655
656−pub(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−
692656 pub(super) fn delete_pin(repository: &gix::Repository, pin: &history::Pin) -> Result<()> {
693657 repository
694658 .edit_references([delete_pin_edit(pin)])
@@ -1320,44 +1284,6 @@ mod tests { · 625 unmodified lines
13201284 Ok(())
13211285 }
13221286
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−
13611287 #[test]
13621288 fn sideways_travel_preserves_an_unreferenced_departure() -> gix_testtools::Result {
13631289 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
3434 Stash,
3535 Review,
3636 CurrentWorktreeBranch,
37+ CurrentWorktreeDetached,
3738 WorktreeBranch,
3839 WorktreeDetached,
3940 Local,
@@ -67,9 +68,11 @@ impl Pin { · 27 unmodified lines
6768 #[derive(Clone, Debug, Eq, PartialEq)]
6869 pub(crate) struct WorktreeCheckout {
6970 pub id: ObjectId,
71+ pub label_id: ObjectId,
7072 pub name: BString,
7173 pub reference: Option<gix::refs::FullName>,
7274 pub is_current: bool,
75+ pub is_detached: bool,
7376 }
7477
7578 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -1213,7 +1216,7 @@ pub(crate) fn tree_revisions(repo: &gix::Repository, include_tags: bool) -> Resu · 1137 unmodified lines
12131216 };
12141217 let name = reference.name().as_bstr().to_owned();
12151218 let kind = decoration_kind(&name);
1216− if kind == DecorationKind::Special
1219+ if matches!(kind, DecorationKind::Special | DecorationKind::Pin)
12171220 || matches!(kind, DecorationKind::Tag) && !include_tags
12181221 || name.starts_with(STASH_PREFIX)
12191222 || name.starts_with(REVIEW_STASH_PREFIX)
@@ -1299,6 +1302,7 @@ pub(crate) fn worktree_checkouts(repo: &gix::Repository) -> Vec<WorktreeCheckout · 79 unmodified lines
12991302 }
13001303 out.sort_by(|a, b| {
13011304 a.id.cmp(&b.id)
1305+ .then_with(|| a.label_id.cmp(&b.label_id))
13021306 .then_with(|| a.name.cmp(&b.name))
13031307 .then_with(|| a.reference.cmp(&b.reference))
13041308 });
@@ -1320,7 +1324,8 @@ fn add_worktree_checkout( · 15 unmodified lines
13201324 return;
13211325 }
13221326 };
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);
13241329 let id = match head.try_peel_to_id() {
13251330 Ok(Some(id)) => id.detach(),
13261331 Ok(None) => return,
@@ -1329,6 +1334,12 @@ fn add_worktree_checkout( · 2 unmodified lines
13291334 return;
13301335 }
13311336 };
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));
13321343 let name = match &reference {
13331344 Some(reference) => reference.shorten().to_owned(),
13341345 None => match detached_name {
@@ -1341,12 +1352,51 @@ fn add_worktree_checkout( · 6 unmodified lines
13411352 };
13421353 out.push(WorktreeCheckout {
13431354 id,
1355+ label_id,
13441356 name,
13451357 reference,
13461358 is_current,
1359+ is_detached,
13471360 });
13481361 }
13491362
1363+fn 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+
13501400 fn worktree_basename(path: &std::path::Path) -> Option<BString> {
13511401 path.file_name()
13521402 .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
17421792 continue;
17431793 };
17441794 let id = id.detach();
1745− if worktrees
1746− .iter()
1747− .any(|worktree| worktree.is_current && worktree.id == id && worktree.reference.as_ref() == Some(&full_name))
1748− {
1795+ if worktrees.iter().any(|worktree| {
1796+ worktree.is_current && worktree.label_id == id && worktree.reference.as_ref() == Some(&full_name)
1797+ }) {
17491798 kind = DecorationKind::CurrentWorktreeBranch;
17501799 } 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)
17521801 }) {
17531802 kind = DecorationKind::WorktreeBranch;
17541803 }
@@ -1775,13 +1824,14 @@ pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin], worktrees: &[Wor · 20 unmodified lines
17751824 }
17761825 out.entry(id).or_default().push(Decoration { name, kind });
17771826 }
1778− for worktree in worktrees.iter().filter(|worktree| !worktree.is_current) {
1779− let kind = if worktree.reference.is_some() {
1780− DecorationKind::WorktreeBranch
1781− } else {
1782− DecorationKind::WorktreeDetached
1827+ for worktree in worktrees.iter().filter(|worktree| worktree.reference.is_none()) {
1828+ let kind = match (worktree.is_current, worktree.is_detached) {
1829+ (true, true) => DecorationKind::CurrentWorktreeDetached,
1830+ (true, false) => DecorationKind::CurrentWorktreeBranch,
1831+ (false, true) => DecorationKind::WorktreeDetached,
1832+ (false, false) => DecorationKind::WorktreeBranch,
17831833 };
1784− let decorations = out.entry(worktree.id).or_default();
1834+ let decorations = out.entry(worktree.label_id).or_default();
17851835 if !decorations
17861836 .iter()
17871837 .any(|decoration| decoration.kind == kind && decoration.name == worktree.name)
@@ -2094,8 +2144,10 @@ mod tests { · 306 unmodified lines
20942144 let worktrees = worktree_checkouts(&repo);
20952145 assert!(worktrees.iter().any(|worktree| {
20962146 worktree.id == main
2147+ && worktree.label_id == main
20972148 && worktree.name == "main"
20982149 && worktree.is_current
2150+ && !worktree.is_detached
20992151 && worktree
21002152 .reference
21012153 .as_ref()
@@ -2103,8 +2155,10 @@ mod tests { · 1 unmodified line
21032155 }));
21042156 assert!(worktrees.iter().any(|worktree| {
21052157 worktree.id == topic
2158+ && worktree.label_id == topic
21062159 && worktree.name == "topic"
21072160 && !worktree.is_current
2161+ && !worktree.is_detached
21082162 && worktree
21092163 .reference
21102164 .as_ref()
@@ -2112,9 +2166,11 @@ mod tests { · 1 unmodified line
21122166 }));
21132167 assert!(worktrees.iter().any(|worktree| {
21142168 worktree.id == root
2169+ && worktree.label_id == root
21152170 && worktree.name == "detached-wt"
21162171 && worktree.reference.is_none()
21172172 && !worktree.is_current
2173+ && worktree.is_detached
21182174 }));
21192175 assert_eq!(worktrees.len(), 3, "the malformed worktree is ignored");
21202176
@@ -2181,14 +2237,43 @@ mod tests { · 60 unmodified lines
21812237 .find(|worktree| worktree.is_current)
21822238 .expect("the current detached worktree is discovered");
21832239 assert!(current.reference.is_none());
2240+ assert!(current.is_detached);
2241+ assert_eq!(current.label_id, current.id);
21842242 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);
21852264 assert!(
2186− !detached_decorations
2187− .get(&current.id)
2188− .is_some_and(|decorations| decorations.iter().any(|decoration| {
2189− decoration.kind == DecorationKind::WorktreeDetached && decoration.name == current.name
2190− }))
2265+ current
2266+ .reference
2267+ .as_ref()
2268+ .is_some_and(|name| name.as_bstr() == b"refs/heads/topic")
21912269 );
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+ }));
21922277 Ok(())
21932278 }
21942279
@@ -2531,8 +2616,14 @@ mod tests { · 336 unmodified lines
25312616
25322617 #[test]
25332618 fn tree_revisions_cover_normal_refs_and_optionally_tags() -> gix_testtools::Result {
2534− let fixture = fixture()?;
2535− let repo = crate::test_repository::open(&fixture)?;
2619+ let fixture = gix_testtools::scripted_fixture_writable("history.sh")?;
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+ )?;
25362627 let names = |include_tags| {
25372628 tree_revisions(&repo, include_tags).map(|revisions| {
25382629 revisions
@@ -2556,6 +2647,12 @@ mod tests { · 17 unmodified lines
25562647 without_tags.iter().all(|name| !name.starts_with("refs/tags/")),
25572648 "tagless rendering does not traverse tag-only history"
25582649 );
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+ );
25592656 Ok(())
25602657 }
25612658
gix-tix/src/lib.rs+0−34
@@ -1571,40 +1571,6 @@ fn event_loop( · 1570 unmodified lines
15711571 urgent = true;
15721572 continue;
15731573 }
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− }
16081574 tree::Input::Quit => return Ok(None),
16091575 },
16101576 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
4646 decorations: Vec<Decoration>,
4747 is_head: bool,
4848 is_anchor: bool,
49− is_pinned: bool,
50− can_pin: bool,
49+ is_detached_worktree: bool,
5150 raw_tip: bool,
5251 sort_key: String,
5352 }
@@ -129,14 +128,6 @@ pub(crate) enum Input { · 75 unmodified lines
129128 groups: Vec<RemoteDeletion>,
130129 fallback: SelectionFallback,
131130 },
132− Pin {
133− id: ObjectId,
134− include_tags: bool,
135− },
136− Unpin {
137− id: ObjectId,
138− fallback: SelectionFallback,
139− },
140131 Quit,
141132 }
142133
@@ -314,30 +305,6 @@ impl Tree { · 171 unmodified lines
314305 self.toggle_count_anchor();
315306 return Input::Handled;
316307 }
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− }
341308 if key.code == KeyCode::Char('G')
342309 || key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::SHIFT)
343310 {
@@ -514,21 +481,8 @@ impl Tree { · 170 unmodified lines
514481 || "Space counts:auto".into(),
515482 |id| format!("Space counts:{}", self.node_label(id)),
516483 );
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();
530484 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"
532486 )
533487 };
534488 frame.render_widget(
@@ -751,8 +705,7 @@ impl Overview { · 216 unmodified lines
751705 let mut labels = HashMap::<CommitIndex, Vec<Decoration>>::new();
752706 let mut heads = HashSet::new();
753707 let mut anchors = HashSet::new();
754− let mut pinned = HashSet::new();
755− let mut pinnable = HashSet::new();
708+ let mut detached_worktrees = HashSet::new();
756709 for (id, decorations) in decorations {
757710 let Some(index) = graph.index(*id) else { continue };
758711 for decoration in decorations {
@@ -762,27 +715,30 @@ impl Overview { · 3 unmodified lines
762715 if decoration.kind == DecorationKind::Head {
763716 heads.insert(index);
764717 anchors.insert(index);
765− pinnable.insert(index);
766718 } else if decoration.kind == DecorationKind::Pin {
767− pinned.insert(index);
768− anchors.insert(index);
719+ continue;
769720 } else if decoration.kind != DecorationKind::Special {
770721 labels.entry(index).or_default().push(decoration.clone());
771722 anchors.insert(index);
772− if decoration.kind != DecorationKind::Stash {
773− pinnable.insert(index);
774− }
775723 }
776724 }
777725 }
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();
778734 let raw: HashSet<_> = refs
779735 .view_tips
780736 .iter()
781737 .chain(&refs.hidden_tips)
738+ .filter(|id| !pin_tips.contains(*id))
782739 .filter_map(|id| graph.index(*id))
783740 .inspect(|index| {
784741 anchors.insert(*index);
785− pinnable.insert(*index);
786742 })
787743 .collect();
788744 if anchors.is_empty() {
@@ -849,8 +805,7 @@ impl Overview { · 60 unmodified lines
849805 decorations,
850806 is_head: heads.contains(&commit),
851807 is_anchor: anchors.contains(&commit),
852− is_pinned: pinned.contains(&commit),
853− can_pin: pinnable.contains(&commit),
808+ is_detached_worktree: detached_worktrees.contains(&commit),
854809 raw_tip: raw.contains(&commit),
855810 sort_key,
856811 }
@@ -1249,7 +1204,7 @@ fn rail_label(node: &Node, count: Option<usize>) -> String { · 392 unmodified lines
12491204 out.push(' ');
12501205 }
12511206 out.push_str(&suffix);
1252− if node.is_pinned {
1207+ if node.is_detached_worktree {
12531208 if !out.is_empty() {
12541209 out.push(' ');
12551210 }
@@ -1262,7 +1217,17 @@ fn node_label(node: &Node) -> String { · 6 unmodified lines
12621217 let labels: Vec<_> = node
12631218 .decorations
12641219 .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+ })
12661231 .collect();
12671232 if !labels.is_empty() {
12681233 labels.join(", ")
@@ -1854,8 +1819,8 @@ mod tests { · 585 unmodified lines
18541819 }
18551820
18561821 #[test]
1857− fn pins_are_markers_and_p_toggles_only_reference_nodes() {
1858− let (graph, refs, mut decorations) = fixture();
1822+ fn detached_worktrees_are_pin_markers_while_ordinary_pins_and_p_are_hidden() {
1823+ let (graph, mut refs, mut decorations) = fixture();
18591824 *decorations.get_mut(&id(5)).expect("topic is decorated") = vec![
18601825 Decoration {
18611826 name: "pin:first".into(),
@@ -1866,46 +1831,41 @@ mod tests { · 4 unmodified lines
18661831 kind: DecorationKind::Pin,
18671832 },
18681833 ];
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;
18691848 let mut tree = Tree::default();
18701849 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− ));
18791850 let overview = tree.overview.as_ref().expect("overview exists");
18801851 assert!(
1881− overview.nodes[topic].decorations.is_empty(),
1882− "pin-only tips need no label"
1852+ !overview.nodes.iter().any(|node| node.id == id(5)),
1853+ "ordinary pin-only tips are absent"
18831854 );
18841855 let unicode = render_overview(overview, true);
18851856 let ascii = render_overview(overview, false);
1886− assert_eq!(unicode.matches('📌').count(), 1, "multiple pins share one marker");
1887− assert!(ascii.contains("[pin]"), "ASCII diagnostics retain the pin marker");
1888− assert!(!unicode.contains("pin:"), "internal pin names are hidden");
1889−
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"
1857+ assert_eq!(unicode.matches('📌').count(), 1, "detached HEAD has one marker");
1858+ assert!(
1859+ unicode.contains("★main"),
1860+ "the remembered branch stays at its actual tip"
18991861 );
1900− let fork =
1901− tree.overview.as_ref().expect("overview exists").by_commit[&graph.index(id(2)).expect("fork exists")];
1902− tree.selected = Some(fork);
1862+ assert!(ascii.contains("[pin]"), "ASCII diagnostics retain detached state");
1863+ assert!(!unicode.contains("pin:"), "ordinary pin names remain hidden");
19031864 assert_eq!(
19041865 tree.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)),
19051866 Input::Handled,
1906− "structural-only nodes cannot be pinned"
1867+ "the tree has no pin action"
19071868 );
1908− assert_eq!(tree.message.as_deref(), Some("no reference at the selected node"));
19091869 }
19101870
19111871 #[test]
gix-tix/src/ui.rs+10−3
@@ -1753,7 +1753,9 @@ fn metadata_line<'a>( · 1752 unmodified lines
17531753 selected
17541754 && matches!(
17551755 decoration.kind,
1756− DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached
1756+ DecorationKind::WorktreeBranch
1757+ | DecorationKind::WorktreeDetached
1758+ | DecorationKind::CurrentWorktreeDetached
17571759 )
17581760 }
17591761 })
@@ -1773,7 +1775,10 @@ fn metadata_line<'a>( · 13 unmodified lines
17731775 format!("{name}@").into()
17741776 } else if decoration.kind == DecorationKind::HeadPinBranch {
17751777 format!("★{name}").into()
1776− } else if decoration.kind == DecorationKind::CurrentWorktreeBranch {
1778+ } else if matches!(
1779+ decoration.kind,
1780+ DecorationKind::CurrentWorktreeBranch | DecorationKind::CurrentWorktreeDetached
1781+ ) {
17771782 format!("@{name}").into()
17781783 } else {
17791784 name
@@ -1951,7 +1956,9 @@ pub(crate) fn decoration_style(kind: DecorationKind) -> Style { · 171 unmodified lines
19511956 DecorationKind::HeadPinBranch => Style::default().fg(Color::Cyan),
19521957 DecorationKind::Stash => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),
19531958 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+ }
19551962 DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached => Style::default().fg(Color::LightBlue),
19561963 DecorationKind::Local => Style::default().fg(Color::Cyan),
19571964 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