git-coco 0.41.0 → 0.41.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
53
53
  /**
54
54
  * Current build version from package.json
55
55
  */
56
- const BUILD_VERSION = "0.41.0";
56
+ const BUILD_VERSION = "0.41.2";
57
57
 
58
58
  const isInteractive = (config) => {
59
59
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -6750,13 +6750,35 @@ async function getCommitLogRangeDetails(from, to, { noMerges, git }) {
6750
6750
  }
6751
6751
 
6752
6752
  /**
6753
- * Retrieves the name of the current branch.
6753
+ * Retrieve the name of the current branch.
6754
6754
  *
6755
- * @param {GetCurrentBranchName} options - The options for retrieving the branch name.
6756
- * @returns {Promise<string>} - A promise that resolves to the name of the current branch.
6755
+ * The first-choice path uses `git rev-parse --abbrev-ref HEAD`, which
6756
+ * returns the active branch on a normal repo. On an initial-commit
6757
+ * repo (fresh `git init` with no commits yet) HEAD does not resolve
6758
+ * and rev-parse fails fatally — but `git symbolic-ref --short HEAD`
6759
+ * still reports the configured initial branch name, so we fall
6760
+ * through to that. Final fallback is an empty string for genuinely
6761
+ * detached / corrupt states; every caller treats that as "no branch
6762
+ * context", which is the right semantics for a no-HEAD repo.
6763
+ *
6764
+ * Without this resilience, every command that depends on the branch
6765
+ * name (e.g. the post-summary step in `coco commit`) would crash
6766
+ * with `fatal: ambiguous argument 'HEAD'` after the entire diff
6767
+ * pipeline already ran (#844).
6757
6768
  */
6758
6769
  async function getCurrentBranchName({ git }) {
6759
- return await git.revparse(['--abbrev-ref', 'HEAD']);
6770
+ try {
6771
+ return await git.revparse(['--abbrev-ref', 'HEAD']);
6772
+ }
6773
+ catch {
6774
+ try {
6775
+ const ref = await git.raw(['symbolic-ref', '--short', 'HEAD']);
6776
+ return ref.trim();
6777
+ }
6778
+ catch {
6779
+ return '';
6780
+ }
6781
+ }
6760
6782
  }
6761
6783
 
6762
6784
  /**
@@ -14951,6 +14973,19 @@ function getLogInkWorkflowActions() {
14951
14973
  kind: 'destructive',
14952
14974
  requiresConfirmation: true,
14953
14975
  },
14976
+ {
14977
+ // Per-view-only — the inkInput handler scopes this to the
14978
+ // worktrees surface so the global `D` keystroke (delete-branch)
14979
+ // keeps working from elsewhere. The empty `key` keeps the
14980
+ // workflow palette-discoverable but does not register a global
14981
+ // hotkey that would collide with delete-branch.
14982
+ id: 'remove-worktree-and-branch',
14983
+ key: '',
14984
+ label: 'Remove worktree + delete branch',
14985
+ description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
14986
+ kind: 'destructive',
14987
+ requiresConfirmation: true,
14988
+ },
14954
14989
  {
14955
14990
  id: 'abort-operation',
14956
14991
  key: 'A',
@@ -18415,6 +18450,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18415
18450
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
18416
18451
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
18417
18452
  }
18453
+ // Per-view worktree action: `D` removes the worktree AND deletes
18454
+ // the branch it was tracking (#838). Scoped to the worktrees
18455
+ // surface so it intercepts BEFORE the global workflow-by-key
18456
+ // dispatcher would otherwise route `D` to delete-branch (which
18457
+ // would silently target whatever was last cursored on the branches
18458
+ // surface instead of acting on the worktree under the cursor here).
18459
+ // `W` keeps its existing "remove worktree only" semantics.
18460
+ if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
18461
+ return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
18462
+ }
18418
18463
  // #783 — full PR action panel keys, scoped to the pull-request view.
18419
18464
  // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
18420
18465
  // comment open prompts first, the rest route through the y-confirm
@@ -21485,6 +21530,56 @@ function worktreePathAction(worktree) {
21485
21530
  message: `Worktree path: ${worktree.path}`,
21486
21531
  };
21487
21532
  }
21533
+ /**
21534
+ * Remove a worktree AND delete the branch it was tracking (#838). The
21535
+ * canonical "I'm done with this side branch" wind-down: removes the
21536
+ * worktree directory, then runs `git branch -d` on the previously
21537
+ * checked-out branch.
21538
+ *
21539
+ * Both pre-flight guards inherit from the underlying helpers:
21540
+ * - removeWorktree refuses the current worktree and dirty worktrees
21541
+ * - deleteBranch refuses the current branch and uses `-d` (safe
21542
+ * delete, refuses unmerged commits)
21543
+ *
21544
+ * Aborts cleanly at any failure point and surfaces a message that
21545
+ * names which step broke. When the worktree had no branch (detached
21546
+ * HEAD) the branch step is silently skipped — there's nothing to
21547
+ * delete and the worktree removal alone counts as success.
21548
+ */
21549
+ async function removeWorktreeAndBranch(git, worktree, branchRefs) {
21550
+ const removeResult = await removeWorktree(git, worktree);
21551
+ if (!removeResult.ok) {
21552
+ return removeResult;
21553
+ }
21554
+ const branchName = worktree.branch;
21555
+ if (!branchName) {
21556
+ return {
21557
+ ok: true,
21558
+ message: `Removed worktree ${worktree.path} (no branch to delete)`,
21559
+ };
21560
+ }
21561
+ // Look up the local BranchRef for the branch this worktree was on.
21562
+ // deleteBranch needs the full ref (not just the name) so its
21563
+ // current-branch and local-only guards apply correctly.
21564
+ const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
21565
+ if (!branch) {
21566
+ return {
21567
+ ok: true,
21568
+ message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
21569
+ };
21570
+ }
21571
+ const deleteResult = await deleteBranch(git, branch);
21572
+ if (!deleteResult.ok) {
21573
+ return {
21574
+ ok: false,
21575
+ message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
21576
+ };
21577
+ }
21578
+ return {
21579
+ ok: true,
21580
+ message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
21581
+ };
21582
+ }
21488
21583
 
21489
21584
  function shortBranch(branch) {
21490
21585
  return branch?.replace(/^refs\/heads\//, '');
@@ -24536,12 +24631,27 @@ function LogInkApp(deps) {
24536
24631
  }, [git, selected?.hash]);
24537
24632
  // #806 follow-up — auto-jump the history view to whichever branch /
24538
24633
  // tag the user is currently cursoring in the sidebar (or the
24539
- // dedicated branches / tags view). Debounced so cursor-scrolling
24540
- // through a long branch list doesn't dispatch on every keystroke.
24634
+ // dedicated branches / tags view).
24635
+ //
24636
+ // Originally this fired on a 150ms trailing-edge debounce. The user
24637
+ // reported the sync feeling inconsistent (#839) — the trailing
24638
+ // pattern means a fast scroll through a long branch list cancels
24639
+ // the timer on every keystroke and only fires once on release; the
24640
+ // user never sees the cursor follow their navigation. Switched to
24641
+ // synchronous fire-on-effect so each cursor move snaps the history
24642
+ // graph immediately. The dispatch is cheap (O(n) findIndex on the
24643
+ // filtered commits + a state spread); React batches the re-renders
24644
+ // so even rapid scroll only paints the final position. Tracks the
24645
+ // last-dispatched hash via a ref so we don't fire setStatus
24646
+ // repeatedly when several adjacent branches all point at the same
24647
+ // commit (very common with squash-merged feature branches that all
24648
+ // converge on `main`'s tip).
24649
+ //
24541
24650
  // No-op when the cursored ref's tip isn't in the loaded commit
24542
24651
  // window (under compact mode the cursored branch's tip may not be
24543
24652
  // fetched yet); a status hint surfaces in that case so the user
24544
24653
  // knows to toggle full graph or load older commits.
24654
+ const lastSyncedHashRef = React.useRef(undefined);
24545
24655
  React.useEffect(() => {
24546
24656
  const onBranchTab = state.activeView === 'branches' ||
24547
24657
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
@@ -24549,58 +24659,58 @@ function LogInkApp(deps) {
24549
24659
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24550
24660
  if (!onBranchTab && !onTagTab)
24551
24661
  return;
24552
- let cancelled = false;
24553
- const timer = setTimeout(() => {
24554
- if (cancelled)
24555
- return;
24556
- let targetHash;
24557
- let targetLabel;
24558
- if (onBranchTab) {
24559
- const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24560
- const visible = state.filter
24561
- ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24562
- : all;
24563
- const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24564
- if (branch) {
24565
- targetHash = branch.hash;
24566
- targetLabel = `branch ${branch.shortName}`;
24567
- }
24568
- }
24569
- else if (onTagTab) {
24570
- const all = sortTags(context.tags?.tags || [], state.tagSort);
24571
- const visible = state.filter
24572
- ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24573
- : all;
24574
- const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24575
- if (tag) {
24576
- targetHash = tag.hash;
24577
- targetLabel = `tag ${tag.name}`;
24578
- }
24579
- }
24580
- if (!targetHash)
24581
- return;
24582
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24583
- if (loaded) {
24584
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
24585
- // Confirmation status message so the user gets feedback even
24586
- // when the dedicated branches / tags view is occupying the
24587
- // main panel and the history cursor moves invisibly behind it.
24588
- dispatch({
24589
- type: 'setStatus',
24590
- value: `Synced history to ${targetLabel} tip`,
24591
- });
24662
+ let targetHash;
24663
+ let targetLabel;
24664
+ if (onBranchTab) {
24665
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24666
+ const visible = state.filter
24667
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24668
+ : all;
24669
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24670
+ if (branch) {
24671
+ targetHash = branch.hash;
24672
+ targetLabel = `branch ${branch.shortName}`;
24592
24673
  }
24593
- else {
24594
- dispatch({
24595
- type: 'setStatus',
24596
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24597
- });
24674
+ }
24675
+ else if (onTagTab) {
24676
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24677
+ const visible = state.filter
24678
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24679
+ : all;
24680
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24681
+ if (tag) {
24682
+ targetHash = tag.hash;
24683
+ targetLabel = `tag ${tag.name}`;
24598
24684
  }
24599
- }, 150);
24600
- return () => {
24601
- cancelled = true;
24602
- clearTimeout(timer);
24603
- };
24685
+ }
24686
+ if (!targetHash)
24687
+ return;
24688
+ // Skip the dispatch + status churn when the cursor hasn't
24689
+ // actually changed which commit it's targeting (the case for
24690
+ // rapid navigation through a cluster of branches that all point
24691
+ // at the same commit). Without this guard the user sees a stream
24692
+ // of "Synced history to <branch> tip" status messages even
24693
+ // though the history cursor never moved.
24694
+ if (targetHash === lastSyncedHashRef.current)
24695
+ return;
24696
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24697
+ if (loaded) {
24698
+ lastSyncedHashRef.current = targetHash;
24699
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24700
+ // Confirmation status message so the user gets feedback even
24701
+ // when the dedicated branches / tags view is occupying the
24702
+ // main panel and the history cursor moves invisibly behind it.
24703
+ dispatch({
24704
+ type: 'setStatus',
24705
+ value: `Synced history to ${targetLabel} tip`,
24706
+ });
24707
+ }
24708
+ else {
24709
+ dispatch({
24710
+ type: 'setStatus',
24711
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24712
+ });
24713
+ }
24604
24714
  }, [
24605
24715
  dispatch, context.branches, context.tags,
24606
24716
  state.activeView, state.focus, state.sidebarTab,
@@ -24608,6 +24718,18 @@ function LogInkApp(deps) {
24608
24718
  state.branchSort, state.tagSort, state.filter,
24609
24719
  state.filteredCommits,
24610
24720
  ]);
24721
+ // Reset the dedup ref when the user moves focus away from the
24722
+ // sidebar branches / tags tab so re-entering re-fires the sync
24723
+ // even if the cursored branch is the same as before.
24724
+ React.useEffect(() => {
24725
+ const onBranchTab = state.activeView === 'branches' ||
24726
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
24727
+ const onTagTab = state.activeView === 'tags' ||
24728
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24729
+ if (!onBranchTab && !onTagTab) {
24730
+ lastSyncedHashRef.current = undefined;
24731
+ }
24732
+ }, [state.activeView, state.focus, state.sidebarTab]);
24611
24733
  React.useEffect(() => {
24612
24734
  let active = true;
24613
24735
  async function loadWorktreeDiff() {
@@ -25047,6 +25169,29 @@ function LogInkApp(deps) {
25047
25169
  }
25048
25170
  return removeWorktree(git, cursorTarget);
25049
25171
  },
25172
+ 'remove-worktree-and-branch': async () => {
25173
+ const all = context.worktreeList?.worktrees || [];
25174
+ const visible = state.filter
25175
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
25176
+ : all;
25177
+ const cursorTarget = visible.length
25178
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
25179
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
25180
+ if (!cursorTarget)
25181
+ return { ok: false, message: 'No worktree selected' };
25182
+ if (cursorTarget.current) {
25183
+ return {
25184
+ ok: false,
25185
+ message: 'Cannot remove the current worktree — switch to another worktree first.',
25186
+ };
25187
+ }
25188
+ // The chained helper handles the worktree removal AND the
25189
+ // safe branch delete in one call. Branch refs come from the
25190
+ // live context so the underlying deleteBranch helper sees
25191
+ // the current/local flags it needs to refuse the destructive
25192
+ // path on the wrong target.
25193
+ return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
25194
+ },
25050
25195
  'abort-operation': async () => {
25051
25196
  const operation = context.operation?.operation;
25052
25197
  if (!operation) {
@@ -27225,7 +27370,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27225
27370
  }, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
27226
27371
  : []), ...(stagedFiles.length
27227
27372
  ? [
27228
- h(Text, { key: 'compose-context-staged-title', bold: true }, 'Staged'),
27373
+ // Section header carries the total count to match the status
27374
+ // surface's "▾ Staged (n)" treatment (#840). The visible
27375
+ // file list is sliced at 12 rows; using `worktree.stagedCount`
27376
+ // (the total) avoids a misleading "Staged (12)" label when
27377
+ // there are actually more staged files below the slice.
27378
+ h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
27229
27379
  ...stagedFiles.map((file, index) => h(Text, {
27230
27380
  key: `compose-context-staged-${index}`,
27231
27381
  color: theme.noColor ? undefined : theme.colors.gitAdded,
@@ -27234,7 +27384,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27234
27384
  ]
27235
27385
  : []), ...(unstagedFiles.length
27236
27386
  ? [
27237
- h(Text, { key: 'compose-context-unstaged-title', bold: true }, 'Unstaged'),
27387
+ h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
27238
27388
  ...unstagedFiles.map((file, index) => h(Text, {
27239
27389
  key: `compose-context-unstaged-${index}`,
27240
27390
  color: theme.noColor ? undefined : theme.colors.gitModified,
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.41.0";
81
+ const BUILD_VERSION = "0.41.2";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -6775,13 +6775,35 @@ async function getCommitLogRangeDetails(from, to, { noMerges, git }) {
6775
6775
  }
6776
6776
 
6777
6777
  /**
6778
- * Retrieves the name of the current branch.
6778
+ * Retrieve the name of the current branch.
6779
6779
  *
6780
- * @param {GetCurrentBranchName} options - The options for retrieving the branch name.
6781
- * @returns {Promise<string>} - A promise that resolves to the name of the current branch.
6780
+ * The first-choice path uses `git rev-parse --abbrev-ref HEAD`, which
6781
+ * returns the active branch on a normal repo. On an initial-commit
6782
+ * repo (fresh `git init` with no commits yet) HEAD does not resolve
6783
+ * and rev-parse fails fatally — but `git symbolic-ref --short HEAD`
6784
+ * still reports the configured initial branch name, so we fall
6785
+ * through to that. Final fallback is an empty string for genuinely
6786
+ * detached / corrupt states; every caller treats that as "no branch
6787
+ * context", which is the right semantics for a no-HEAD repo.
6788
+ *
6789
+ * Without this resilience, every command that depends on the branch
6790
+ * name (e.g. the post-summary step in `coco commit`) would crash
6791
+ * with `fatal: ambiguous argument 'HEAD'` after the entire diff
6792
+ * pipeline already ran (#844).
6782
6793
  */
6783
6794
  async function getCurrentBranchName({ git }) {
6784
- return await git.revparse(['--abbrev-ref', 'HEAD']);
6795
+ try {
6796
+ return await git.revparse(['--abbrev-ref', 'HEAD']);
6797
+ }
6798
+ catch {
6799
+ try {
6800
+ const ref = await git.raw(['symbolic-ref', '--short', 'HEAD']);
6801
+ return ref.trim();
6802
+ }
6803
+ catch {
6804
+ return '';
6805
+ }
6806
+ }
6785
6807
  }
6786
6808
 
6787
6809
  /**
@@ -14976,6 +14998,19 @@ function getLogInkWorkflowActions() {
14976
14998
  kind: 'destructive',
14977
14999
  requiresConfirmation: true,
14978
15000
  },
15001
+ {
15002
+ // Per-view-only — the inkInput handler scopes this to the
15003
+ // worktrees surface so the global `D` keystroke (delete-branch)
15004
+ // keeps working from elsewhere. The empty `key` keeps the
15005
+ // workflow palette-discoverable but does not register a global
15006
+ // hotkey that would collide with delete-branch.
15007
+ id: 'remove-worktree-and-branch',
15008
+ key: '',
15009
+ label: 'Remove worktree + delete branch',
15010
+ description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
15011
+ kind: 'destructive',
15012
+ requiresConfirmation: true,
15013
+ },
14979
15014
  {
14980
15015
  id: 'abort-operation',
14981
15016
  key: 'A',
@@ -18440,6 +18475,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18440
18475
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
18441
18476
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
18442
18477
  }
18478
+ // Per-view worktree action: `D` removes the worktree AND deletes
18479
+ // the branch it was tracking (#838). Scoped to the worktrees
18480
+ // surface so it intercepts BEFORE the global workflow-by-key
18481
+ // dispatcher would otherwise route `D` to delete-branch (which
18482
+ // would silently target whatever was last cursored on the branches
18483
+ // surface instead of acting on the worktree under the cursor here).
18484
+ // `W` keeps its existing "remove worktree only" semantics.
18485
+ if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
18486
+ return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
18487
+ }
18443
18488
  // #783 — full PR action panel keys, scoped to the pull-request view.
18444
18489
  // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
18445
18490
  // comment open prompts first, the rest route through the y-confirm
@@ -21510,6 +21555,56 @@ function worktreePathAction(worktree) {
21510
21555
  message: `Worktree path: ${worktree.path}`,
21511
21556
  };
21512
21557
  }
21558
+ /**
21559
+ * Remove a worktree AND delete the branch it was tracking (#838). The
21560
+ * canonical "I'm done with this side branch" wind-down: removes the
21561
+ * worktree directory, then runs `git branch -d` on the previously
21562
+ * checked-out branch.
21563
+ *
21564
+ * Both pre-flight guards inherit from the underlying helpers:
21565
+ * - removeWorktree refuses the current worktree and dirty worktrees
21566
+ * - deleteBranch refuses the current branch and uses `-d` (safe
21567
+ * delete, refuses unmerged commits)
21568
+ *
21569
+ * Aborts cleanly at any failure point and surfaces a message that
21570
+ * names which step broke. When the worktree had no branch (detached
21571
+ * HEAD) the branch step is silently skipped — there's nothing to
21572
+ * delete and the worktree removal alone counts as success.
21573
+ */
21574
+ async function removeWorktreeAndBranch(git, worktree, branchRefs) {
21575
+ const removeResult = await removeWorktree(git, worktree);
21576
+ if (!removeResult.ok) {
21577
+ return removeResult;
21578
+ }
21579
+ const branchName = worktree.branch;
21580
+ if (!branchName) {
21581
+ return {
21582
+ ok: true,
21583
+ message: `Removed worktree ${worktree.path} (no branch to delete)`,
21584
+ };
21585
+ }
21586
+ // Look up the local BranchRef for the branch this worktree was on.
21587
+ // deleteBranch needs the full ref (not just the name) so its
21588
+ // current-branch and local-only guards apply correctly.
21589
+ const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
21590
+ if (!branch) {
21591
+ return {
21592
+ ok: true,
21593
+ message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
21594
+ };
21595
+ }
21596
+ const deleteResult = await deleteBranch(git, branch);
21597
+ if (!deleteResult.ok) {
21598
+ return {
21599
+ ok: false,
21600
+ message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
21601
+ };
21602
+ }
21603
+ return {
21604
+ ok: true,
21605
+ message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
21606
+ };
21607
+ }
21513
21608
 
21514
21609
  function shortBranch(branch) {
21515
21610
  return branch?.replace(/^refs\/heads\//, '');
@@ -24561,12 +24656,27 @@ function LogInkApp(deps) {
24561
24656
  }, [git, selected?.hash]);
24562
24657
  // #806 follow-up — auto-jump the history view to whichever branch /
24563
24658
  // tag the user is currently cursoring in the sidebar (or the
24564
- // dedicated branches / tags view). Debounced so cursor-scrolling
24565
- // through a long branch list doesn't dispatch on every keystroke.
24659
+ // dedicated branches / tags view).
24660
+ //
24661
+ // Originally this fired on a 150ms trailing-edge debounce. The user
24662
+ // reported the sync feeling inconsistent (#839) — the trailing
24663
+ // pattern means a fast scroll through a long branch list cancels
24664
+ // the timer on every keystroke and only fires once on release; the
24665
+ // user never sees the cursor follow their navigation. Switched to
24666
+ // synchronous fire-on-effect so each cursor move snaps the history
24667
+ // graph immediately. The dispatch is cheap (O(n) findIndex on the
24668
+ // filtered commits + a state spread); React batches the re-renders
24669
+ // so even rapid scroll only paints the final position. Tracks the
24670
+ // last-dispatched hash via a ref so we don't fire setStatus
24671
+ // repeatedly when several adjacent branches all point at the same
24672
+ // commit (very common with squash-merged feature branches that all
24673
+ // converge on `main`'s tip).
24674
+ //
24566
24675
  // No-op when the cursored ref's tip isn't in the loaded commit
24567
24676
  // window (under compact mode the cursored branch's tip may not be
24568
24677
  // fetched yet); a status hint surfaces in that case so the user
24569
24678
  // knows to toggle full graph or load older commits.
24679
+ const lastSyncedHashRef = React.useRef(undefined);
24570
24680
  React.useEffect(() => {
24571
24681
  const onBranchTab = state.activeView === 'branches' ||
24572
24682
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
@@ -24574,58 +24684,58 @@ function LogInkApp(deps) {
24574
24684
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24575
24685
  if (!onBranchTab && !onTagTab)
24576
24686
  return;
24577
- let cancelled = false;
24578
- const timer = setTimeout(() => {
24579
- if (cancelled)
24580
- return;
24581
- let targetHash;
24582
- let targetLabel;
24583
- if (onBranchTab) {
24584
- const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24585
- const visible = state.filter
24586
- ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24587
- : all;
24588
- const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24589
- if (branch) {
24590
- targetHash = branch.hash;
24591
- targetLabel = `branch ${branch.shortName}`;
24592
- }
24593
- }
24594
- else if (onTagTab) {
24595
- const all = sortTags(context.tags?.tags || [], state.tagSort);
24596
- const visible = state.filter
24597
- ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24598
- : all;
24599
- const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24600
- if (tag) {
24601
- targetHash = tag.hash;
24602
- targetLabel = `tag ${tag.name}`;
24603
- }
24604
- }
24605
- if (!targetHash)
24606
- return;
24607
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24608
- if (loaded) {
24609
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
24610
- // Confirmation status message so the user gets feedback even
24611
- // when the dedicated branches / tags view is occupying the
24612
- // main panel and the history cursor moves invisibly behind it.
24613
- dispatch({
24614
- type: 'setStatus',
24615
- value: `Synced history to ${targetLabel} tip`,
24616
- });
24687
+ let targetHash;
24688
+ let targetLabel;
24689
+ if (onBranchTab) {
24690
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24691
+ const visible = state.filter
24692
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24693
+ : all;
24694
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24695
+ if (branch) {
24696
+ targetHash = branch.hash;
24697
+ targetLabel = `branch ${branch.shortName}`;
24617
24698
  }
24618
- else {
24619
- dispatch({
24620
- type: 'setStatus',
24621
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24622
- });
24699
+ }
24700
+ else if (onTagTab) {
24701
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24702
+ const visible = state.filter
24703
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24704
+ : all;
24705
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24706
+ if (tag) {
24707
+ targetHash = tag.hash;
24708
+ targetLabel = `tag ${tag.name}`;
24623
24709
  }
24624
- }, 150);
24625
- return () => {
24626
- cancelled = true;
24627
- clearTimeout(timer);
24628
- };
24710
+ }
24711
+ if (!targetHash)
24712
+ return;
24713
+ // Skip the dispatch + status churn when the cursor hasn't
24714
+ // actually changed which commit it's targeting (the case for
24715
+ // rapid navigation through a cluster of branches that all point
24716
+ // at the same commit). Without this guard the user sees a stream
24717
+ // of "Synced history to <branch> tip" status messages even
24718
+ // though the history cursor never moved.
24719
+ if (targetHash === lastSyncedHashRef.current)
24720
+ return;
24721
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24722
+ if (loaded) {
24723
+ lastSyncedHashRef.current = targetHash;
24724
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24725
+ // Confirmation status message so the user gets feedback even
24726
+ // when the dedicated branches / tags view is occupying the
24727
+ // main panel and the history cursor moves invisibly behind it.
24728
+ dispatch({
24729
+ type: 'setStatus',
24730
+ value: `Synced history to ${targetLabel} tip`,
24731
+ });
24732
+ }
24733
+ else {
24734
+ dispatch({
24735
+ type: 'setStatus',
24736
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24737
+ });
24738
+ }
24629
24739
  }, [
24630
24740
  dispatch, context.branches, context.tags,
24631
24741
  state.activeView, state.focus, state.sidebarTab,
@@ -24633,6 +24743,18 @@ function LogInkApp(deps) {
24633
24743
  state.branchSort, state.tagSort, state.filter,
24634
24744
  state.filteredCommits,
24635
24745
  ]);
24746
+ // Reset the dedup ref when the user moves focus away from the
24747
+ // sidebar branches / tags tab so re-entering re-fires the sync
24748
+ // even if the cursored branch is the same as before.
24749
+ React.useEffect(() => {
24750
+ const onBranchTab = state.activeView === 'branches' ||
24751
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
24752
+ const onTagTab = state.activeView === 'tags' ||
24753
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24754
+ if (!onBranchTab && !onTagTab) {
24755
+ lastSyncedHashRef.current = undefined;
24756
+ }
24757
+ }, [state.activeView, state.focus, state.sidebarTab]);
24636
24758
  React.useEffect(() => {
24637
24759
  let active = true;
24638
24760
  async function loadWorktreeDiff() {
@@ -25072,6 +25194,29 @@ function LogInkApp(deps) {
25072
25194
  }
25073
25195
  return removeWorktree(git, cursorTarget);
25074
25196
  },
25197
+ 'remove-worktree-and-branch': async () => {
25198
+ const all = context.worktreeList?.worktrees || [];
25199
+ const visible = state.filter
25200
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
25201
+ : all;
25202
+ const cursorTarget = visible.length
25203
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
25204
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
25205
+ if (!cursorTarget)
25206
+ return { ok: false, message: 'No worktree selected' };
25207
+ if (cursorTarget.current) {
25208
+ return {
25209
+ ok: false,
25210
+ message: 'Cannot remove the current worktree — switch to another worktree first.',
25211
+ };
25212
+ }
25213
+ // The chained helper handles the worktree removal AND the
25214
+ // safe branch delete in one call. Branch refs come from the
25215
+ // live context so the underlying deleteBranch helper sees
25216
+ // the current/local flags it needs to refuse the destructive
25217
+ // path on the wrong target.
25218
+ return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
25219
+ },
25075
25220
  'abort-operation': async () => {
25076
25221
  const operation = context.operation?.operation;
25077
25222
  if (!operation) {
@@ -27250,7 +27395,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27250
27395
  }, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
27251
27396
  : []), ...(stagedFiles.length
27252
27397
  ? [
27253
- h(Text, { key: 'compose-context-staged-title', bold: true }, 'Staged'),
27398
+ // Section header carries the total count to match the status
27399
+ // surface's "▾ Staged (n)" treatment (#840). The visible
27400
+ // file list is sliced at 12 rows; using `worktree.stagedCount`
27401
+ // (the total) avoids a misleading "Staged (12)" label when
27402
+ // there are actually more staged files below the slice.
27403
+ h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
27254
27404
  ...stagedFiles.map((file, index) => h(Text, {
27255
27405
  key: `compose-context-staged-${index}`,
27256
27406
  color: theme.noColor ? undefined : theme.colors.gitAdded,
@@ -27259,7 +27409,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27259
27409
  ]
27260
27410
  : []), ...(unstagedFiles.length
27261
27411
  ? [
27262
- h(Text, { key: 'compose-context-unstaged-title', bold: true }, 'Unstaged'),
27412
+ h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
27263
27413
  ...unstagedFiles.map((file, index) => h(Text, {
27264
27414
  key: `compose-context-unstaged-${index}`,
27265
27415
  color: theme.noColor ? undefined : theme.colors.gitModified,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.41.0",
3
+ "version": "0.41.2",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",