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.
Unified · Split
| @@ -136,11 +136,12 @@ without trading responsiveness for metadata that is not visible. · 135 unmodified lines | ||
| 136 | 136 | visible. If it has visible descendants, its unselected non-whitespace content |
| 137 | 137 | is underlined and `@` is bold; a selected row keeps only bold `@`. |
| 138 | 138 | - 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. | |
| 144 | 145 | - When reference labels are hidden, worktree labels are visible only on the |
| 145 | 146 | selected row. Stale, malformed, unborn, and otherwise unreadable worktree |
| 146 | 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 | 229 | map uniquely through a named remote's fetch refspecs; it deletes every resolved |
| 229 | 230 | remote reference, grouped into one Git push per remote. Pushes continue after |
| 230 | 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 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. | |
| 238 | 240 | |
| 239 | 241 | ### Tree diagnostics |
| 240 | 242 | |
| 241 | 243 | - `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 | |
| 244 | 246 | all normal references by default; positional revisions scope traversal. |
| 245 | 247 | - `--no-tags`, `--worktrees`, and repeatable `--hide <revision>` match the |
| 246 | 248 | corresponding tree inputs. Output uses ASCII lines and `o` nodes by default; |
| @@ -653,42 +653,6 @@ pub(crate) fn create_or_reuse_pin( · 652 unmodified lines | ||
| 653 | 653 | Ok((history::Pin { name, target, id }, true)) |
| 654 | 654 | } |
| 655 | 655 | |
| 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 | − | |
| 692 | 656 | pub(super) fn delete_pin(repository: &gix::Repository, pin: &history::Pin) -> Result<()> { |
| 693 | 657 | repository |
| 694 | 658 | .edit_references([delete_pin_edit(pin)]) |
| @@ -1320,44 +1284,6 @@ mod tests { · 625 unmodified lines | ||
| 1320 | 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 | 1287 | #[test] |
| 1362 | 1288 | fn sideways_travel_preserves_an_unreferenced_departure() -> gix_testtools::Result { |
| 1363 | 1289 | let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; |
| @@ -34,6 +34,7 @@ pub(crate) enum DecorationKind { · 33 unmodified lines | ||
| 34 | 34 | Stash, |
| 35 | 35 | Review, |
| 36 | 36 | CurrentWorktreeBranch, |
| 37 | + CurrentWorktreeDetached, | |
| 37 | 38 | WorktreeBranch, |
| 38 | 39 | WorktreeDetached, |
| 39 | 40 | Local, |
| @@ -67,9 +68,11 @@ impl Pin { · 27 unmodified lines | ||
| 67 | 68 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 68 | 69 | pub(crate) struct WorktreeCheckout { |
| 69 | 70 | pub id: ObjectId, |
| 71 | + pub label_id: ObjectId, | |
| 70 | 72 | pub name: BString, |
| 71 | 73 | pub reference: Option<gix::refs::FullName>, |
| 72 | 74 | pub is_current: bool, |
| 75 | + pub is_detached: bool, | |
| 73 | 76 | } |
| 74 | 77 | |
| 75 | 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 | 1217 | let name = reference.name().as_bstr().to_owned(); |
| 1215 | 1218 | let kind = decoration_kind(&name); |
| 1216 | − if kind == DecorationKind::Special | |
| 1219 | + if matches!(kind, DecorationKind::Special | DecorationKind::Pin) | |
| 1217 | 1220 | || matches!(kind, DecorationKind::Tag) && !include_tags |
| 1218 | 1221 | || name.starts_with(STASH_PREFIX) |
| 1219 | 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 | 1303 | out.sort_by(|a, b| { |
| 1301 | 1304 | a.id.cmp(&b.id) |
| 1305 | + .then_with(|| a.label_id.cmp(&b.label_id)) | |
| 1302 | 1306 | .then_with(|| a.name.cmp(&b.name)) |
| 1303 | 1307 | .then_with(|| a.reference.cmp(&b.reference)) |
| 1304 | 1308 | }); |
| @@ -1320,7 +1324,8 @@ fn add_worktree_checkout( · 15 unmodified lines | ||
| 1320 | 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 | 1329 | let id = match head.try_peel_to_id() { |
| 1325 | 1330 | Ok(Some(id)) => id.detach(), |
| 1326 | 1331 | Ok(None) => return, |
| @@ -1329,6 +1334,12 @@ fn add_worktree_checkout( · 2 unmodified lines | ||
| 1329 | 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 | 1343 | let name = match &reference { |
| 1333 | 1344 | Some(reference) => reference.shorten().to_owned(), |
| 1334 | 1345 | None => match detached_name { |
| @@ -1341,12 +1352,51 @@ fn add_worktree_checkout( · 6 unmodified lines | ||
| 1341 | 1352 | }; |
| 1342 | 1353 | out.push(WorktreeCheckout { |
| 1343 | 1354 | id, |
| 1355 | + label_id, | |
| 1344 | 1356 | name, |
| 1345 | 1357 | reference, |
| 1346 | 1358 | is_current, |
| 1359 | + is_detached, | |
| 1347 | 1360 | }); |
| 1348 | 1361 | } |
| 1349 | 1362 | |
| 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 | + | |
| 1350 | 1400 | fn worktree_basename(path: &std::path::Path) -> Option<BString> { |
| 1351 | 1401 | path.file_name() |
| 1352 | 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 | 1792 | continue; |
| 1743 | 1793 | }; |
| 1744 | 1794 | 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 | + }) { | |
| 1749 | 1798 | kind = DecorationKind::CurrentWorktreeBranch; |
| 1750 | 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 | 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 | 1825 | out.entry(id).or_default().push(Decoration { name, kind }); |
| 1777 | 1826 | } |
| 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, | |
| 1783 | 1833 | }; |
| 1784 | − let decorations = out.entry(worktree.id).or_default(); | |
| 1834 | + let decorations = out.entry(worktree.label_id).or_default(); | |
| 1785 | 1835 | if !decorations |
| 1786 | 1836 | .iter() |
| 1787 | 1837 | .any(|decoration| decoration.kind == kind && decoration.name == worktree.name) |
| @@ -2094,8 +2144,10 @@ mod tests { · 306 unmodified lines | ||
| 2094 | 2144 | let worktrees = worktree_checkouts(&repo); |
| 2095 | 2145 | assert!(worktrees.iter().any(|worktree| { |
| 2096 | 2146 | worktree.id == main |
| 2147 | + && worktree.label_id == main | |
| 2097 | 2148 | && worktree.name == "main" |
| 2098 | 2149 | && worktree.is_current |
| 2150 | + && !worktree.is_detached | |
| 2099 | 2151 | && worktree |
| 2100 | 2152 | .reference |
| 2101 | 2153 | .as_ref() |
| @@ -2103,8 +2155,10 @@ mod tests { · 1 unmodified line | ||
| 2103 | 2155 | })); |
| 2104 | 2156 | assert!(worktrees.iter().any(|worktree| { |
| 2105 | 2157 | worktree.id == topic |
| 2158 | + && worktree.label_id == topic | |
| 2106 | 2159 | && worktree.name == "topic" |
| 2107 | 2160 | && !worktree.is_current |
| 2161 | + && !worktree.is_detached | |
| 2108 | 2162 | && worktree |
| 2109 | 2163 | .reference |
| 2110 | 2164 | .as_ref() |
| @@ -2112,9 +2166,11 @@ mod tests { · 1 unmodified line | ||
| 2112 | 2166 | })); |
| 2113 | 2167 | assert!(worktrees.iter().any(|worktree| { |
| 2114 | 2168 | worktree.id == root |
| 2169 | + && worktree.label_id == root | |
| 2115 | 2170 | && worktree.name == "detached-wt" |
| 2116 | 2171 | && worktree.reference.is_none() |
| 2117 | 2172 | && !worktree.is_current |
| 2173 | + && worktree.is_detached | |
| 2118 | 2174 | })); |
| 2119 | 2175 | assert_eq!(worktrees.len(), 3, "the malformed worktree is ignored"); |
| 2120 | 2176 | |
| @@ -2181,14 +2237,43 @@ mod tests { · 60 unmodified lines | ||
| 2181 | 2237 | .find(|worktree| worktree.is_current) |
| 2182 | 2238 | .expect("the current detached worktree is discovered"); |
| 2183 | 2239 | assert!(current.reference.is_none()); |
| 2240 | + assert!(current.is_detached); | |
| 2241 | + assert_eq!(current.label_id, current.id); | |
| 2184 | 2242 | let detached_decorations = decorations(&detached_repo, &[], &detached_worktrees)?; |
| 2243 | + assert!(detached_decorations.get(¤t.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 | 2264 | assert!( |
| 2186 | − !detached_decorations | |
| 2187 | − .get(¤t.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") | |
| 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 | 2277 | Ok(()) |
| 2193 | 2278 | } |
| 2194 | 2279 | |
| @@ -2531,8 +2616,14 @@ mod tests { · 336 unmodified lines | ||
| 2531 | 2616 | |
| 2532 | 2617 | #[test] |
| 2533 | 2618 | 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 | + )?; | |
| 2536 | 2627 | let names = |include_tags| { |
| 2537 | 2628 | tree_revisions(&repo, include_tags).map(|revisions| { |
| 2538 | 2629 | revisions |
| @@ -2556,6 +2647,12 @@ mod tests { · 17 unmodified lines | ||
| 2556 | 2647 | without_tags.iter().all(|name| !name.starts_with("refs/tags/")), |
| 2557 | 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 | 2656 | Ok(()) |
| 2560 | 2657 | } |
| 2561 | 2658 | |
| @@ -1571,40 +1571,6 @@ fn event_loop( · 1570 unmodified lines | ||
| 1571 | 1571 | urgent = true; |
| 1572 | 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 | 1574 | tree::Input::Quit => return Ok(None), |
| 1609 | 1575 | }, |
| 1610 | 1576 | TerminalEvent::Mouse(mouse) if tree.handle_mouse(mouse.kind, 1) => { |
| @@ -46,8 +46,7 @@ struct Node { · 45 unmodified lines | ||
| 46 | 46 | decorations: Vec<Decoration>, |
| 47 | 47 | is_head: bool, |
| 48 | 48 | is_anchor: bool, |
| 49 | − is_pinned: bool, | |
| 50 | − can_pin: bool, | |
| 49 | + is_detached_worktree: bool, | |
| 51 | 50 | raw_tip: bool, |
| 52 | 51 | sort_key: String, |
| 53 | 52 | } |
| @@ -129,14 +128,6 @@ pub(crate) enum Input { · 75 unmodified lines | ||
| 129 | 128 | groups: Vec<RemoteDeletion>, |
| 130 | 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 | 131 | Quit, |
| 141 | 132 | } |
| 142 | 133 | |
| @@ -314,30 +305,6 @@ impl Tree { · 171 unmodified lines | ||
| 314 | 305 | self.toggle_count_anchor(); |
| 315 | 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 | 308 | if key.code == KeyCode::Char('G') |
| 342 | 309 | || key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::SHIFT) |
| 343 | 310 | { |
| @@ -514,21 +481,8 @@ impl Tree { · 170 unmodified lines | ||
| 514 | 481 | || "Space counts:auto".into(), |
| 515 | 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 | 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 | 488 | frame.render_widget( |
| @@ -751,8 +705,7 @@ impl Overview { · 216 unmodified lines | ||
| 751 | 705 | let mut labels = HashMap::<CommitIndex, Vec<Decoration>>::new(); |
| 752 | 706 | let mut heads = HashSet::new(); |
| 753 | 707 | let mut anchors = HashSet::new(); |
| 754 | − let mut pinned = HashSet::new(); | |
| 755 | − let mut pinnable = HashSet::new(); | |
| 708 | + let mut detached_worktrees = HashSet::new(); | |
| 756 | 709 | for (id, decorations) in decorations { |
| 757 | 710 | let Some(index) = graph.index(*id) else { continue }; |
| 758 | 711 | for decoration in decorations { |
| @@ -762,27 +715,30 @@ impl Overview { · 3 unmodified lines | ||
| 762 | 715 | if decoration.kind == DecorationKind::Head { |
| 763 | 716 | heads.insert(index); |
| 764 | 717 | anchors.insert(index); |
| 765 | − pinnable.insert(index); | |
| 766 | 718 | } else if decoration.kind == DecorationKind::Pin { |
| 767 | − pinned.insert(index); | |
| 768 | − anchors.insert(index); | |
| 719 | + continue; | |
| 769 | 720 | } else if decoration.kind != DecorationKind::Special { |
| 770 | 721 | labels.entry(index).or_default().push(decoration.clone()); |
| 771 | 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 | 734 | let raw: HashSet<_> = refs |
| 779 | 735 | .view_tips |
| 780 | 736 | .iter() |
| 781 | 737 | .chain(&refs.hidden_tips) |
| 738 | + .filter(|id| !pin_tips.contains(*id)) | |
| 782 | 739 | .filter_map(|id| graph.index(*id)) |
| 783 | 740 | .inspect(|index| { |
| 784 | 741 | anchors.insert(*index); |
| 785 | − pinnable.insert(*index); | |
| 786 | 742 | }) |
| 787 | 743 | .collect(); |
| 788 | 744 | if anchors.is_empty() { |
| @@ -849,8 +805,7 @@ impl Overview { · 60 unmodified lines | ||
| 849 | 805 | decorations, |
| 850 | 806 | is_head: heads.contains(&commit), |
| 851 | 807 | 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), | |
| 854 | 809 | raw_tip: raw.contains(&commit), |
| 855 | 810 | sort_key, |
| 856 | 811 | } |
| @@ -1249,7 +1204,7 @@ fn rail_label(node: &Node, count: Option<usize>) -> String { · 392 unmodified lines | ||
| 1249 | 1204 | out.push(' '); |
| 1250 | 1205 | } |
| 1251 | 1206 | out.push_str(&suffix); |
| 1252 | − if node.is_pinned { | |
| 1207 | + if node.is_detached_worktree { | |
| 1253 | 1208 | if !out.is_empty() { |
| 1254 | 1209 | out.push(' '); |
| 1255 | 1210 | } |
| @@ -1262,7 +1217,17 @@ fn node_label(node: &Node) -> String { · 6 unmodified lines | ||
| 1262 | 1217 | let labels: Vec<_> = node |
| 1263 | 1218 | .decorations |
| 1264 | 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 | 1231 | .collect(); |
| 1267 | 1232 | if !labels.is_empty() { |
| 1268 | 1233 | labels.join(", ") |
| @@ -1854,8 +1819,8 @@ mod tests { · 585 unmodified lines | ||
| 1854 | 1819 | } |
| 1855 | 1820 | |
| 1856 | 1821 | #[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(); | |
| 1859 | 1824 | *decorations.get_mut(&id(5)).expect("topic is decorated") = vec![ |
| 1860 | 1825 | Decoration { |
| 1861 | 1826 | name: "pin:first".into(), |
| @@ -1866,46 +1831,41 @@ mod tests { · 4 unmodified lines | ||
| 1866 | 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 | 1848 | let mut tree = Tree::default(); |
| 1870 | 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 | 1850 | let overview = tree.overview.as_ref().expect("overview exists"); |
| 1880 | 1851 | 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" | |
| 1883 | 1854 | ); |
| 1884 | 1855 | let unicode = render_overview(overview, true); |
| 1885 | 1856 | 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" | |
| 1899 | 1861 | ); |
| 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"); | |
| 1903 | 1864 | assert_eq!( |
| 1904 | 1865 | tree.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)), |
| 1905 | 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 | 1871 | #[test] |
| @@ -1753,7 +1753,9 @@ fn metadata_line<'a>( · 1752 unmodified lines | ||
| 1753 | 1753 | selected |
| 1754 | 1754 | && matches!( |
| 1755 | 1755 | decoration.kind, |
| 1756 | − DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached | |
| 1756 | + 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 | 1775 | format!("{name}@").into() |
| 1774 | 1776 | } else if decoration.kind == DecorationKind::HeadPinBranch { |
| 1775 | 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 | 1782 | format!("@{name}").into() |
| 1778 | 1783 | } else { |
| 1779 | 1784 | name |
| @@ -1951,7 +1956,9 @@ pub(crate) fn decoration_style(kind: DecorationKind) -> Style { · 171 unmodified lines | ||
| 1951 | 1956 | DecorationKind::HeadPinBranch => Style::default().fg(Color::Cyan), |
| 1952 | 1957 | DecorationKind::Stash => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD), |
| 1953 | 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 | 1962 | DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached => Style::default().fg(Color::LightBlue), |
| 1956 | 1963 | DecorationKind::Local => Style::default().fg(Color::Cyan), |
| 1957 | 1964 | DecorationKind::Remote => Style::default().fg(Color::Yellow), |