git-coco 0.62.2 → 0.62.4

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.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.62.2";
64
+ const BUILD_VERSION = "0.62.4";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -26368,6 +26368,8 @@ function applyLogInkAction(state, action) {
26368
26368
  pendingMutationConfirmation: action.value ? undefined : state.pendingMutationConfirmation,
26369
26369
  pendingKey: undefined,
26370
26370
  };
26371
+ case 'setWorktreeCheckoutConflict':
26372
+ return { ...state, worktreeCheckoutConflict: action.value, pendingKey: undefined };
26371
26373
  case 'setPendingMutationConfirmation':
26372
26374
  return {
26373
26375
  ...state,
@@ -27740,7 +27742,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27740
27742
  return [];
27741
27743
  }
27742
27744
  if (state.pendingConfirmationId) {
27745
+ // Worktree-conflict removal options (#1175): alongside the y-switch,
27746
+ // `r` removes the conflicting worktree and checks the branch out
27747
+ // here, `x` removes the worktree AND deletes the branch. Both defer
27748
+ // to the runtime (it owns the git ops + the conflict context); the
27749
+ // runtime clears the conflict state once it resolves.
27750
+ if (state.pendingConfirmationId === 'switch-to-conflicting-worktree' && state.worktreeCheckoutConflict) {
27751
+ if (inputValue === 'r') {
27752
+ return [
27753
+ { type: 'runWorkflowAction', id: 'conflict-remove-worktree-checkout' },
27754
+ action({ type: 'setPendingConfirmation', value: undefined }),
27755
+ ];
27756
+ }
27757
+ if (inputValue === 'x') {
27758
+ return [
27759
+ { type: 'runWorkflowAction', id: 'conflict-remove-worktree-branch' },
27760
+ action({ type: 'setPendingConfirmation', value: undefined }),
27761
+ ];
27762
+ }
27763
+ }
27743
27764
  if (inputValue === 'y') {
27765
+ // Worktree-conflict switch (#1175): the branch is already checked
27766
+ // out elsewhere, so "switch" just opens that worktree as a nested
27767
+ // repo frame (same mechanism as drilling into a submodule) — no
27768
+ // git mutation, hence handled here rather than via the runtime.
27769
+ if (state.pendingConfirmationId === 'switch-to-conflicting-worktree') {
27770
+ const conflict = state.worktreeCheckoutConflict;
27771
+ if (conflict) {
27772
+ return [
27773
+ action({ type: 'pushRepoFrame', label: conflict.branch, workdir: conflict.worktreePath }),
27774
+ action({ type: 'setStatus', value: `Switched to worktree ${conflict.worktreePath} (${conflict.branch})` }),
27775
+ action({ type: 'setPendingConfirmation', value: undefined }),
27776
+ action({ type: 'setWorktreeCheckoutConflict', value: undefined }),
27777
+ ];
27778
+ }
27779
+ return [
27780
+ action({ type: 'setPendingConfirmation', value: undefined }),
27781
+ action({ type: 'setWorktreeCheckoutConflict', value: undefined }),
27782
+ ];
27783
+ }
27744
27784
  const workflowAction = getLogInkWorkflowActionById(state.pendingConfirmationId);
27745
27785
  if (workflowAction?.id === 'ai-commit-summary') {
27746
27786
  return [
@@ -27766,6 +27806,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27766
27806
  if (inputValue === 'n' || key.escape) {
27767
27807
  return [
27768
27808
  action({ type: 'setPendingConfirmation', value: undefined }),
27809
+ // Drop any worktree-conflict context so the prompt doesn't
27810
+ // linger after the user declines to switch.
27811
+ ...(state.worktreeCheckoutConflict
27812
+ ? [action({ type: 'setWorktreeCheckoutConflict', value: undefined })]
27813
+ : []),
27769
27814
  action({ type: 'setStatus', value: 'workflow action cancelled' }),
27770
27815
  ];
27771
27816
  }
@@ -34351,7 +34396,13 @@ function sidebarTabLabel(tab) {
34351
34396
  * Sliding window keeps the cursor in view as the user navigates a long
34352
34397
  * list; truncation hints surface the count of hidden rows.
34353
34398
  */
34354
- function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
34399
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount,
34400
+ // Optional per-row foreground colour (e.g. the current branch in
34401
+ // green). Applied only when the row is NOT the active selection —
34402
+ // the selection's inverse/background styling owns the row's colour in
34403
+ // that case, and layering a foreground under `inverse` would swap it
34404
+ // into an unexpected background tint.
34405
+ rowColor) {
34355
34406
  if (items.length === 0)
34356
34407
  return [];
34357
34408
  const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
@@ -34368,10 +34419,12 @@ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, wid
34368
34419
  break;
34369
34420
  const isSelected = focused && index === selectedIndex;
34370
34421
  const text = toRowText(items[index], index);
34422
+ const color = isSelected ? undefined : rowColor?.(items[index], index);
34371
34423
  elements.push(h(Text, {
34372
34424
  key: `${keyPrefix}-row-${index}`,
34373
34425
  backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
34374
34426
  inverse: isSelected,
34427
+ color,
34375
34428
  }, truncateCells(` ${text}`, width - 4)));
34376
34429
  }
34377
34430
  if (window.truncatedBelow > 0) {
@@ -34481,7 +34534,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34481
34534
  ? spin
34482
34535
  : branchRowMarker(branch, { ascii: theme.ascii }).glyph;
34483
34536
  return `${glyph} ${branch.shortName}`;
34484
- }, 'tab-branches', visibleListCount),
34537
+ }, 'tab-branches', visibleListCount,
34538
+ // Paint the checked-out branch green so "where am I?" reads at a
34539
+ // glance, matching the green HEAD marker the branches surface
34540
+ // already uses. NO_COLOR themes fall back to the `*` glyph alone.
34541
+ (branch) => (branch.current && !theme.noColor ? theme.colors.success : undefined)),
34485
34542
  ];
34486
34543
  }
34487
34544
  if (tab === 'tags') {
@@ -34982,11 +35039,16 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34982
35039
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
34983
35040
  // If truncation chopped into the timestamp/divergence portion,
34984
35041
  // fall back to a single Text to keep the visible width honest.
35042
+ // The checked-out branch is painted green (matching its green
35043
+ // HEAD marker) so "where am I?" reads at a glance. NO_COLOR
35044
+ // themes fall back to the `*` glyph alone.
35045
+ const currentColor = branch.current && !theme.noColor ? theme.colors.success : undefined;
34985
35046
  if (truncated !== fullText) {
34986
35047
  return h(Text, {
34987
35048
  key: `branch-${index}`,
34988
35049
  bold: isSelected,
34989
35050
  dimColor: lineDim,
35051
+ color: currentColor,
34990
35052
  }, truncated);
34991
35053
  }
34992
35054
  return h(Text, {
@@ -35001,7 +35063,12 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
35001
35063
  // no-upstream kinds return undefined from
35002
35064
  // `getBranchRowMarkerColor`, so those markers inherit the
35003
35065
  // row's dim and read as quiet chrome.
35004
- h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
35066
+ h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph),
35067
+ // Name span: green for the current branch (dimColor:false keeps
35068
+ // it bright), otherwise it inherits the row's normal styling.
35069
+ currentColor
35070
+ ? h(Text, { color: currentColor, dimColor: false }, trailingName)
35071
+ : trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
35005
35072
  });
35006
35073
  // Scroll indicators — same "N more above/below" pattern as the
35007
35074
  // sidebar and help overlay so the user knows the list continues.
@@ -37618,25 +37685,36 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
37618
37685
  : state.pendingMutationConfirmation === 'discard-draft'
37619
37686
  ? 'Quit and discard the in-progress commit draft'
37620
37687
  : undefined;
37621
- const label = action?.label || mutationLabel || 'Workflow action';
37688
+ // Worktree-conflict switch (#1175): a checkout was rejected because
37689
+ // the branch is checked out elsewhere — name the branch + worktree so
37690
+ // the prompt explains what "y" does (jump into that worktree).
37691
+ const conflict = state.worktreeCheckoutConflict;
37692
+ const isWorktreeConflict = state.pendingConfirmationId === 'switch-to-conflicting-worktree';
37693
+ const label = isWorktreeConflict && conflict
37694
+ ? `Switch to the worktree where '${conflict.branch}' is checked out?`
37695
+ : action?.label || mutationLabel || 'Workflow action';
37622
37696
  const warning = state.pendingMutationConfirmation === 'discard-draft'
37623
37697
  ? 'You have an unsaved commit draft. Press y to discard it and quit.'
37624
37698
  : state.pendingMutationConfirmation
37625
37699
  ? 'This discards local changes and cannot be undone by Coco.'
37626
- // Second-stage confirm raised when a safe delete hit an unmerged
37627
- // branch — name the reason so the force isn't a blind "y again".
37628
- : state.pendingConfirmationId === 'force-delete-branch'
37629
- ? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
37630
- : action?.kind === 'ai'
37631
- ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37632
- : 'Destructive Git action requires confirmation.';
37700
+ : isWorktreeConflict && conflict
37701
+ ? `'${conflict.branch}' is checked out at ${conflict.worktreePath}.${conflict.dirty ? ' That worktree has uncommitted changes removal will be refused until it is clean or stashed.' : ''}`
37702
+ // Second-stage confirm raised when a safe delete hit an unmerged
37703
+ // branch name the reason so the force isn't a blind "y again".
37704
+ : state.pendingConfirmationId === 'force-delete-branch'
37705
+ ? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
37706
+ : action?.kind === 'ai'
37707
+ ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37708
+ : 'Destructive Git action requires confirmation.';
37633
37709
  return h(Box, {
37634
37710
  borderColor: focusBorderColor(theme, focused),
37635
37711
  borderStyle: theme.borderStyle,
37636
37712
  flexDirection: 'column',
37637
37713
  width,
37638
37714
  paddingX: 1,
37639
- }, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncateCells(label, width - 4)), h(Text, { dimColor: true }, truncateCells(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined, 'Press y to confirm or n/Esc to cancel.'));
37715
+ }, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncateCells(label, width - 4)), h(Text, { dimColor: true }, truncateCells(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined, truncateCells(isWorktreeConflict
37716
+ ? 'y switch · r remove worktree & check out here · x remove worktree & delete branch · n/Esc cancel'
37717
+ : 'Press y to confirm or n/Esc to cancel.', width - 4)));
37640
37718
  }
37641
37719
  /**
37642
37720
  * First-launch onboarding overlay (P1.3). Shown once per machine, gated
@@ -38800,6 +38878,15 @@ function renderReflogSurface(ctx) {
38800
38878
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38801
38879
  * of #890. No behavior change.
38802
38880
  */
38881
+ const GAP = 2; // cells between columns
38882
+ /** Truncate to `w` cells, then pad to `w` (left = padEnd, right = padStart). */
38883
+ function cell(value, w, align = 'left') {
38884
+ const t = truncateCells(value, w);
38885
+ // padStart/padEnd count code units; refs / ages / counts / branch
38886
+ // names are ASCII in practice, matching the branches surface's
38887
+ // padEnd-based column alignment.
38888
+ return align === 'right' ? t.padStart(w) : t.padEnd(w);
38889
+ }
38803
38890
  function renderStashSurface(ctx, spinnerFrame = 0) {
38804
38891
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38805
38892
  const { Box, Text } = components;
@@ -38810,7 +38897,9 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38810
38897
  ? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
38811
38898
  : allStashes;
38812
38899
  const selected = Math.max(0, Math.min(state.selectedStashIndex, Math.max(0, stashes.length - 1)));
38813
- const listRows = Math.max(4, bodyRows - 4);
38900
+ // One extra row reserved (vs the other surfaces' `- 4`) for the column
38901
+ // header row below.
38902
+ const listRows = Math.max(4, bodyRows - 5);
38814
38903
  const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
38815
38904
  const visible = stashes.slice(startIndex, startIndex + listRows);
38816
38905
  const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
@@ -38820,10 +38909,67 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38820
38909
  const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
38821
38910
  const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
38822
38911
  const now = getRenderNow();
38823
- // Available width for a row: box width minus the 2-cell horizontal
38824
- // padding. Truncate to it (with a small floor) instead of a magic 140
38825
- // so the richer meta degrades gracefully on narrow terminals.
38826
- const rowWidth = Math.max(20, width - 2);
38912
+ // Usable interior width: panel width minus the border (2) and the
38913
+ // paddingX:1 (2). The previous `width - 2` under-counted the border
38914
+ // and made every near-full row overflow by 2 cells and wrap — the
38915
+ // single biggest readability hit in the old list.
38916
+ const contentWidth = Math.max(20, width - 4);
38917
+ const ageOf = (s) => formatCompactRelativeDate(s.date, now);
38918
+ // Column widths derived from the visible window (#833 pattern) so rows
38919
+ // align without re-measuring the whole list. Each column is at least
38920
+ // as wide as its header label so the header never truncates ("age",
38921
+ // "branch", "files"); age tops out at 5 ("today" is the longest value
38922
+ // formatCompactRelativeDate emits).
38923
+ const refCol = visible.length
38924
+ ? Math.min(11, Math.max(3, ...visible.map((s) => cellWidth(s.ref))))
38925
+ : 9;
38926
+ const ageCol = visible.length
38927
+ ? Math.min(5, Math.max(3, ...visible.map((s) => cellWidth(ageOf(s)))))
38928
+ : 3;
38929
+ const branchColMax = visible.length
38930
+ ? Math.max(0, ...visible.map((s) => cellWidth(s.branch || '')))
38931
+ : 0;
38932
+ const filesCol = visible.length
38933
+ ? Math.max(5, ...visible.map((s) => String(s.files.length).length))
38934
+ : 5;
38935
+ // Responsive degradation. Keep ref + message always; when the message
38936
+ // floor is threatened, shed columns the preview pane already shows —
38937
+ // branch first, then age, then the file count — before squeezing the
38938
+ // message.
38939
+ // Widen to fit the "branch" header when any stash carries a branch;
38940
+ // stays 0 (column dropped) when none do.
38941
+ let branchCol = branchColMax > 0 ? Math.min(18, Math.max(6, branchColMax)) : 0;
38942
+ let showAge = true;
38943
+ let showFiles = true;
38944
+ const fixedWidth = () => 2 + refCol +
38945
+ (showAge ? GAP + ageCol : 0) +
38946
+ (branchCol > 0 ? GAP + branchCol : 0) +
38947
+ (showFiles ? GAP + filesCol : 0) +
38948
+ GAP; // gap before the message column
38949
+ let messageWidth = contentWidth - fixedWidth();
38950
+ if (messageWidth < 24 && branchCol > 0) {
38951
+ branchCol = 0;
38952
+ messageWidth = contentWidth - fixedWidth();
38953
+ }
38954
+ if (messageWidth < 16 && showAge) {
38955
+ showAge = false;
38956
+ messageWidth = contentWidth - fixedWidth();
38957
+ }
38958
+ if (messageWidth < 12 && showFiles) {
38959
+ showFiles = false;
38960
+ messageWidth = contentWidth - fixedWidth();
38961
+ }
38962
+ messageWidth = Math.max(8, messageWidth);
38963
+ // Column header. Right-aligned labels over the right-aligned numeric
38964
+ // columns (age, files) so header and data share an edge.
38965
+ let headerText = ` ${cell('ref', refCol)}`;
38966
+ if (showAge)
38967
+ headerText += `${' '.repeat(GAP)}${cell('age', ageCol, 'right')}`;
38968
+ if (branchCol > 0)
38969
+ headerText += `${' '.repeat(GAP)}${cell('branch', branchCol)}`;
38970
+ if (showFiles)
38971
+ headerText += `${' '.repeat(GAP)}${cell('files', filesCol, 'right')}`;
38972
+ headerText += `${' '.repeat(GAP)}message`;
38827
38973
  const lines = loading
38828
38974
  ? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
38829
38975
  : stashes.length === 0
@@ -38832,32 +38978,22 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38832
38978
  const index = startIndex + offset;
38833
38979
  const isSelected = index === selected;
38834
38980
  const cursor = isSelected ? '>' : ' ';
38835
- // Surface the metadata the StashEntry already carries — origin
38836
- // branch, file count, and relative age — between the ref and the
38837
- // message, so the list answers "which stash is this?" without an
38838
- // Enter→diff round trip.
38839
- const age = formatCompactRelativeDate(stash.date, now);
38840
- const fileCount = stash.files.length;
38841
- const meta = [
38842
- stash.branch ? `on ${stash.branch}` : '',
38843
- fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
38844
- age,
38845
- ].filter(Boolean).join(' · ');
38846
- const rowText = meta
38847
- ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38848
- : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
38981
+ const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
38849
38982
  // The `stash@{N}` ref is an identifier, not a status icon, so a
38850
38983
  // delete-in-flight appends an accent spinner at the row's end
38851
- // (2 cells reserved from the width budget).
38852
- const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
38984
+ // (2 cells reserved from the message budget).
38853
38985
  const spinnerSpan = deleting
38854
38986
  ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
38855
38987
  : null;
38988
+ const message = truncateCells(stash.message, messageWidth - (deleting ? 2 : 0));
38989
+ // ref + message read as primary; age / branch / file-count are
38990
+ // dim metadata (kept dim even on the bold selected row so the
38991
+ // message stays the focal point).
38856
38992
  return h(Text, {
38857
38993
  key: `stash-${index}`,
38858
38994
  bold: isSelected,
38859
38995
  dimColor: !isSelected,
38860
- }, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
38996
+ }, `${cursor} `, cell(stash.ref, refCol), showAge ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(ageOf(stash), ageCol, 'right')}`) : null, branchCol > 0 ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(stash.branch || '', branchCol)}`) : null, showFiles ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(String(stash.files.length), filesCol, 'right')}`) : null, `${' '.repeat(GAP)}${message}`, spinnerSpan);
38861
38997
  });
38862
38998
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
38863
38999
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -38868,7 +39004,11 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38868
39004
  flexShrink: 0,
38869
39005
  paddingX: 1,
38870
39006
  width,
38871
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(stashHasMoreAbove
39007
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme),
39008
+ // Column header — only when there are rows to label.
39009
+ ...(!loading && stashes.length > 0
39010
+ ? [h(Text, { key: 'stash-col-header', dimColor: true }, truncateCells(headerText, contentWidth))]
39011
+ : []), ...(stashHasMoreAbove
38872
39012
  ? [h(Text, { key: 'stash-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
38873
39013
  : []), ...lines, ...(stashHasMoreBelow
38874
39014
  ? [h(Text, { key: 'stash-more-below', dimColor: true }, ` ↓ ${stashes.length - (startIndex + listRows)} more below`)]
@@ -43688,6 +43828,42 @@ function LogInkApp(deps) {
43688
43828
  // path on the wrong target.
43689
43829
  return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
43690
43830
  },
43831
+ // Worktree-checkout-conflict resolutions (#1175). Unlike the
43832
+ // cursor-targeted handlers above, these act on the worktree
43833
+ // captured in `state.worktreeCheckoutConflict` (the one git named
43834
+ // when it refused the checkout), not the worktrees-view cursor.
43835
+ 'conflict-remove-worktree-checkout': async () => {
43836
+ const conflict = state.worktreeCheckoutConflict;
43837
+ dispatch({ type: 'setWorktreeCheckoutConflict', value: undefined });
43838
+ if (!conflict)
43839
+ return { ok: false, message: 'No worktree conflict to resolve.' };
43840
+ const worktree = context.worktreeList?.worktrees?.find((w) => w.path === conflict.worktreePath);
43841
+ if (!worktree)
43842
+ return { ok: false, message: `Worktree ${conflict.worktreePath} not found.` };
43843
+ // removeWorktree refuses a dirty / current worktree and returns
43844
+ // a clear message — surface it rather than forcing.
43845
+ const removed = await removeWorktree(git, worktree);
43846
+ if (!removed.ok)
43847
+ return removed;
43848
+ const branch = (context.branches?.localBranches || []).find((b) => b.type === 'local' && b.shortName === conflict.branch);
43849
+ if (!branch) {
43850
+ return { ok: true, message: `Removed worktree ${worktree.path}; branch ${conflict.branch} not found to check out.` };
43851
+ }
43852
+ const checkout = await checkoutBranch(git, branch);
43853
+ return checkout.ok
43854
+ ? { ok: true, message: `Removed worktree ${worktree.path} and checked out ${conflict.branch}` }
43855
+ : { ok: false, message: `Removed worktree ${worktree.path}, but checkout failed: ${checkout.message}` };
43856
+ },
43857
+ 'conflict-remove-worktree-branch': async () => {
43858
+ const conflict = state.worktreeCheckoutConflict;
43859
+ dispatch({ type: 'setWorktreeCheckoutConflict', value: undefined });
43860
+ if (!conflict)
43861
+ return { ok: false, message: 'No worktree conflict to resolve.' };
43862
+ const worktree = context.worktreeList?.worktrees?.find((w) => w.path === conflict.worktreePath);
43863
+ if (!worktree)
43864
+ return { ok: false, message: `Worktree ${conflict.worktreePath} not found.` };
43865
+ return removeWorktreeAndBranch(git, worktree, context.branches?.localBranches || []);
43866
+ },
43691
43867
  'abort-operation': async () => {
43692
43868
  const operation = context.operation?.operation;
43693
43869
  if (!operation) {
@@ -44150,6 +44326,30 @@ function LogInkApp(deps) {
44150
44326
  kind: 'warning',
44151
44327
  });
44152
44328
  }
44329
+ // Checking out a branch that's already checked out in another
44330
+ // worktree is rejected by git ("already checked out at <path>").
44331
+ // Rather than dead-end on that, capture the conflict and raise a
44332
+ // y-confirm offering to switch into that worktree — the branch IS
44333
+ // checked out, just elsewhere (#1175).
44334
+ if (id === 'checkout-branch' && !result?.ok && isBranchCheckedOutElsewhereError(result?.message)) {
44335
+ const worktreePath = parseCheckedOutWorktreePath(result?.message);
44336
+ const branchName = pendingItemAction?.id;
44337
+ if (worktreePath && branchName) {
44338
+ const worktree = context.worktreeList?.worktrees?.find((w) => w.path === worktreePath);
44339
+ dispatch({
44340
+ type: 'setWorktreeCheckoutConflict',
44341
+ value: { branch: branchName, worktreePath, dirty: worktree?.dirty ?? false },
44342
+ });
44343
+ dispatch({ type: 'setPendingConfirmation', value: 'switch-to-conflicting-worktree' });
44344
+ }
44345
+ else {
44346
+ dispatch({
44347
+ type: 'setStatus',
44348
+ value: `'${branchName ?? 'branch'}' is already checked out in another worktree.`,
44349
+ kind: 'warning',
44350
+ });
44351
+ }
44352
+ }
44153
44353
  // Refresh history rows AS WELL when the workflow could have
44154
44354
  // changed the commits the user sees (#945 follow-up). The
44155
44355
  // workflow IDs below all either create/rewrite local commits or
@@ -44159,6 +44359,10 @@ function LogInkApp(deps) {
44159
44359
  // metadata-only mutations (delete-tag, set-upstream, etc.).
44160
44360
  const historyMutatingIds = new Set([
44161
44361
  'checkout-branch',
44362
+ // Resolving a checkout conflict changes HEAD (checkout) and/or the
44363
+ // ref set (branch delete), so the graph needs a refresh.
44364
+ 'conflict-remove-worktree-checkout',
44365
+ 'conflict-remove-worktree-branch',
44162
44366
  'continue-operation',
44163
44367
  'pull-current-branch',
44164
44368
  // Fetch / pull / push bring in new commits and move
@@ -44194,7 +44398,7 @@ function LogInkApp(deps) {
44194
44398
  // (resolvePendingItemAction → action 'checkout'), so a silent
44195
44399
  // stale-while-revalidate swap keeps the list readable and just
44196
44400
  // repaints the current-branch marker once the new context lands.
44197
- if (id === 'checkout-branch' && result?.ok) {
44401
+ if ((id === 'checkout-branch' || id === 'conflict-remove-worktree-checkout') && result?.ok) {
44198
44402
  dispatch({ type: 'resetBranchSelection' });
44199
44403
  await refreshContext({ silent: true });
44200
44404
  }
@@ -44261,7 +44465,7 @@ function LogInkApp(deps) {
44261
44465
  }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
44262
44466
  state.branchSort, state.filter, state.selectedBranchIndex,
44263
44467
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
44264
- state.statusFilterMask, state.tagSort]);
44468
+ state.statusFilterMask, state.tagSort, state.worktreeCheckoutConflict]);
44265
44469
  // Resolve the active view's "yank target" (commit hash / branch /
44266
44470
  // tag / stash ref / file path) against the live filtered+sorted list,
44267
44471
  // copy it to the system clipboard, and surface the result on the
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.62.2";
81
+ const BUILD_VERSION = "0.62.4";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -26385,6 +26385,8 @@ function applyLogInkAction(state, action) {
26385
26385
  pendingMutationConfirmation: action.value ? undefined : state.pendingMutationConfirmation,
26386
26386
  pendingKey: undefined,
26387
26387
  };
26388
+ case 'setWorktreeCheckoutConflict':
26389
+ return { ...state, worktreeCheckoutConflict: action.value, pendingKey: undefined };
26388
26390
  case 'setPendingMutationConfirmation':
26389
26391
  return {
26390
26392
  ...state,
@@ -27757,7 +27759,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27757
27759
  return [];
27758
27760
  }
27759
27761
  if (state.pendingConfirmationId) {
27762
+ // Worktree-conflict removal options (#1175): alongside the y-switch,
27763
+ // `r` removes the conflicting worktree and checks the branch out
27764
+ // here, `x` removes the worktree AND deletes the branch. Both defer
27765
+ // to the runtime (it owns the git ops + the conflict context); the
27766
+ // runtime clears the conflict state once it resolves.
27767
+ if (state.pendingConfirmationId === 'switch-to-conflicting-worktree' && state.worktreeCheckoutConflict) {
27768
+ if (inputValue === 'r') {
27769
+ return [
27770
+ { type: 'runWorkflowAction', id: 'conflict-remove-worktree-checkout' },
27771
+ action({ type: 'setPendingConfirmation', value: undefined }),
27772
+ ];
27773
+ }
27774
+ if (inputValue === 'x') {
27775
+ return [
27776
+ { type: 'runWorkflowAction', id: 'conflict-remove-worktree-branch' },
27777
+ action({ type: 'setPendingConfirmation', value: undefined }),
27778
+ ];
27779
+ }
27780
+ }
27760
27781
  if (inputValue === 'y') {
27782
+ // Worktree-conflict switch (#1175): the branch is already checked
27783
+ // out elsewhere, so "switch" just opens that worktree as a nested
27784
+ // repo frame (same mechanism as drilling into a submodule) — no
27785
+ // git mutation, hence handled here rather than via the runtime.
27786
+ if (state.pendingConfirmationId === 'switch-to-conflicting-worktree') {
27787
+ const conflict = state.worktreeCheckoutConflict;
27788
+ if (conflict) {
27789
+ return [
27790
+ action({ type: 'pushRepoFrame', label: conflict.branch, workdir: conflict.worktreePath }),
27791
+ action({ type: 'setStatus', value: `Switched to worktree ${conflict.worktreePath} (${conflict.branch})` }),
27792
+ action({ type: 'setPendingConfirmation', value: undefined }),
27793
+ action({ type: 'setWorktreeCheckoutConflict', value: undefined }),
27794
+ ];
27795
+ }
27796
+ return [
27797
+ action({ type: 'setPendingConfirmation', value: undefined }),
27798
+ action({ type: 'setWorktreeCheckoutConflict', value: undefined }),
27799
+ ];
27800
+ }
27761
27801
  const workflowAction = getLogInkWorkflowActionById(state.pendingConfirmationId);
27762
27802
  if (workflowAction?.id === 'ai-commit-summary') {
27763
27803
  return [
@@ -27783,6 +27823,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27783
27823
  if (inputValue === 'n' || key.escape) {
27784
27824
  return [
27785
27825
  action({ type: 'setPendingConfirmation', value: undefined }),
27826
+ // Drop any worktree-conflict context so the prompt doesn't
27827
+ // linger after the user declines to switch.
27828
+ ...(state.worktreeCheckoutConflict
27829
+ ? [action({ type: 'setWorktreeCheckoutConflict', value: undefined })]
27830
+ : []),
27786
27831
  action({ type: 'setStatus', value: 'workflow action cancelled' }),
27787
27832
  ];
27788
27833
  }
@@ -34368,7 +34413,13 @@ function sidebarTabLabel(tab) {
34368
34413
  * Sliding window keeps the cursor in view as the user navigates a long
34369
34414
  * list; truncation hints surface the count of hidden rows.
34370
34415
  */
34371
- function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
34416
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount,
34417
+ // Optional per-row foreground colour (e.g. the current branch in
34418
+ // green). Applied only when the row is NOT the active selection —
34419
+ // the selection's inverse/background styling owns the row's colour in
34420
+ // that case, and layering a foreground under `inverse` would swap it
34421
+ // into an unexpected background tint.
34422
+ rowColor) {
34372
34423
  if (items.length === 0)
34373
34424
  return [];
34374
34425
  const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
@@ -34385,10 +34436,12 @@ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, wid
34385
34436
  break;
34386
34437
  const isSelected = focused && index === selectedIndex;
34387
34438
  const text = toRowText(items[index], index);
34439
+ const color = isSelected ? undefined : rowColor?.(items[index], index);
34388
34440
  elements.push(h(Text, {
34389
34441
  key: `${keyPrefix}-row-${index}`,
34390
34442
  backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
34391
34443
  inverse: isSelected,
34444
+ color,
34392
34445
  }, truncateCells(` ${text}`, width - 4)));
34393
34446
  }
34394
34447
  if (window.truncatedBelow > 0) {
@@ -34498,7 +34551,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34498
34551
  ? spin
34499
34552
  : branchRowMarker(branch, { ascii: theme.ascii }).glyph;
34500
34553
  return `${glyph} ${branch.shortName}`;
34501
- }, 'tab-branches', visibleListCount),
34554
+ }, 'tab-branches', visibleListCount,
34555
+ // Paint the checked-out branch green so "where am I?" reads at a
34556
+ // glance, matching the green HEAD marker the branches surface
34557
+ // already uses. NO_COLOR themes fall back to the `*` glyph alone.
34558
+ (branch) => (branch.current && !theme.noColor ? theme.colors.success : undefined)),
34502
34559
  ];
34503
34560
  }
34504
34561
  if (tab === 'tags') {
@@ -34999,11 +35056,16 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34999
35056
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
35000
35057
  // If truncation chopped into the timestamp/divergence portion,
35001
35058
  // fall back to a single Text to keep the visible width honest.
35059
+ // The checked-out branch is painted green (matching its green
35060
+ // HEAD marker) so "where am I?" reads at a glance. NO_COLOR
35061
+ // themes fall back to the `*` glyph alone.
35062
+ const currentColor = branch.current && !theme.noColor ? theme.colors.success : undefined;
35002
35063
  if (truncated !== fullText) {
35003
35064
  return h(Text, {
35004
35065
  key: `branch-${index}`,
35005
35066
  bold: isSelected,
35006
35067
  dimColor: lineDim,
35068
+ color: currentColor,
35007
35069
  }, truncated);
35008
35070
  }
35009
35071
  return h(Text, {
@@ -35018,7 +35080,12 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
35018
35080
  // no-upstream kinds return undefined from
35019
35081
  // `getBranchRowMarkerColor`, so those markers inherit the
35020
35082
  // row's dim and read as quiet chrome.
35021
- h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
35083
+ h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph),
35084
+ // Name span: green for the current branch (dimColor:false keeps
35085
+ // it bright), otherwise it inherits the row's normal styling.
35086
+ currentColor
35087
+ ? h(Text, { color: currentColor, dimColor: false }, trailingName)
35088
+ : trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
35022
35089
  });
35023
35090
  // Scroll indicators — same "N more above/below" pattern as the
35024
35091
  // sidebar and help overlay so the user knows the list continues.
@@ -37635,25 +37702,36 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
37635
37702
  : state.pendingMutationConfirmation === 'discard-draft'
37636
37703
  ? 'Quit and discard the in-progress commit draft'
37637
37704
  : undefined;
37638
- const label = action?.label || mutationLabel || 'Workflow action';
37705
+ // Worktree-conflict switch (#1175): a checkout was rejected because
37706
+ // the branch is checked out elsewhere — name the branch + worktree so
37707
+ // the prompt explains what "y" does (jump into that worktree).
37708
+ const conflict = state.worktreeCheckoutConflict;
37709
+ const isWorktreeConflict = state.pendingConfirmationId === 'switch-to-conflicting-worktree';
37710
+ const label = isWorktreeConflict && conflict
37711
+ ? `Switch to the worktree where '${conflict.branch}' is checked out?`
37712
+ : action?.label || mutationLabel || 'Workflow action';
37639
37713
  const warning = state.pendingMutationConfirmation === 'discard-draft'
37640
37714
  ? 'You have an unsaved commit draft. Press y to discard it and quit.'
37641
37715
  : state.pendingMutationConfirmation
37642
37716
  ? 'This discards local changes and cannot be undone by Coco.'
37643
- // Second-stage confirm raised when a safe delete hit an unmerged
37644
- // branch — name the reason so the force isn't a blind "y again".
37645
- : state.pendingConfirmationId === 'force-delete-branch'
37646
- ? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
37647
- : action?.kind === 'ai'
37648
- ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37649
- : 'Destructive Git action requires confirmation.';
37717
+ : isWorktreeConflict && conflict
37718
+ ? `'${conflict.branch}' is checked out at ${conflict.worktreePath}.${conflict.dirty ? ' That worktree has uncommitted changes removal will be refused until it is clean or stashed.' : ''}`
37719
+ // Second-stage confirm raised when a safe delete hit an unmerged
37720
+ // branch name the reason so the force isn't a blind "y again".
37721
+ : state.pendingConfirmationId === 'force-delete-branch'
37722
+ ? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
37723
+ : action?.kind === 'ai'
37724
+ ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37725
+ : 'Destructive Git action requires confirmation.';
37650
37726
  return h(Box, {
37651
37727
  borderColor: focusBorderColor(theme, focused),
37652
37728
  borderStyle: theme.borderStyle,
37653
37729
  flexDirection: 'column',
37654
37730
  width,
37655
37731
  paddingX: 1,
37656
- }, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncateCells(label, width - 4)), h(Text, { dimColor: true }, truncateCells(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined, 'Press y to confirm or n/Esc to cancel.'));
37732
+ }, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncateCells(label, width - 4)), h(Text, { dimColor: true }, truncateCells(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined, truncateCells(isWorktreeConflict
37733
+ ? 'y switch · r remove worktree & check out here · x remove worktree & delete branch · n/Esc cancel'
37734
+ : 'Press y to confirm or n/Esc to cancel.', width - 4)));
37657
37735
  }
37658
37736
  /**
37659
37737
  * First-launch onboarding overlay (P1.3). Shown once per machine, gated
@@ -38817,6 +38895,15 @@ function renderReflogSurface(ctx) {
38817
38895
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38818
38896
  * of #890. No behavior change.
38819
38897
  */
38898
+ const GAP = 2; // cells between columns
38899
+ /** Truncate to `w` cells, then pad to `w` (left = padEnd, right = padStart). */
38900
+ function cell(value, w, align = 'left') {
38901
+ const t = truncateCells(value, w);
38902
+ // padStart/padEnd count code units; refs / ages / counts / branch
38903
+ // names are ASCII in practice, matching the branches surface's
38904
+ // padEnd-based column alignment.
38905
+ return align === 'right' ? t.padStart(w) : t.padEnd(w);
38906
+ }
38820
38907
  function renderStashSurface(ctx, spinnerFrame = 0) {
38821
38908
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38822
38909
  const { Box, Text } = components;
@@ -38827,7 +38914,9 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38827
38914
  ? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
38828
38915
  : allStashes;
38829
38916
  const selected = Math.max(0, Math.min(state.selectedStashIndex, Math.max(0, stashes.length - 1)));
38830
- const listRows = Math.max(4, bodyRows - 4);
38917
+ // One extra row reserved (vs the other surfaces' `- 4`) for the column
38918
+ // header row below.
38919
+ const listRows = Math.max(4, bodyRows - 5);
38831
38920
  const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
38832
38921
  const visible = stashes.slice(startIndex, startIndex + listRows);
38833
38922
  const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
@@ -38837,10 +38926,67 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38837
38926
  const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
38838
38927
  const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
38839
38928
  const now = getRenderNow();
38840
- // Available width for a row: box width minus the 2-cell horizontal
38841
- // padding. Truncate to it (with a small floor) instead of a magic 140
38842
- // so the richer meta degrades gracefully on narrow terminals.
38843
- const rowWidth = Math.max(20, width - 2);
38929
+ // Usable interior width: panel width minus the border (2) and the
38930
+ // paddingX:1 (2). The previous `width - 2` under-counted the border
38931
+ // and made every near-full row overflow by 2 cells and wrap — the
38932
+ // single biggest readability hit in the old list.
38933
+ const contentWidth = Math.max(20, width - 4);
38934
+ const ageOf = (s) => formatCompactRelativeDate(s.date, now);
38935
+ // Column widths derived from the visible window (#833 pattern) so rows
38936
+ // align without re-measuring the whole list. Each column is at least
38937
+ // as wide as its header label so the header never truncates ("age",
38938
+ // "branch", "files"); age tops out at 5 ("today" is the longest value
38939
+ // formatCompactRelativeDate emits).
38940
+ const refCol = visible.length
38941
+ ? Math.min(11, Math.max(3, ...visible.map((s) => cellWidth(s.ref))))
38942
+ : 9;
38943
+ const ageCol = visible.length
38944
+ ? Math.min(5, Math.max(3, ...visible.map((s) => cellWidth(ageOf(s)))))
38945
+ : 3;
38946
+ const branchColMax = visible.length
38947
+ ? Math.max(0, ...visible.map((s) => cellWidth(s.branch || '')))
38948
+ : 0;
38949
+ const filesCol = visible.length
38950
+ ? Math.max(5, ...visible.map((s) => String(s.files.length).length))
38951
+ : 5;
38952
+ // Responsive degradation. Keep ref + message always; when the message
38953
+ // floor is threatened, shed columns the preview pane already shows —
38954
+ // branch first, then age, then the file count — before squeezing the
38955
+ // message.
38956
+ // Widen to fit the "branch" header when any stash carries a branch;
38957
+ // stays 0 (column dropped) when none do.
38958
+ let branchCol = branchColMax > 0 ? Math.min(18, Math.max(6, branchColMax)) : 0;
38959
+ let showAge = true;
38960
+ let showFiles = true;
38961
+ const fixedWidth = () => 2 + refCol +
38962
+ (showAge ? GAP + ageCol : 0) +
38963
+ (branchCol > 0 ? GAP + branchCol : 0) +
38964
+ (showFiles ? GAP + filesCol : 0) +
38965
+ GAP; // gap before the message column
38966
+ let messageWidth = contentWidth - fixedWidth();
38967
+ if (messageWidth < 24 && branchCol > 0) {
38968
+ branchCol = 0;
38969
+ messageWidth = contentWidth - fixedWidth();
38970
+ }
38971
+ if (messageWidth < 16 && showAge) {
38972
+ showAge = false;
38973
+ messageWidth = contentWidth - fixedWidth();
38974
+ }
38975
+ if (messageWidth < 12 && showFiles) {
38976
+ showFiles = false;
38977
+ messageWidth = contentWidth - fixedWidth();
38978
+ }
38979
+ messageWidth = Math.max(8, messageWidth);
38980
+ // Column header. Right-aligned labels over the right-aligned numeric
38981
+ // columns (age, files) so header and data share an edge.
38982
+ let headerText = ` ${cell('ref', refCol)}`;
38983
+ if (showAge)
38984
+ headerText += `${' '.repeat(GAP)}${cell('age', ageCol, 'right')}`;
38985
+ if (branchCol > 0)
38986
+ headerText += `${' '.repeat(GAP)}${cell('branch', branchCol)}`;
38987
+ if (showFiles)
38988
+ headerText += `${' '.repeat(GAP)}${cell('files', filesCol, 'right')}`;
38989
+ headerText += `${' '.repeat(GAP)}message`;
38844
38990
  const lines = loading
38845
38991
  ? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
38846
38992
  : stashes.length === 0
@@ -38849,32 +38995,22 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38849
38995
  const index = startIndex + offset;
38850
38996
  const isSelected = index === selected;
38851
38997
  const cursor = isSelected ? '>' : ' ';
38852
- // Surface the metadata the StashEntry already carries — origin
38853
- // branch, file count, and relative age — between the ref and the
38854
- // message, so the list answers "which stash is this?" without an
38855
- // Enter→diff round trip.
38856
- const age = formatCompactRelativeDate(stash.date, now);
38857
- const fileCount = stash.files.length;
38858
- const meta = [
38859
- stash.branch ? `on ${stash.branch}` : '',
38860
- fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
38861
- age,
38862
- ].filter(Boolean).join(' · ');
38863
- const rowText = meta
38864
- ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38865
- : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
38998
+ const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
38866
38999
  // The `stash@{N}` ref is an identifier, not a status icon, so a
38867
39000
  // delete-in-flight appends an accent spinner at the row's end
38868
- // (2 cells reserved from the width budget).
38869
- const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
39001
+ // (2 cells reserved from the message budget).
38870
39002
  const spinnerSpan = deleting
38871
39003
  ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
38872
39004
  : null;
39005
+ const message = truncateCells(stash.message, messageWidth - (deleting ? 2 : 0));
39006
+ // ref + message read as primary; age / branch / file-count are
39007
+ // dim metadata (kept dim even on the bold selected row so the
39008
+ // message stays the focal point).
38873
39009
  return h(Text, {
38874
39010
  key: `stash-${index}`,
38875
39011
  bold: isSelected,
38876
39012
  dimColor: !isSelected,
38877
- }, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
39013
+ }, `${cursor} `, cell(stash.ref, refCol), showAge ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(ageOf(stash), ageCol, 'right')}`) : null, branchCol > 0 ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(stash.branch || '', branchCol)}`) : null, showFiles ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(String(stash.files.length), filesCol, 'right')}`) : null, `${' '.repeat(GAP)}${message}`, spinnerSpan);
38878
39014
  });
38879
39015
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
38880
39016
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -38885,7 +39021,11 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38885
39021
  flexShrink: 0,
38886
39022
  paddingX: 1,
38887
39023
  width,
38888
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(stashHasMoreAbove
39024
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme),
39025
+ // Column header — only when there are rows to label.
39026
+ ...(!loading && stashes.length > 0
39027
+ ? [h(Text, { key: 'stash-col-header', dimColor: true }, truncateCells(headerText, contentWidth))]
39028
+ : []), ...(stashHasMoreAbove
38889
39029
  ? [h(Text, { key: 'stash-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
38890
39030
  : []), ...lines, ...(stashHasMoreBelow
38891
39031
  ? [h(Text, { key: 'stash-more-below', dimColor: true }, ` ↓ ${stashes.length - (startIndex + listRows)} more below`)]
@@ -43705,6 +43845,42 @@ function LogInkApp(deps) {
43705
43845
  // path on the wrong target.
43706
43846
  return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
43707
43847
  },
43848
+ // Worktree-checkout-conflict resolutions (#1175). Unlike the
43849
+ // cursor-targeted handlers above, these act on the worktree
43850
+ // captured in `state.worktreeCheckoutConflict` (the one git named
43851
+ // when it refused the checkout), not the worktrees-view cursor.
43852
+ 'conflict-remove-worktree-checkout': async () => {
43853
+ const conflict = state.worktreeCheckoutConflict;
43854
+ dispatch({ type: 'setWorktreeCheckoutConflict', value: undefined });
43855
+ if (!conflict)
43856
+ return { ok: false, message: 'No worktree conflict to resolve.' };
43857
+ const worktree = context.worktreeList?.worktrees?.find((w) => w.path === conflict.worktreePath);
43858
+ if (!worktree)
43859
+ return { ok: false, message: `Worktree ${conflict.worktreePath} not found.` };
43860
+ // removeWorktree refuses a dirty / current worktree and returns
43861
+ // a clear message — surface it rather than forcing.
43862
+ const removed = await removeWorktree(git, worktree);
43863
+ if (!removed.ok)
43864
+ return removed;
43865
+ const branch = (context.branches?.localBranches || []).find((b) => b.type === 'local' && b.shortName === conflict.branch);
43866
+ if (!branch) {
43867
+ return { ok: true, message: `Removed worktree ${worktree.path}; branch ${conflict.branch} not found to check out.` };
43868
+ }
43869
+ const checkout = await checkoutBranch(git, branch);
43870
+ return checkout.ok
43871
+ ? { ok: true, message: `Removed worktree ${worktree.path} and checked out ${conflict.branch}` }
43872
+ : { ok: false, message: `Removed worktree ${worktree.path}, but checkout failed: ${checkout.message}` };
43873
+ },
43874
+ 'conflict-remove-worktree-branch': async () => {
43875
+ const conflict = state.worktreeCheckoutConflict;
43876
+ dispatch({ type: 'setWorktreeCheckoutConflict', value: undefined });
43877
+ if (!conflict)
43878
+ return { ok: false, message: 'No worktree conflict to resolve.' };
43879
+ const worktree = context.worktreeList?.worktrees?.find((w) => w.path === conflict.worktreePath);
43880
+ if (!worktree)
43881
+ return { ok: false, message: `Worktree ${conflict.worktreePath} not found.` };
43882
+ return removeWorktreeAndBranch(git, worktree, context.branches?.localBranches || []);
43883
+ },
43708
43884
  'abort-operation': async () => {
43709
43885
  const operation = context.operation?.operation;
43710
43886
  if (!operation) {
@@ -44167,6 +44343,30 @@ function LogInkApp(deps) {
44167
44343
  kind: 'warning',
44168
44344
  });
44169
44345
  }
44346
+ // Checking out a branch that's already checked out in another
44347
+ // worktree is rejected by git ("already checked out at <path>").
44348
+ // Rather than dead-end on that, capture the conflict and raise a
44349
+ // y-confirm offering to switch into that worktree — the branch IS
44350
+ // checked out, just elsewhere (#1175).
44351
+ if (id === 'checkout-branch' && !result?.ok && isBranchCheckedOutElsewhereError(result?.message)) {
44352
+ const worktreePath = parseCheckedOutWorktreePath(result?.message);
44353
+ const branchName = pendingItemAction?.id;
44354
+ if (worktreePath && branchName) {
44355
+ const worktree = context.worktreeList?.worktrees?.find((w) => w.path === worktreePath);
44356
+ dispatch({
44357
+ type: 'setWorktreeCheckoutConflict',
44358
+ value: { branch: branchName, worktreePath, dirty: worktree?.dirty ?? false },
44359
+ });
44360
+ dispatch({ type: 'setPendingConfirmation', value: 'switch-to-conflicting-worktree' });
44361
+ }
44362
+ else {
44363
+ dispatch({
44364
+ type: 'setStatus',
44365
+ value: `'${branchName ?? 'branch'}' is already checked out in another worktree.`,
44366
+ kind: 'warning',
44367
+ });
44368
+ }
44369
+ }
44170
44370
  // Refresh history rows AS WELL when the workflow could have
44171
44371
  // changed the commits the user sees (#945 follow-up). The
44172
44372
  // workflow IDs below all either create/rewrite local commits or
@@ -44176,6 +44376,10 @@ function LogInkApp(deps) {
44176
44376
  // metadata-only mutations (delete-tag, set-upstream, etc.).
44177
44377
  const historyMutatingIds = new Set([
44178
44378
  'checkout-branch',
44379
+ // Resolving a checkout conflict changes HEAD (checkout) and/or the
44380
+ // ref set (branch delete), so the graph needs a refresh.
44381
+ 'conflict-remove-worktree-checkout',
44382
+ 'conflict-remove-worktree-branch',
44179
44383
  'continue-operation',
44180
44384
  'pull-current-branch',
44181
44385
  // Fetch / pull / push bring in new commits and move
@@ -44211,7 +44415,7 @@ function LogInkApp(deps) {
44211
44415
  // (resolvePendingItemAction → action 'checkout'), so a silent
44212
44416
  // stale-while-revalidate swap keeps the list readable and just
44213
44417
  // repaints the current-branch marker once the new context lands.
44214
- if (id === 'checkout-branch' && result?.ok) {
44418
+ if ((id === 'checkout-branch' || id === 'conflict-remove-worktree-checkout') && result?.ok) {
44215
44419
  dispatch({ type: 'resetBranchSelection' });
44216
44420
  await refreshContext({ silent: true });
44217
44421
  }
@@ -44278,7 +44482,7 @@ function LogInkApp(deps) {
44278
44482
  }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
44279
44483
  state.branchSort, state.filter, state.selectedBranchIndex,
44280
44484
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
44281
- state.statusFilterMask, state.tagSort]);
44485
+ state.statusFilterMask, state.tagSort, state.worktreeCheckoutConflict]);
44282
44486
  // Resolve the active view's "yank target" (commit hash / branch /
44283
44487
  // tag / stash ref / file path) against the live filtered+sorted list,
44284
44488
  // copy it to the system clipboard, and surface the result on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.62.2",
3
+ "version": "0.62.4",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",