git-coco 0.52.0 → 0.53.0

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.52.0";
64
+ const BUILD_VERSION = "0.53.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -15612,6 +15612,7 @@ Structural rules:
15612
15612
  - Only use hunk IDs LITERALLY copied from the "Staged hunk inventory" section below. Do not invent or guess hunk IDs.
15613
15613
  - If the hunk inventory says "No hunk-level inventory available" then EVERY group's "hunks" array MUST be empty (use only "files"). Do not write hunk IDs like "path::hunk-1" when no hunk inventory exists — those are not valid.
15614
15614
  - Prefer 2-5 commits unless the changes are truly all one topic.
15615
+ - Order the groups in the sequence they would logically be built — foundational changes first, consumers after. If group B uses a symbol, function, type, or file introduced in group A, A MUST appear before B in the array. The applier commits in array order, so this order becomes the git history. Example: a "feat: add helpers" group that introduces \`formatX()\` must come before a "feat: wire helpers into renderer" group that calls \`formatX()\`, even if the staged diff is presented in the opposite order. When two groups have no dependency relationship, prefer the one closer to a "scaffold" (types, config, new files) before the one closer to a "use site" (existing files modified to consume the new code).
15615
15616
 
15616
15617
  Commit message style:
15617
15618
  - Write each "title" in the imperative mood ("add", not "added"), under 72 chars.
@@ -21068,6 +21069,37 @@ function getLogInkWorkflowActions() {
21068
21069
  kind: 'normal',
21069
21070
  requiresConfirmation: false,
21070
21071
  },
21072
+ // Per-view variants of fetch / pull / push that act on the
21073
+ // cursored branch instead of the current one. Empty `key` keeps
21074
+ // them palette-discoverable without registering a global hotkey —
21075
+ // inkInput.ts dispatches them contextually when the user presses
21076
+ // F / U / P while the branches sidebar is focused. Outside that
21077
+ // context, the F / U / P keys still fire the global *-current-*
21078
+ // / fetch-remotes variants above.
21079
+ {
21080
+ id: 'fetch-selected-branch',
21081
+ key: '',
21082
+ label: 'Fetch selected branch',
21083
+ description: 'Run `git fetch <remote> <branch>` for the cursored branch in the branches view / sidebar.',
21084
+ kind: 'normal',
21085
+ requiresConfirmation: false,
21086
+ },
21087
+ {
21088
+ id: 'pull-selected-branch',
21089
+ key: '',
21090
+ label: 'Pull selected branch',
21091
+ description: 'Pull the cursored branch in the branches view / sidebar. Falls back to a fast-forward-only refspec fetch when the branch is not currently checked out; refuses non-FF.',
21092
+ kind: 'normal',
21093
+ requiresConfirmation: false,
21094
+ },
21095
+ {
21096
+ id: 'push-selected-branch',
21097
+ key: '',
21098
+ label: 'Push selected branch',
21099
+ description: 'Run `git push <remote> <branch>` for the cursored branch in the branches view / sidebar.',
21100
+ kind: 'normal',
21101
+ requiresConfirmation: false,
21102
+ },
21071
21103
  {
21072
21104
  // Per-view-only — the inkInput handler scopes this to the tags
21073
21105
  // surface so we don't expose `R` as a remote-delete from elsewhere.
@@ -22008,8 +22040,22 @@ function getLogInkFooterHints(options) {
22008
22040
  // "enter open" hint that drills into the dedicated view.
22009
22041
  const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
22010
22042
  if (itemsPresent && options.sidebarTab === 'branches') {
22043
+ // P / U / F fire the global pull-current-branch, push-current-branch,
22044
+ // fetch-remotes workflows — already implemented, just not visible in
22045
+ // the footer before. Surfacing them here matters because the user's
22046
+ // attention is on a branch when the branches sidebar is focused;
22047
+ // pull / push / fetch are the next obvious actions.
22048
+ //
22049
+ // Note: `U` and `P` currently operate on the CURRENT branch, not the
22050
+ // cursored one. Task #5 will extend them to act on the cursored row;
22051
+ // until then the labels read as "current-branch ops" by virtue of
22052
+ // matching the workflow descriptions.
22011
22053
  return {
22012
- contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
22054
+ contextual: [
22055
+ '↑/↓ branches', '←/→ tab', 'enter checkout',
22056
+ 'F fetch', 'U pull', 'P push',
22057
+ 'D delete', 'R rename', 'u upstream',
22058
+ ],
22013
22059
  global: NORMAL_GLOBAL_HINTS,
22014
22060
  };
22015
22061
  }
@@ -26438,6 +26484,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26438
26484
  events.push({ type: 'createManualCommit' });
26439
26485
  return events;
26440
26486
  }
26487
+ // Context-sensitive per-branch variants of F / U / P. When the
26488
+ // user has the branches sidebar / view focused with at least one
26489
+ // branch, F / U / P should act on the cursored row, not on the
26490
+ // current branch. This intercept fires BEFORE the generic
26491
+ // workflow-by-key lookup below so the global *-current-branch
26492
+ // variants don't shadow the contextual ones.
26493
+ //
26494
+ // Outside the branches context, the generic lookup runs and the
26495
+ // F / U / P keys hit the global `fetch-remotes` / `pull-current-branch`
26496
+ // / `push-current-branch` workflows as before.
26497
+ if (isBranchActionTarget(state) && context.branchCount) {
26498
+ if (inputValue === 'F') {
26499
+ return [{ type: 'runWorkflowAction', id: 'fetch-selected-branch' }];
26500
+ }
26501
+ if (inputValue === 'U') {
26502
+ return [{ type: 'runWorkflowAction', id: 'pull-selected-branch' }];
26503
+ }
26504
+ if (inputValue === 'P') {
26505
+ return [{ type: 'runWorkflowAction', id: 'push-selected-branch' }];
26506
+ }
26507
+ }
26441
26508
  const workflowAction = getLogInkWorkflowActionByKey(inputValue);
26442
26509
  if (workflowAction?.requiresConfirmation) {
26443
26510
  return [action({ type: 'setPendingConfirmation', value: workflowAction.id })];
@@ -27540,6 +27607,106 @@ function pushCurrentBranch(git) {
27540
27607
  function setUpstream(git, localBranch, upstreamBranch) {
27541
27608
  return runAction$5(() => git.raw(['branch', '--set-upstream-to', upstreamBranch, localBranch]), `Set ${localBranch} upstream to ${upstreamBranch}`);
27542
27609
  }
27610
+ /**
27611
+ * Push an arbitrary local branch (need not be the current branch) to
27612
+ * its remote. Refuses when the branch has no upstream and no remote
27613
+ * defaulting is configured — that branch needs a `git push -u …` from
27614
+ * the shell first.
27615
+ *
27616
+ * Pairs with `pushCurrentBranch` (no-arg variant); the workstation
27617
+ * dispatcher picks one or the other based on where the cursor is.
27618
+ */
27619
+ function pushBranch(git, branch) {
27620
+ if (branch.type !== 'local') {
27621
+ return Promise.resolve({
27622
+ ok: false,
27623
+ message: 'Only local branches can be pushed.',
27624
+ });
27625
+ }
27626
+ if (!branch.upstream || !branch.remote) {
27627
+ return Promise.resolve({
27628
+ ok: false,
27629
+ message: `${branch.shortName} has no upstream — checkout the branch and run \`git push -u <remote> ${branch.shortName}\` first.`,
27630
+ });
27631
+ }
27632
+ return runAction$5(() => git.raw(['push', branch.remote, branch.shortName]), `Pushed ${branch.shortName} to ${branch.upstream}`);
27633
+ }
27634
+ /**
27635
+ * Fetch the cursored branch's upstream from its remote. Side-effect
27636
+ * free on the working tree — just updates the remote-tracking ref.
27637
+ * Works for any branch with an upstream regardless of checkout state.
27638
+ *
27639
+ * Falls back to a clean error when the branch has no upstream
27640
+ * configured (`git fetch <remote> <name>` would assume an unrelated
27641
+ * default refspec and surprise the user).
27642
+ */
27643
+ function fetchBranch(git, branch) {
27644
+ if (branch.type !== 'local') {
27645
+ return Promise.resolve({
27646
+ ok: false,
27647
+ message: 'Only local branches can be fetched per-branch — use F to fetch all remotes.',
27648
+ });
27649
+ }
27650
+ if (!branch.upstream || !branch.remote) {
27651
+ return Promise.resolve({
27652
+ ok: false,
27653
+ message: `${branch.shortName} has no upstream — nothing to fetch.`,
27654
+ });
27655
+ }
27656
+ // `branch.upstream` is the short form (e.g. `origin/main`); the
27657
+ // ref name after the remote prefix is what fetch wants as the
27658
+ // refspec source. For a remote `origin` and upstream `origin/main`
27659
+ // we run `git fetch origin main`.
27660
+ const upstreamRef = branch.upstream.startsWith(`${branch.remote}/`)
27661
+ ? branch.upstream.slice(branch.remote.length + 1)
27662
+ : branch.upstream;
27663
+ return runAction$5(() => git.raw(['fetch', branch.remote, upstreamRef]), `Fetched ${branch.upstream}`);
27664
+ }
27665
+ /**
27666
+ * Pull the cursored branch. Branches into two paths based on whether
27667
+ * the branch is currently checked out:
27668
+ *
27669
+ * - **Current branch**: defer to `pullCurrentBranch` (standard
27670
+ * `git pull --ff-only`).
27671
+ * - **Non-current branch**: use the refspec form
27672
+ * `git fetch <remote> <branch>:<branch>` which advances the local
27673
+ * ref to match the remote ref ONLY if the update is fast-forward.
27674
+ * Returns non-zero on non-FF without touching the working tree.
27675
+ * Diverged branches need a checkout + `pull --rebase` from the
27676
+ * user; we refuse rather than try to do that for them.
27677
+ *
27678
+ * `currentBranchName` lets the dispatcher compare without re-querying
27679
+ * git — it already has the value in `context.branches.currentBranch`.
27680
+ */
27681
+ function pullBranch(git, branch, currentBranchName) {
27682
+ if (branch.type !== 'local') {
27683
+ return Promise.resolve({
27684
+ ok: false,
27685
+ message: 'Only local branches can be pulled.',
27686
+ });
27687
+ }
27688
+ if (!branch.upstream || !branch.remote) {
27689
+ return Promise.resolve({
27690
+ ok: false,
27691
+ message: `${branch.shortName} has no upstream — nothing to pull.`,
27692
+ });
27693
+ }
27694
+ // Current branch — defer to the in-place workflow.
27695
+ if (branch.shortName === currentBranchName) {
27696
+ return pullCurrentBranch(git);
27697
+ }
27698
+ // Non-current branch — refspec-based fast-forward refusing non-FF.
27699
+ // `branch.upstream` is `<remote>/<ref>`; strip the remote prefix to
27700
+ // get the upstream ref name to fetch.
27701
+ const upstreamRef = branch.upstream.startsWith(`${branch.remote}/`)
27702
+ ? branch.upstream.slice(branch.remote.length + 1)
27703
+ : branch.upstream;
27704
+ return runAction$5(() => git.raw([
27705
+ 'fetch',
27706
+ branch.remote,
27707
+ `${upstreamRef}:${branch.shortName}`,
27708
+ ]), `Fast-forwarded ${branch.shortName} to ${branch.upstream}`);
27709
+ }
27543
27710
 
27544
27711
  async function runAction$4(action, successMessage) {
27545
27712
  try {
@@ -29063,29 +29230,81 @@ function formatBranchDivergence(branch, options = {}) {
29063
29230
  parts.push(`↓${branch.behind}`);
29064
29231
  return `${parts.join(' ')} ${branch.upstream}`;
29065
29232
  }
29066
- /**
29067
- * Single-cell marker shown to the left of a branch name in lists.
29068
- *
29069
- * - `*` — current branch (regardless of remote state)
29070
- * - `◌` no upstream
29071
- * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
29072
- * - `↕` has upstream + diverged (any non-zero ahead/behind)
29073
- * - ` ` fallback / no info
29074
- *
29075
- * ASCII fallbacks (legible without box-drawing/arrow glyphs):
29076
- * - `?` for "no upstream", `=` for synced, `~` for diverged.
29077
- */
29233
+ function formatUpstreamAheadBanner(branch, options = {}) {
29234
+ if (!branch?.upstream || branch.behind <= 0) {
29235
+ return undefined;
29236
+ }
29237
+ const sep = options.ascii ? '.' : '·';
29238
+ if (branch.ahead > 0) {
29239
+ // Diverged local has work too, fast-forward pull is impossible.
29240
+ // Suggest pull --rebase as the cleaner-history default; users who
29241
+ // prefer merge can do that themselves.
29242
+ const symbols = options.ascii
29243
+ ? `+${branch.ahead} -${branch.behind}`
29244
+ : `↑${branch.ahead} ↓${branch.behind}`;
29245
+ return `${symbols} diverged from ${branch.upstream} ${sep} F fetch ${sep} U pull --rebase`;
29246
+ }
29247
+ // Behind-only — fast-forward pull works.
29248
+ const arrow = options.ascii ? 'v' : '↓';
29249
+ const noun = branch.behind === 1 ? 'commit' : 'commits';
29250
+ return `${arrow} ${branch.behind} ${noun} behind ${branch.upstream} ${sep} F fetch ${sep} U pull`;
29251
+ }
29078
29252
  function branchRowMarker(branch, options = {}) {
29079
- if (branch.current)
29080
- return '*';
29081
- if (!branch.upstream)
29082
- return options.ascii ? '?' : '◌';
29253
+ if (branch.current) {
29254
+ return { glyph: '*', kind: 'head' };
29255
+ }
29256
+ if (!branch.upstream) {
29257
+ return { glyph: options.ascii ? '?' : '◌', kind: 'no-upstream' };
29258
+ }
29083
29259
  const ahead = branch.ahead ?? 0;
29084
29260
  const behind = branch.behind ?? 0;
29085
29261
  if (ahead === 0 && behind === 0) {
29086
- return options.ascii ? '=' : '≡';
29262
+ return { glyph: options.ascii ? '=' : '≡', kind: 'synced' };
29263
+ }
29264
+ if (ahead > 0 && behind > 0) {
29265
+ return { glyph: options.ascii ? '~' : '⇅', kind: 'diverged' };
29266
+ }
29267
+ if (behind > 0) {
29268
+ return { glyph: options.ascii ? 'v' : '↓', kind: 'behind' };
29269
+ }
29270
+ // ahead > 0 (the only remaining case after the guards above)
29271
+ return { glyph: options.ascii ? '^' : '↑', kind: 'ahead' };
29272
+ }
29273
+ /**
29274
+ * Theme-aware colour picker for a `BranchRowMarker.kind`.
29275
+ *
29276
+ * Reuses the existing chip / banner colour semantic so the workstation
29277
+ * speaks one visual language across history (chips, "behind upstream"
29278
+ * banner) and the branches list:
29279
+ *
29280
+ * - `head` → success green (matches HEAD chip)
29281
+ * - `behind` → warning yellow (matches "behind upstream" banner)
29282
+ * - `diverged` → warning yellow (same: action needed inbound)
29283
+ * - `ahead` → info blue (you have work to push)
29284
+ * - `synced` → undefined (neutral; inherit row's existing dim)
29285
+ * - `no-upstream` → undefined (neutral; same)
29286
+ *
29287
+ * Returns `undefined` under `noColor` / `ascii` for the muted cases so
29288
+ * the row renderer skips the colour wrap entirely; the glyph alone
29289
+ * carries the meaning.
29290
+ */
29291
+ function getBranchRowMarkerColor(kind, theme) {
29292
+ if (theme.noColor)
29293
+ return undefined;
29294
+ switch (kind) {
29295
+ case 'head':
29296
+ return theme.colors.success;
29297
+ case 'behind':
29298
+ case 'diverged':
29299
+ return theme.colors.warning;
29300
+ case 'ahead':
29301
+ return theme.colors.info;
29302
+ case 'synced':
29303
+ case 'no-upstream':
29304
+ return undefined;
29305
+ default:
29306
+ return undefined;
29087
29307
  }
29088
- return options.ascii ? '~' : '↕';
29089
29308
  }
29090
29309
  /**
29091
29310
  * Compact, human-friendly relative timestamp for the branch row.
@@ -29693,7 +29912,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
29693
29912
  ];
29694
29913
  return [
29695
29914
  ...headerRows,
29696
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
29915
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii }).glyph} ${branch.shortName}`, 'tab-branches', visibleListCount),
29697
29916
  ];
29698
29917
  }
29699
29918
  if (tab === 'tags') {
@@ -30156,21 +30375,22 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
30156
30375
  const isSelected = index === selected;
30157
30376
  const cursor = isSelected ? '>' : ' ';
30158
30377
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
30378
+ const markerColor = getBranchRowMarkerColor(marker.kind, theme);
30159
30379
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
30160
30380
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
30161
30381
  // Split the row into spans so the timestamp stays dim even on the
30162
- // currently-selected (bold) row. The leading marker + name keep
30163
- // their per-window-derived column widths; the timestamp is
30164
- // right-padded so the divergence column stays aligned across rows.
30382
+ // currently-selected (bold) row, and the sync-state marker keeps
30383
+ // its own colour even when the surrounding row text is dimmed.
30165
30384
  const namePadded = truncateCells(branch.shortName, nameColWidth).padEnd(nameColWidth);
30166
30385
  const timestampPadded = lastTouched.padEnd(8);
30167
30386
  const lineDim = !isSelected && !branch.current;
30168
- const head = `${cursor} ${marker} ${namePadded} `;
30387
+ const cursorAndPad = `${cursor} `;
30388
+ const trailingName = ` ${namePadded} `;
30169
30389
  const trailingDivergence = divergence ? ` ${divergence}` : '';
30170
30390
  // Truncate the assembled line to the actual panel width so a
30171
30391
  // narrow inspector / sidebar focus doesn't push branch rows
30172
30392
  // onto a second visual line (#830).
30173
- const fullText = `${head}${timestampPadded}${trailingDivergence}`;
30393
+ const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
30174
30394
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
30175
30395
  // If truncation chopped into the timestamp/divergence portion,
30176
30396
  // fall back to a single Text to keep the visible width honest.
@@ -30185,7 +30405,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
30185
30405
  key: `branch-${index}`,
30186
30406
  bold: isSelected,
30187
30407
  dimColor: lineDim,
30188
- }, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
30408
+ }, cursorAndPad,
30409
+ // The marker carries the sync-state colour; an explicit
30410
+ // `dimColor: false` on this span keeps the colour bright even
30411
+ // when the surrounding row is dim (other branches in the list
30412
+ // dim out under the existing `lineDim` rule). The synced /
30413
+ // no-upstream kinds return undefined from
30414
+ // `getBranchRowMarkerColor`, so those markers inherit the
30415
+ // row's dim and read as quiet chrome.
30416
+ h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
30189
30417
  });
30190
30418
  return h(Box, {
30191
30419
  borderColor: focusBorderColor(theme, focused),
@@ -32197,6 +32425,25 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
32197
32425
  paddingX: 1,
32198
32426
  width,
32199
32427
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${title} | ${graphMode} | ${loadState}`)),
32428
+ // Upstream-ahead banner. Surfaces "the remote has work you don't"
32429
+ // for the current branch — distinct from the chip work in 0.52.0
32430
+ // which colours remote refs IN the row set. On a behind branch the
32431
+ // upstream commits aren't reachable from local HEAD, so the chips
32432
+ // alone can't signal "fetch / pull needed." This single line does.
32433
+ //
32434
+ // Two wording variants (behind-only vs diverged) live in the
32435
+ // helper; render is identical aside from the formatted string.
32436
+ // Warning yellow = same semantic as the remote-tracking chip kind.
32437
+ ...((() => {
32438
+ const currentBranchRef = context.branches?.localBranches.find((branch) => branch.current);
32439
+ const banner = formatUpstreamAheadBanner(currentBranchRef, { ascii: theme.ascii });
32440
+ if (!banner)
32441
+ return [];
32442
+ return [h(Text, {
32443
+ key: 'upstream-ahead-banner',
32444
+ color: theme.noColor ? undefined : theme.colors.warning,
32445
+ }, banner)];
32446
+ })()),
32200
32447
  // Server-side filter indicator (#776). Only rendered when the user
32201
32448
  // has an active path:/author: prefix; clears when they Ctrl+U.
32202
32449
  ...(state.historyFetchArgs
@@ -36877,16 +37124,20 @@ function LogInkApp(deps) {
36877
37124
  return;
36878
37125
  }
36879
37126
  // Success — close the overlay, reset compose (the staged set is
36880
- // now empty since the plan committed everything), and pop the
36881
- // compose view so the user lands on whatever was beneath (usually
36882
- // status, sometimes history).
37127
+ // now empty since the plan committed everything), and route the
37128
+ // user to the history view so they see the just-landed commits
37129
+ // with the recent-commit marker firing on each row that was
37130
+ // created. Previous behavior popped compose to whatever was
37131
+ // beneath (often status — which now reads "clean worktree" and
37132
+ // gives the user no signal that anything just happened);
37133
+ // history is the natural follow-on surface.
37134
+ //
37135
+ // navigateHome nukes the rest of the stack so `<` after apply
37136
+ // doesn't walk back into the now-empty compose / status state
37137
+ // the user just left behind.
36883
37138
  dispatch({ type: 'clearSplitPlan' });
36884
37139
  dispatch({ type: 'commitCompose', action: { type: 'reset' } });
36885
- // Only pop if compose is on top — the apply could have been
36886
- // invoked from a deeper stack and we don't want to over-pop.
36887
- if (state.activeView === 'compose' && state.viewStack.length > 1) {
36888
- dispatch({ type: 'popView' });
36889
- }
37140
+ dispatch({ type: 'navigateHome' });
36890
37141
  // Refresh BEFORE setting the final status so we can peek at the
36891
37142
  // post-apply worktree state and craft a directive next-step hint
36892
37143
  // ("X unstaged + Y untracked remaining — press gs to stage / I
@@ -36936,7 +37187,7 @@ function LogInkApp(deps) {
36936
37187
  }
36937
37188
  const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
36938
37189
  dispatch({ type: 'setStatus', value: successMessage, kind: 'success' });
36939
- }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.activeView, state.splitPlan, state.viewStack.length]);
37190
+ }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
36940
37191
  // Esc inside the overlay — close without applying. Status line gets
36941
37192
  // a confirmation so the user knows the operation was abandoned.
36942
37193
  const cancelCommitSplit = React.useCallback(() => {
@@ -37443,6 +37694,41 @@ function LogInkApp(deps) {
37443
37694
  'fetch-remotes': async () => fetchRemotes(git),
37444
37695
  'pull-current-branch': async () => pullCurrentBranch(git),
37445
37696
  'push-current-branch': async () => pushCurrentBranch(git),
37697
+ // Per-branch fetch / pull / push that operate on the cursored
37698
+ // row in the branches sidebar. inkInput.ts dispatches these
37699
+ // when F / U / P fire from the sidebar; the *-current-branch
37700
+ // / fetch-remotes variants above still handle the same keys
37701
+ // from any other context.
37702
+ 'fetch-selected-branch': async () => {
37703
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
37704
+ const visible = state.filter
37705
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
37706
+ : all;
37707
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
37708
+ if (!branch)
37709
+ return { ok: false, message: 'No branch selected' };
37710
+ return fetchBranch(git, branch);
37711
+ },
37712
+ 'pull-selected-branch': async () => {
37713
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
37714
+ const visible = state.filter
37715
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
37716
+ : all;
37717
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
37718
+ if (!branch)
37719
+ return { ok: false, message: 'No branch selected' };
37720
+ return pullBranch(git, branch, context.branches?.currentBranch);
37721
+ },
37722
+ 'push-selected-branch': async () => {
37723
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
37724
+ const visible = state.filter
37725
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
37726
+ : all;
37727
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
37728
+ if (!branch)
37729
+ return { ok: false, message: 'No branch selected' };
37730
+ return pushBranch(git, branch);
37731
+ },
37446
37732
  'rename-branch': async () => {
37447
37733
  const newName = payload?.trim();
37448
37734
  if (!newName)
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.52.0";
81
+ const BUILD_VERSION = "0.53.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -15629,6 +15629,7 @@ Structural rules:
15629
15629
  - Only use hunk IDs LITERALLY copied from the "Staged hunk inventory" section below. Do not invent or guess hunk IDs.
15630
15630
  - If the hunk inventory says "No hunk-level inventory available" then EVERY group's "hunks" array MUST be empty (use only "files"). Do not write hunk IDs like "path::hunk-1" when no hunk inventory exists — those are not valid.
15631
15631
  - Prefer 2-5 commits unless the changes are truly all one topic.
15632
+ - Order the groups in the sequence they would logically be built — foundational changes first, consumers after. If group B uses a symbol, function, type, or file introduced in group A, A MUST appear before B in the array. The applier commits in array order, so this order becomes the git history. Example: a "feat: add helpers" group that introduces \`formatX()\` must come before a "feat: wire helpers into renderer" group that calls \`formatX()\`, even if the staged diff is presented in the opposite order. When two groups have no dependency relationship, prefer the one closer to a "scaffold" (types, config, new files) before the one closer to a "use site" (existing files modified to consume the new code).
15632
15633
 
15633
15634
  Commit message style:
15634
15635
  - Write each "title" in the imperative mood ("add", not "added"), under 72 chars.
@@ -21085,6 +21086,37 @@ function getLogInkWorkflowActions() {
21085
21086
  kind: 'normal',
21086
21087
  requiresConfirmation: false,
21087
21088
  },
21089
+ // Per-view variants of fetch / pull / push that act on the
21090
+ // cursored branch instead of the current one. Empty `key` keeps
21091
+ // them palette-discoverable without registering a global hotkey —
21092
+ // inkInput.ts dispatches them contextually when the user presses
21093
+ // F / U / P while the branches sidebar is focused. Outside that
21094
+ // context, the F / U / P keys still fire the global *-current-*
21095
+ // / fetch-remotes variants above.
21096
+ {
21097
+ id: 'fetch-selected-branch',
21098
+ key: '',
21099
+ label: 'Fetch selected branch',
21100
+ description: 'Run `git fetch <remote> <branch>` for the cursored branch in the branches view / sidebar.',
21101
+ kind: 'normal',
21102
+ requiresConfirmation: false,
21103
+ },
21104
+ {
21105
+ id: 'pull-selected-branch',
21106
+ key: '',
21107
+ label: 'Pull selected branch',
21108
+ description: 'Pull the cursored branch in the branches view / sidebar. Falls back to a fast-forward-only refspec fetch when the branch is not currently checked out; refuses non-FF.',
21109
+ kind: 'normal',
21110
+ requiresConfirmation: false,
21111
+ },
21112
+ {
21113
+ id: 'push-selected-branch',
21114
+ key: '',
21115
+ label: 'Push selected branch',
21116
+ description: 'Run `git push <remote> <branch>` for the cursored branch in the branches view / sidebar.',
21117
+ kind: 'normal',
21118
+ requiresConfirmation: false,
21119
+ },
21088
21120
  {
21089
21121
  // Per-view-only — the inkInput handler scopes this to the tags
21090
21122
  // surface so we don't expose `R` as a remote-delete from elsewhere.
@@ -22025,8 +22057,22 @@ function getLogInkFooterHints(options) {
22025
22057
  // "enter open" hint that drills into the dedicated view.
22026
22058
  const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
22027
22059
  if (itemsPresent && options.sidebarTab === 'branches') {
22060
+ // P / U / F fire the global pull-current-branch, push-current-branch,
22061
+ // fetch-remotes workflows — already implemented, just not visible in
22062
+ // the footer before. Surfacing them here matters because the user's
22063
+ // attention is on a branch when the branches sidebar is focused;
22064
+ // pull / push / fetch are the next obvious actions.
22065
+ //
22066
+ // Note: `U` and `P` currently operate on the CURRENT branch, not the
22067
+ // cursored one. Task #5 will extend them to act on the cursored row;
22068
+ // until then the labels read as "current-branch ops" by virtue of
22069
+ // matching the workflow descriptions.
22028
22070
  return {
22029
- contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
22071
+ contextual: [
22072
+ '↑/↓ branches', '←/→ tab', 'enter checkout',
22073
+ 'F fetch', 'U pull', 'P push',
22074
+ 'D delete', 'R rename', 'u upstream',
22075
+ ],
22030
22076
  global: NORMAL_GLOBAL_HINTS,
22031
22077
  };
22032
22078
  }
@@ -26455,6 +26501,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26455
26501
  events.push({ type: 'createManualCommit' });
26456
26502
  return events;
26457
26503
  }
26504
+ // Context-sensitive per-branch variants of F / U / P. When the
26505
+ // user has the branches sidebar / view focused with at least one
26506
+ // branch, F / U / P should act on the cursored row, not on the
26507
+ // current branch. This intercept fires BEFORE the generic
26508
+ // workflow-by-key lookup below so the global *-current-branch
26509
+ // variants don't shadow the contextual ones.
26510
+ //
26511
+ // Outside the branches context, the generic lookup runs and the
26512
+ // F / U / P keys hit the global `fetch-remotes` / `pull-current-branch`
26513
+ // / `push-current-branch` workflows as before.
26514
+ if (isBranchActionTarget(state) && context.branchCount) {
26515
+ if (inputValue === 'F') {
26516
+ return [{ type: 'runWorkflowAction', id: 'fetch-selected-branch' }];
26517
+ }
26518
+ if (inputValue === 'U') {
26519
+ return [{ type: 'runWorkflowAction', id: 'pull-selected-branch' }];
26520
+ }
26521
+ if (inputValue === 'P') {
26522
+ return [{ type: 'runWorkflowAction', id: 'push-selected-branch' }];
26523
+ }
26524
+ }
26458
26525
  const workflowAction = getLogInkWorkflowActionByKey(inputValue);
26459
26526
  if (workflowAction?.requiresConfirmation) {
26460
26527
  return [action({ type: 'setPendingConfirmation', value: workflowAction.id })];
@@ -27557,6 +27624,106 @@ function pushCurrentBranch(git) {
27557
27624
  function setUpstream(git, localBranch, upstreamBranch) {
27558
27625
  return runAction$5(() => git.raw(['branch', '--set-upstream-to', upstreamBranch, localBranch]), `Set ${localBranch} upstream to ${upstreamBranch}`);
27559
27626
  }
27627
+ /**
27628
+ * Push an arbitrary local branch (need not be the current branch) to
27629
+ * its remote. Refuses when the branch has no upstream and no remote
27630
+ * defaulting is configured — that branch needs a `git push -u …` from
27631
+ * the shell first.
27632
+ *
27633
+ * Pairs with `pushCurrentBranch` (no-arg variant); the workstation
27634
+ * dispatcher picks one or the other based on where the cursor is.
27635
+ */
27636
+ function pushBranch(git, branch) {
27637
+ if (branch.type !== 'local') {
27638
+ return Promise.resolve({
27639
+ ok: false,
27640
+ message: 'Only local branches can be pushed.',
27641
+ });
27642
+ }
27643
+ if (!branch.upstream || !branch.remote) {
27644
+ return Promise.resolve({
27645
+ ok: false,
27646
+ message: `${branch.shortName} has no upstream — checkout the branch and run \`git push -u <remote> ${branch.shortName}\` first.`,
27647
+ });
27648
+ }
27649
+ return runAction$5(() => git.raw(['push', branch.remote, branch.shortName]), `Pushed ${branch.shortName} to ${branch.upstream}`);
27650
+ }
27651
+ /**
27652
+ * Fetch the cursored branch's upstream from its remote. Side-effect
27653
+ * free on the working tree — just updates the remote-tracking ref.
27654
+ * Works for any branch with an upstream regardless of checkout state.
27655
+ *
27656
+ * Falls back to a clean error when the branch has no upstream
27657
+ * configured (`git fetch <remote> <name>` would assume an unrelated
27658
+ * default refspec and surprise the user).
27659
+ */
27660
+ function fetchBranch(git, branch) {
27661
+ if (branch.type !== 'local') {
27662
+ return Promise.resolve({
27663
+ ok: false,
27664
+ message: 'Only local branches can be fetched per-branch — use F to fetch all remotes.',
27665
+ });
27666
+ }
27667
+ if (!branch.upstream || !branch.remote) {
27668
+ return Promise.resolve({
27669
+ ok: false,
27670
+ message: `${branch.shortName} has no upstream — nothing to fetch.`,
27671
+ });
27672
+ }
27673
+ // `branch.upstream` is the short form (e.g. `origin/main`); the
27674
+ // ref name after the remote prefix is what fetch wants as the
27675
+ // refspec source. For a remote `origin` and upstream `origin/main`
27676
+ // we run `git fetch origin main`.
27677
+ const upstreamRef = branch.upstream.startsWith(`${branch.remote}/`)
27678
+ ? branch.upstream.slice(branch.remote.length + 1)
27679
+ : branch.upstream;
27680
+ return runAction$5(() => git.raw(['fetch', branch.remote, upstreamRef]), `Fetched ${branch.upstream}`);
27681
+ }
27682
+ /**
27683
+ * Pull the cursored branch. Branches into two paths based on whether
27684
+ * the branch is currently checked out:
27685
+ *
27686
+ * - **Current branch**: defer to `pullCurrentBranch` (standard
27687
+ * `git pull --ff-only`).
27688
+ * - **Non-current branch**: use the refspec form
27689
+ * `git fetch <remote> <branch>:<branch>` which advances the local
27690
+ * ref to match the remote ref ONLY if the update is fast-forward.
27691
+ * Returns non-zero on non-FF without touching the working tree.
27692
+ * Diverged branches need a checkout + `pull --rebase` from the
27693
+ * user; we refuse rather than try to do that for them.
27694
+ *
27695
+ * `currentBranchName` lets the dispatcher compare without re-querying
27696
+ * git — it already has the value in `context.branches.currentBranch`.
27697
+ */
27698
+ function pullBranch(git, branch, currentBranchName) {
27699
+ if (branch.type !== 'local') {
27700
+ return Promise.resolve({
27701
+ ok: false,
27702
+ message: 'Only local branches can be pulled.',
27703
+ });
27704
+ }
27705
+ if (!branch.upstream || !branch.remote) {
27706
+ return Promise.resolve({
27707
+ ok: false,
27708
+ message: `${branch.shortName} has no upstream — nothing to pull.`,
27709
+ });
27710
+ }
27711
+ // Current branch — defer to the in-place workflow.
27712
+ if (branch.shortName === currentBranchName) {
27713
+ return pullCurrentBranch(git);
27714
+ }
27715
+ // Non-current branch — refspec-based fast-forward refusing non-FF.
27716
+ // `branch.upstream` is `<remote>/<ref>`; strip the remote prefix to
27717
+ // get the upstream ref name to fetch.
27718
+ const upstreamRef = branch.upstream.startsWith(`${branch.remote}/`)
27719
+ ? branch.upstream.slice(branch.remote.length + 1)
27720
+ : branch.upstream;
27721
+ return runAction$5(() => git.raw([
27722
+ 'fetch',
27723
+ branch.remote,
27724
+ `${upstreamRef}:${branch.shortName}`,
27725
+ ]), `Fast-forwarded ${branch.shortName} to ${branch.upstream}`);
27726
+ }
27560
27727
 
27561
27728
  async function runAction$4(action, successMessage) {
27562
27729
  try {
@@ -29080,29 +29247,81 @@ function formatBranchDivergence(branch, options = {}) {
29080
29247
  parts.push(`↓${branch.behind}`);
29081
29248
  return `${parts.join(' ')} ${branch.upstream}`;
29082
29249
  }
29083
- /**
29084
- * Single-cell marker shown to the left of a branch name in lists.
29085
- *
29086
- * - `*` — current branch (regardless of remote state)
29087
- * - `◌` no upstream
29088
- * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
29089
- * - `↕` has upstream + diverged (any non-zero ahead/behind)
29090
- * - ` ` fallback / no info
29091
- *
29092
- * ASCII fallbacks (legible without box-drawing/arrow glyphs):
29093
- * - `?` for "no upstream", `=` for synced, `~` for diverged.
29094
- */
29250
+ function formatUpstreamAheadBanner(branch, options = {}) {
29251
+ if (!branch?.upstream || branch.behind <= 0) {
29252
+ return undefined;
29253
+ }
29254
+ const sep = options.ascii ? '.' : '·';
29255
+ if (branch.ahead > 0) {
29256
+ // Diverged local has work too, fast-forward pull is impossible.
29257
+ // Suggest pull --rebase as the cleaner-history default; users who
29258
+ // prefer merge can do that themselves.
29259
+ const symbols = options.ascii
29260
+ ? `+${branch.ahead} -${branch.behind}`
29261
+ : `↑${branch.ahead} ↓${branch.behind}`;
29262
+ return `${symbols} diverged from ${branch.upstream} ${sep} F fetch ${sep} U pull --rebase`;
29263
+ }
29264
+ // Behind-only — fast-forward pull works.
29265
+ const arrow = options.ascii ? 'v' : '↓';
29266
+ const noun = branch.behind === 1 ? 'commit' : 'commits';
29267
+ return `${arrow} ${branch.behind} ${noun} behind ${branch.upstream} ${sep} F fetch ${sep} U pull`;
29268
+ }
29095
29269
  function branchRowMarker(branch, options = {}) {
29096
- if (branch.current)
29097
- return '*';
29098
- if (!branch.upstream)
29099
- return options.ascii ? '?' : '◌';
29270
+ if (branch.current) {
29271
+ return { glyph: '*', kind: 'head' };
29272
+ }
29273
+ if (!branch.upstream) {
29274
+ return { glyph: options.ascii ? '?' : '◌', kind: 'no-upstream' };
29275
+ }
29100
29276
  const ahead = branch.ahead ?? 0;
29101
29277
  const behind = branch.behind ?? 0;
29102
29278
  if (ahead === 0 && behind === 0) {
29103
- return options.ascii ? '=' : '≡';
29279
+ return { glyph: options.ascii ? '=' : '≡', kind: 'synced' };
29280
+ }
29281
+ if (ahead > 0 && behind > 0) {
29282
+ return { glyph: options.ascii ? '~' : '⇅', kind: 'diverged' };
29283
+ }
29284
+ if (behind > 0) {
29285
+ return { glyph: options.ascii ? 'v' : '↓', kind: 'behind' };
29286
+ }
29287
+ // ahead > 0 (the only remaining case after the guards above)
29288
+ return { glyph: options.ascii ? '^' : '↑', kind: 'ahead' };
29289
+ }
29290
+ /**
29291
+ * Theme-aware colour picker for a `BranchRowMarker.kind`.
29292
+ *
29293
+ * Reuses the existing chip / banner colour semantic so the workstation
29294
+ * speaks one visual language across history (chips, "behind upstream"
29295
+ * banner) and the branches list:
29296
+ *
29297
+ * - `head` → success green (matches HEAD chip)
29298
+ * - `behind` → warning yellow (matches "behind upstream" banner)
29299
+ * - `diverged` → warning yellow (same: action needed inbound)
29300
+ * - `ahead` → info blue (you have work to push)
29301
+ * - `synced` → undefined (neutral; inherit row's existing dim)
29302
+ * - `no-upstream` → undefined (neutral; same)
29303
+ *
29304
+ * Returns `undefined` under `noColor` / `ascii` for the muted cases so
29305
+ * the row renderer skips the colour wrap entirely; the glyph alone
29306
+ * carries the meaning.
29307
+ */
29308
+ function getBranchRowMarkerColor(kind, theme) {
29309
+ if (theme.noColor)
29310
+ return undefined;
29311
+ switch (kind) {
29312
+ case 'head':
29313
+ return theme.colors.success;
29314
+ case 'behind':
29315
+ case 'diverged':
29316
+ return theme.colors.warning;
29317
+ case 'ahead':
29318
+ return theme.colors.info;
29319
+ case 'synced':
29320
+ case 'no-upstream':
29321
+ return undefined;
29322
+ default:
29323
+ return undefined;
29104
29324
  }
29105
- return options.ascii ? '~' : '↕';
29106
29325
  }
29107
29326
  /**
29108
29327
  * Compact, human-friendly relative timestamp for the branch row.
@@ -29710,7 +29929,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
29710
29929
  ];
29711
29930
  return [
29712
29931
  ...headerRows,
29713
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
29932
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii }).glyph} ${branch.shortName}`, 'tab-branches', visibleListCount),
29714
29933
  ];
29715
29934
  }
29716
29935
  if (tab === 'tags') {
@@ -30173,21 +30392,22 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
30173
30392
  const isSelected = index === selected;
30174
30393
  const cursor = isSelected ? '>' : ' ';
30175
30394
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
30395
+ const markerColor = getBranchRowMarkerColor(marker.kind, theme);
30176
30396
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
30177
30397
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
30178
30398
  // Split the row into spans so the timestamp stays dim even on the
30179
- // currently-selected (bold) row. The leading marker + name keep
30180
- // their per-window-derived column widths; the timestamp is
30181
- // right-padded so the divergence column stays aligned across rows.
30399
+ // currently-selected (bold) row, and the sync-state marker keeps
30400
+ // its own colour even when the surrounding row text is dimmed.
30182
30401
  const namePadded = truncateCells(branch.shortName, nameColWidth).padEnd(nameColWidth);
30183
30402
  const timestampPadded = lastTouched.padEnd(8);
30184
30403
  const lineDim = !isSelected && !branch.current;
30185
- const head = `${cursor} ${marker} ${namePadded} `;
30404
+ const cursorAndPad = `${cursor} `;
30405
+ const trailingName = ` ${namePadded} `;
30186
30406
  const trailingDivergence = divergence ? ` ${divergence}` : '';
30187
30407
  // Truncate the assembled line to the actual panel width so a
30188
30408
  // narrow inspector / sidebar focus doesn't push branch rows
30189
30409
  // onto a second visual line (#830).
30190
- const fullText = `${head}${timestampPadded}${trailingDivergence}`;
30410
+ const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
30191
30411
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
30192
30412
  // If truncation chopped into the timestamp/divergence portion,
30193
30413
  // fall back to a single Text to keep the visible width honest.
@@ -30202,7 +30422,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
30202
30422
  key: `branch-${index}`,
30203
30423
  bold: isSelected,
30204
30424
  dimColor: lineDim,
30205
- }, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
30425
+ }, cursorAndPad,
30426
+ // The marker carries the sync-state colour; an explicit
30427
+ // `dimColor: false` on this span keeps the colour bright even
30428
+ // when the surrounding row is dim (other branches in the list
30429
+ // dim out under the existing `lineDim` rule). The synced /
30430
+ // no-upstream kinds return undefined from
30431
+ // `getBranchRowMarkerColor`, so those markers inherit the
30432
+ // row's dim and read as quiet chrome.
30433
+ h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
30206
30434
  });
30207
30435
  return h(Box, {
30208
30436
  borderColor: focusBorderColor(theme, focused),
@@ -32214,6 +32442,25 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
32214
32442
  paddingX: 1,
32215
32443
  width,
32216
32444
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${title} | ${graphMode} | ${loadState}`)),
32445
+ // Upstream-ahead banner. Surfaces "the remote has work you don't"
32446
+ // for the current branch — distinct from the chip work in 0.52.0
32447
+ // which colours remote refs IN the row set. On a behind branch the
32448
+ // upstream commits aren't reachable from local HEAD, so the chips
32449
+ // alone can't signal "fetch / pull needed." This single line does.
32450
+ //
32451
+ // Two wording variants (behind-only vs diverged) live in the
32452
+ // helper; render is identical aside from the formatted string.
32453
+ // Warning yellow = same semantic as the remote-tracking chip kind.
32454
+ ...((() => {
32455
+ const currentBranchRef = context.branches?.localBranches.find((branch) => branch.current);
32456
+ const banner = formatUpstreamAheadBanner(currentBranchRef, { ascii: theme.ascii });
32457
+ if (!banner)
32458
+ return [];
32459
+ return [h(Text, {
32460
+ key: 'upstream-ahead-banner',
32461
+ color: theme.noColor ? undefined : theme.colors.warning,
32462
+ }, banner)];
32463
+ })()),
32217
32464
  // Server-side filter indicator (#776). Only rendered when the user
32218
32465
  // has an active path:/author: prefix; clears when they Ctrl+U.
32219
32466
  ...(state.historyFetchArgs
@@ -36894,16 +37141,20 @@ function LogInkApp(deps) {
36894
37141
  return;
36895
37142
  }
36896
37143
  // Success — close the overlay, reset compose (the staged set is
36897
- // now empty since the plan committed everything), and pop the
36898
- // compose view so the user lands on whatever was beneath (usually
36899
- // status, sometimes history).
37144
+ // now empty since the plan committed everything), and route the
37145
+ // user to the history view so they see the just-landed commits
37146
+ // with the recent-commit marker firing on each row that was
37147
+ // created. Previous behavior popped compose to whatever was
37148
+ // beneath (often status — which now reads "clean worktree" and
37149
+ // gives the user no signal that anything just happened);
37150
+ // history is the natural follow-on surface.
37151
+ //
37152
+ // navigateHome nukes the rest of the stack so `<` after apply
37153
+ // doesn't walk back into the now-empty compose / status state
37154
+ // the user just left behind.
36900
37155
  dispatch({ type: 'clearSplitPlan' });
36901
37156
  dispatch({ type: 'commitCompose', action: { type: 'reset' } });
36902
- // Only pop if compose is on top — the apply could have been
36903
- // invoked from a deeper stack and we don't want to over-pop.
36904
- if (state.activeView === 'compose' && state.viewStack.length > 1) {
36905
- dispatch({ type: 'popView' });
36906
- }
37157
+ dispatch({ type: 'navigateHome' });
36907
37158
  // Refresh BEFORE setting the final status so we can peek at the
36908
37159
  // post-apply worktree state and craft a directive next-step hint
36909
37160
  // ("X unstaged + Y untracked remaining — press gs to stage / I
@@ -36953,7 +37204,7 @@ function LogInkApp(deps) {
36953
37204
  }
36954
37205
  const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
36955
37206
  dispatch({ type: 'setStatus', value: successMessage, kind: 'success' });
36956
- }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.activeView, state.splitPlan, state.viewStack.length]);
37207
+ }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
36957
37208
  // Esc inside the overlay — close without applying. Status line gets
36958
37209
  // a confirmation so the user knows the operation was abandoned.
36959
37210
  const cancelCommitSplit = React.useCallback(() => {
@@ -37460,6 +37711,41 @@ function LogInkApp(deps) {
37460
37711
  'fetch-remotes': async () => fetchRemotes(git),
37461
37712
  'pull-current-branch': async () => pullCurrentBranch(git),
37462
37713
  'push-current-branch': async () => pushCurrentBranch(git),
37714
+ // Per-branch fetch / pull / push that operate on the cursored
37715
+ // row in the branches sidebar. inkInput.ts dispatches these
37716
+ // when F / U / P fire from the sidebar; the *-current-branch
37717
+ // / fetch-remotes variants above still handle the same keys
37718
+ // from any other context.
37719
+ 'fetch-selected-branch': async () => {
37720
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
37721
+ const visible = state.filter
37722
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
37723
+ : all;
37724
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
37725
+ if (!branch)
37726
+ return { ok: false, message: 'No branch selected' };
37727
+ return fetchBranch(git, branch);
37728
+ },
37729
+ 'pull-selected-branch': async () => {
37730
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
37731
+ const visible = state.filter
37732
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
37733
+ : all;
37734
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
37735
+ if (!branch)
37736
+ return { ok: false, message: 'No branch selected' };
37737
+ return pullBranch(git, branch, context.branches?.currentBranch);
37738
+ },
37739
+ 'push-selected-branch': async () => {
37740
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
37741
+ const visible = state.filter
37742
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
37743
+ : all;
37744
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
37745
+ if (!branch)
37746
+ return { ok: false, message: 'No branch selected' };
37747
+ return pushBranch(git, branch);
37748
+ },
37463
37749
  'rename-branch': async () => {
37464
37750
  const newName = payload?.trim();
37465
37751
  if (!newName)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "test:publish": "npm run lint && npm run build && npm run test:cli && npm pack --dry-run",
42
42
  "test:cli": "tsx bin/smokeCli.ts",
43
43
  "bench": "tsx bin/benchmark.ts",
44
- "scenario": "git-scenarios",
44
+ "scenario": "tsx bin/scenarioRunner.ts",
45
45
  "eval:structural-extract": "tsx bin/structuralExtractEval.ts",
46
46
  "pretest:jest": "npm run build:info && node bin/copyTreeSitterWasm.mjs",
47
47
  "test:jest": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@commitlint/types": "^20.5.0",
64
- "@gfargo/git-scenarios": "^0.3.0",
64
+ "@gfargo/git-scenarios": "^0.3.3",
65
65
  "@rollup/plugin-commonjs": "^29.0.0",
66
66
  "@rollup/plugin-eslint": "^9.0.5",
67
67
  "@rollup/plugin-json": "^6.0.0",