git-coco 0.51.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.
Files changed (3) hide show
  1. package/dist/index.esm.mjs +1200 -103
  2. package/dist/index.js +1200 -103
  3. package/package.json +3 -2
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.51.0";
81
+ const BUILD_VERSION = "0.53.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -8229,9 +8229,40 @@ async function getCommitLogAgainstTag({ git, logger, targetTag, }) {
8229
8229
  return [];
8230
8230
  }
8231
8231
 
8232
+ async function refExists(git, ref) {
8233
+ try {
8234
+ // `--verify --quiet` suppresses stderr noise on a missing ref and
8235
+ // emits the resolved sha on stdout when it exists. simple-git
8236
+ // returns an empty string (rather than throwing) when git exits
8237
+ // 1 under `--quiet`, so the presence/absence check is on the
8238
+ // output, not on whether the call rejected.
8239
+ const out = await git.raw(['rev-parse', '--verify', '--quiet', ref]);
8240
+ return out.trim().length > 0;
8241
+ }
8242
+ catch {
8243
+ return false;
8244
+ }
8245
+ }
8232
8246
  /**
8233
8247
  * Retrieves the commit log for the current branch.
8234
8248
  *
8249
+ * Edge states that are not errors and should not be reported as such:
8250
+ *
8251
+ * - Detached HEAD (including mid-rebase and mid-bisect, which both
8252
+ * leave HEAD detached). There is no "current branch" to compare
8253
+ * against; the helper logs a yellow status line and returns [].
8254
+ * - Comparison ref missing — e.g. the repo has no `origin` remote,
8255
+ * so `origin/main` does not resolve; or the local comparison
8256
+ * branch (`main`) simply does not exist. Previously this threw
8257
+ * and surfaced as a red "Encountered an error" banner. Now we
8258
+ * probe the ref up front and report a clean status line.
8259
+ * - Empty rev-list output. The previous yellow "Unable to determine
8260
+ * first and last commit" wording read like an error; it's just
8261
+ * "no commits ahead of the comparison ref", which is the normal
8262
+ * outcome when the branch is at or behind its baseline.
8263
+ *
8264
+ * The catch block is reserved for genuinely unexpected git failures.
8265
+ *
8235
8266
  * @param {Object} options - The options for retrieving the commit log.
8236
8267
  * @param {SimpleGit} options.git - The SimpleGit instance.
8237
8268
  * @param {Logger} options.logger - The logger for logging messages.
@@ -8240,43 +8271,53 @@ async function getCommitLogAgainstTag({ git, logger, targetTag, }) {
8240
8271
  * @returns {Promise<CommitDetails[]>} The array of commit messages in the commit log.
8241
8272
  */
8242
8273
  async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main', comparisonRemote = 'origin', }) {
8274
+ const branchName = await getCurrentBranchName({ git });
8275
+ // Detached HEAD: `git rev-parse --abbrev-ref HEAD` returns the literal
8276
+ // string 'HEAD' in this state. Also covers mid-rebase and mid-bisect,
8277
+ // which both detach HEAD onto the picked / midpoint commit. There's
8278
+ // no branch to compare against, so don't pretend there was an error.
8279
+ if (!branchName || branchName === 'HEAD') {
8280
+ logger?.log('HEAD is detached (or a rebase / bisect is in progress) — no branch context to compare against.', { color: 'yellow' });
8281
+ return [];
8282
+ }
8243
8283
  try {
8244
- const branchName = await getCurrentBranchName({ git });
8245
- const hasCommits = (await git.raw(['rev-list', '--count', branchName])) !== '0';
8246
- if (!hasCommits) {
8247
- logger?.log('No commits on the current branch.');
8248
- return [];
8249
- }
8250
- let uniqueCommits;
8284
+ let comparisonRef;
8251
8285
  if (comparisonBranch === branchName) {
8252
- // If the comparison branch is the same as the current branch, we compare against the remote.
8253
- uniqueCommits = (await git.raw(['rev-list', `${comparisonRemote}/${comparisonBranch}..${branchName}`]))
8254
- .split('\n')
8255
- .filter(Boolean)
8256
- .reverse();
8286
+ // Same branch as the comparison target compare against the
8287
+ // remote-tracking ref. If the remote (or the ref) does not
8288
+ // exist, fall back to a clean status line rather than throwing.
8289
+ const remoteRef = `${comparisonRemote}/${comparisonBranch}`;
8290
+ if (!(await refExists(git, remoteRef))) {
8291
+ logger?.log(`No "${remoteRef}" ref to compare against — skipping changelog for "${branchName}".`, { color: 'yellow' });
8292
+ return [];
8293
+ }
8294
+ comparisonRef = remoteRef;
8257
8295
  }
8258
8296
  else {
8259
- // Your existing code for different branches
8260
- uniqueCommits = (await git.raw(['rev-list', `${comparisonBranch}..${branchName}`]))
8261
- .split('\n')
8262
- .filter(Boolean)
8263
- .reverse();
8297
+ if (!(await refExists(git, comparisonBranch))) {
8298
+ logger?.log(`Comparison branch "${comparisonBranch}" does not exist — skipping changelog for "${branchName}".`, { color: 'yellow' });
8299
+ return [];
8300
+ }
8301
+ comparisonRef = comparisonBranch;
8264
8302
  }
8265
- logger?.verbose(`Found ${uniqueCommits.length} unique commits on "${branchName}"`, {
8266
- color: 'blue',
8267
- });
8303
+ const uniqueCommits = (await git.raw(['rev-list', `${comparisonRef}..${branchName}`]))
8304
+ .split('\n')
8305
+ .filter(Boolean)
8306
+ .reverse();
8307
+ logger?.verbose(`Found ${uniqueCommits.length} unique commits on "${branchName}" vs "${comparisonRef}"`, { color: 'blue' });
8268
8308
  const firstCommit = uniqueCommits[0];
8269
8309
  const lastCommit = uniqueCommits[uniqueCommits.length - 1];
8270
8310
  if (!firstCommit || !lastCommit) {
8271
- logger?.log('Unable to determine first and last commit on the current branch', {
8272
- color: 'yellow',
8273
- });
8311
+ // Empty rev-list output is the normal outcome when the branch is
8312
+ // at or behind its baseline. Not an error.
8313
+ logger?.log(`No commits on "${branchName}" ahead of "${comparisonRef}".`);
8274
8314
  return [];
8275
8315
  }
8276
8316
  return await getCommitLogRangeDetails(firstCommit, lastCommit, { git, noMerges: true });
8277
8317
  }
8278
8318
  catch (error) {
8279
8319
  logger?.log('Encountered an error getting commit log from current branch', { color: 'red' });
8320
+ logger?.verbose(error instanceof Error ? error.message : String(error), { color: 'red' });
8280
8321
  }
8281
8322
  return [];
8282
8323
  }
@@ -14344,7 +14385,13 @@ const handler$9 = async (argv, logger) => {
14344
14385
  return `## Diff for ${data.branch}\n\n${diffSummary}`;
14345
14386
  }
14346
14387
  if (!data.commits || data.commits.length === 0) {
14347
- return `## ${data.branch}\n\nNo commits found.`;
14388
+ // Short-circuit with an empty context so the review loop drops
14389
+ // into `noResult` instead of spending an LLM call summarising
14390
+ // "No commits found." into a fake changelog entry. The
14391
+ // upstream helper (getCommitLogCurrentBranch) already logged
14392
+ // the reason (detached HEAD, missing comparison ref, branch at
14393
+ // baseline, etc.) in a friendly status line.
14394
+ return '';
14348
14395
  }
14349
14396
  let result = `## ${data.branch}\n\n`;
14350
14397
  result += data.commits.map(commit => {
@@ -14421,10 +14468,15 @@ const handler$9 = async (argv, logger) => {
14421
14468
  },
14422
14469
  noResult: async () => {
14423
14470
  if (config.range) {
14424
- logger.log(`No commits found in the provided range.`, { color: 'red' });
14471
+ logger.log(`No commits found in the provided range.`, { color: 'yellow' });
14425
14472
  commandExit(0);
14426
14473
  }
14427
- logger.log(`No commits found in the current branch.`, { color: 'red' });
14474
+ // Yellow rather than red — for the no-commits-on-current-branch
14475
+ // case the upstream helper has already explained the reason in
14476
+ // a friendly status line (detached HEAD, no comparison ref,
14477
+ // branch at baseline). This is the trailing summary, not an
14478
+ // error.
14479
+ logger.log(`No commits found in the current branch.`, { color: 'yellow' });
14428
14480
  commandExit(0);
14429
14481
  },
14430
14482
  });
@@ -15577,6 +15629,7 @@ Structural rules:
15577
15629
  - Only use hunk IDs LITERALLY copied from the "Staged hunk inventory" section below. Do not invent or guess hunk IDs.
15578
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.
15579
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).
15580
15633
 
15581
15634
  Commit message style:
15582
15635
  - Write each "title" in the imperative mood ("add", not "added"), under 72 chars.
@@ -17909,6 +17962,38 @@ const builder$4 = (yargs) => {
17909
17962
  return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
17910
17963
  };
17911
17964
 
17965
+ /**
17966
+ * Detect whether the repository has any commits yet.
17967
+ *
17968
+ * A "fresh" repo (one created by `git init` with no commits) has an
17969
+ * **unborn HEAD** — the `main` (or configured-default) branch ref
17970
+ * exists symbolically but doesn't point at any object. Any plumbing
17971
+ * command that tries to resolve HEAD (`git log`, `git show`, `git
17972
+ * rev-list`) fails fatally on such a repo with `fatal: your current
17973
+ * branch '<X>' does not have any commits yet`.
17974
+ *
17975
+ * Without an explicit pre-check, callers crash with that raw error
17976
+ * (see {@link ../utils/commandExecutor} — the generic-error path
17977
+ * just prints whatever was thrown). This helper lets a command
17978
+ * short-circuit to a friendly "no commits yet" message instead.
17979
+ *
17980
+ * Implementation uses `git rev-parse --verify HEAD` because it's the
17981
+ * cheapest "does HEAD resolve?" probe — no log walk, no working-tree
17982
+ * scan. Returns `true` when rev-parse rejects (unborn HEAD) and
17983
+ * `false` when it succeeds.
17984
+ *
17985
+ * @returns `true` when HEAD is unborn (no commits); `false` otherwise.
17986
+ */
17987
+ async function isEmptyRepo(git) {
17988
+ try {
17989
+ await git.revparse(['--verify', 'HEAD']);
17990
+ return false;
17991
+ }
17992
+ catch {
17993
+ return true;
17994
+ }
17995
+ }
17996
+
17912
17997
  /**
17913
17998
  * Git LFS pointer parsing + diff summarization (#884).
17914
17999
  *
@@ -18327,6 +18412,19 @@ function buildToggleGraphArgs(argv, fullGraph) {
18327
18412
  return { ...argv, view: argv.view ?? 'compact' };
18328
18413
  }
18329
18414
  async function getLogRows(git, argv, options = {}) {
18415
+ // Unborn HEAD short-circuit. Without this, `git log` on a freshly
18416
+ // `git init`'d repo throws "fatal: your current branch 'main' does
18417
+ // not have any commits yet" — fine when the caller can catch and
18418
+ // translate, painful otherwise (the workstation runtime surfaces it
18419
+ // as "Failed to load commits: fatal: ..." in the status line).
18420
+ //
18421
+ // Returning [] is the natural contract: callers that already render
18422
+ // an empty-history surface (`formatLogInkHistoryEmpty`) get the
18423
+ // right experience automatically; `coco log` retains its own
18424
+ // friendlier message via the handler's isEmptyRepo check.
18425
+ if (await isEmptyRepo(git)) {
18426
+ return [];
18427
+ }
18330
18428
  return parseLogOutput(await git.raw(buildLogArgs(argv, options)));
18331
18429
  }
18332
18430
  async function getCommitDetail(git, commit) {
@@ -18398,6 +18496,7 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
18398
18496
  deletions: file.deletions,
18399
18497
  },
18400
18498
  hunks: finalHunks,
18499
+ submoduleChange,
18401
18500
  };
18402
18501
  }
18403
18502
 
@@ -20987,6 +21086,37 @@ function getLogInkWorkflowActions() {
20987
21086
  kind: 'normal',
20988
21087
  requiresConfirmation: false,
20989
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
+ },
20990
21120
  {
20991
21121
  // Per-view-only — the inkInput handler scopes this to the tags
20992
21122
  // surface so we don't expose `R` as a remote-delete from elsewhere.
@@ -21810,6 +21940,56 @@ function formatLogInkBreadcrumb(viewStack) {
21810
21940
  // they're nested deeper than the root view.
21811
21941
  return `${viewStack.join(' › ')} ← <`;
21812
21942
  }
21943
+ /**
21944
+ * Render the nested-repo navigation stack (#931) as a breadcrumb suitable
21945
+ * for the chrome header. Returns an empty string for a root-only stack
21946
+ * so the header stays compact when nothing has been pushed.
21947
+ *
21948
+ * The trailing `← esc` reminds the user that Esc is the way out — same
21949
+ * shape as the view breadcrumb's `← <` so the two read consistently.
21950
+ * The repo breadcrumb shows in addition to the view breadcrumb when
21951
+ * both stacks are non-trivial; the chrome layer is responsible for
21952
+ * laying them out side by side.
21953
+ *
21954
+ * Examples:
21955
+ * `[root]` → ''
21956
+ * `[coco, vendor/lib]` → 'coco › vendor/lib ← esc'
21957
+ * `[coco, vendor/lib, deep]` → 'coco › vendor/lib › deep ← esc'
21958
+ */
21959
+ function formatLogInkRepoBreadcrumb(repoStack) {
21960
+ if (repoStack.length <= 1) {
21961
+ return '';
21962
+ }
21963
+ return `${repoStack.map((frame) => frame.label).join(' › ')} ← esc`;
21964
+ }
21965
+ /**
21966
+ * Combine the repo-stack and view-stack breadcrumb segments for the
21967
+ * header chrome (#931). Each segment is independently rendered by its
21968
+ * formatter and may be empty; this helper interleaves the leading
21969
+ * spacing so the header builder doesn't have to branch on four cases.
21970
+ *
21971
+ * repoCrumb='' viewCrumb='' → ''
21972
+ * repoCrumb='X' viewCrumb='' → ' X'
21973
+ * repoCrumb='' viewCrumb='Y' → ' Y'
21974
+ * repoCrumb='X' viewCrumb='Y' → ' X Y'
21975
+ *
21976
+ * Two leading spaces match the existing chrome — they separate the
21977
+ * breadcrumb from the trailing repo/branch segment in the title row.
21978
+ * Four spaces between segments give the repo crumb visual breathing
21979
+ * room before the view crumb begins.
21980
+ */
21981
+ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
21982
+ if (repoCrumb && viewCrumb) {
21983
+ return ` ${repoCrumb} ${viewCrumb}`;
21984
+ }
21985
+ if (repoCrumb) {
21986
+ return ` ${repoCrumb}`;
21987
+ }
21988
+ if (viewCrumb) {
21989
+ return ` ${viewCrumb}`;
21990
+ }
21991
+ return '';
21992
+ }
21813
21993
  function getLogInkFooterHints(options) {
21814
21994
  if (options.pendingKey) {
21815
21995
  const continuations = getLogInkChordContinuations(options.pendingKey);
@@ -21877,8 +22057,22 @@ function getLogInkFooterHints(options) {
21877
22057
  // "enter open" hint that drills into the dedicated view.
21878
22058
  const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
21879
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.
21880
22070
  return {
21881
- 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
+ ],
21882
22076
  global: NORMAL_GLOBAL_HINTS,
21883
22077
  };
21884
22078
  }
@@ -22564,6 +22758,93 @@ function withPoppedView(state) {
22564
22758
  pendingKey: undefined,
22565
22759
  };
22566
22760
  }
22761
+ /**
22762
+ * Push a nested-repo frame onto `state.repoStack` (#931). Snapshots
22763
+ * the active view position into the new frame's `parentReturn` so a
22764
+ * subsequent pop lands the user back where they came from, then
22765
+ * resets the per-frame navigation state (active view, view stack,
22766
+ * row / file / submodule cursors, filter) so the nested frame opens
22767
+ * in a clean slate — the mental equivalent of a fresh `coco ui`
22768
+ * launched against the submodule's working dir.
22769
+ *
22770
+ * Carry-over preferences (sidebar tab, branch / tag sort, palette
22771
+ * recents, inspector tab, diff view mode) are intentionally left
22772
+ * untouched. They're user-level choices that should persist across
22773
+ * frames, the same way they persist across view pushes today.
22774
+ *
22775
+ * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
22776
+ * outside the reducer in `app.ts`'s parallel ref structure — this
22777
+ * helper only manages the pure view-model side of the push.
22778
+ */
22779
+ function withPushedRepoFrame(state, payload) {
22780
+ const newFrame = {
22781
+ label: payload.label,
22782
+ workdir: payload.workdir,
22783
+ entryRange: payload.entryRange,
22784
+ parentReturn: {
22785
+ activeView: state.activeView,
22786
+ selectedIndex: state.selectedIndex,
22787
+ selectedFileIndex: state.selectedFileIndex,
22788
+ selectedSubmoduleIndex: state.selectedSubmoduleIndex,
22789
+ filter: state.filter,
22790
+ },
22791
+ };
22792
+ return {
22793
+ ...state,
22794
+ repoStack: [...state.repoStack, newFrame],
22795
+ activeView: 'history',
22796
+ viewStack: ['history'],
22797
+ selectedIndex: 0,
22798
+ selectedFileIndex: 0,
22799
+ selectedSubmoduleIndex: 0,
22800
+ filter: '',
22801
+ filterMode: false,
22802
+ pendingCommitFocused: false,
22803
+ pendingKey: undefined,
22804
+ pendingConfirmationId: undefined,
22805
+ pendingConfirmationPayload: undefined,
22806
+ pendingMutationConfirmation: undefined,
22807
+ };
22808
+ }
22809
+ /**
22810
+ * Pop the top repo frame off `state.repoStack` (#931) and restore
22811
+ * the parent's view position from the captured `parentReturn`. A
22812
+ * no-op when the stack is already at its single root frame so this
22813
+ * action is safe to dispatch from generic input handlers (e.g. the
22814
+ * Esc auto-pop wiring that lands in a follow-up PR).
22815
+ *
22816
+ * The defensive `parentReturn` fallback handles the never-supposed-
22817
+ * to-happen case where a non-root frame somehow has no return state
22818
+ * recorded — drop the frame but leave the user's view position
22819
+ * alone rather than crash mid-session.
22820
+ */
22821
+ function withPoppedRepoFrame(state) {
22822
+ if (state.repoStack.length <= 1) {
22823
+ return { ...state, pendingKey: undefined };
22824
+ }
22825
+ const topFrame = state.repoStack[state.repoStack.length - 1];
22826
+ const ret = topFrame.parentReturn;
22827
+ const repoStack = state.repoStack.slice(0, -1);
22828
+ if (!ret) {
22829
+ return { ...state, repoStack, pendingKey: undefined };
22830
+ }
22831
+ return {
22832
+ ...state,
22833
+ repoStack,
22834
+ activeView: ret.activeView,
22835
+ viewStack: [ret.activeView],
22836
+ selectedIndex: ret.selectedIndex,
22837
+ selectedFileIndex: ret.selectedFileIndex,
22838
+ selectedSubmoduleIndex: ret.selectedSubmoduleIndex,
22839
+ filter: ret.filter,
22840
+ filterMode: false,
22841
+ pendingCommitFocused: false,
22842
+ pendingKey: undefined,
22843
+ pendingConfirmationId: undefined,
22844
+ pendingConfirmationPayload: undefined,
22845
+ pendingMutationConfirmation: undefined,
22846
+ };
22847
+ }
22567
22848
  function withReplacedView(state, value) {
22568
22849
  if (topOfStack(state.viewStack) === value) {
22569
22850
  return { ...state, pendingKey: undefined };
@@ -22708,7 +22989,7 @@ function createLogInkState(rows, options = {}) {
22708
22989
  selectedPullRequestTriageIndex: 0,
22709
22990
  selectedIssueFilter: 'open',
22710
22991
  selectedPullRequestFilter: 'open',
22711
- repoStack: [{ label: options.repoLabel || 'root' }],
22992
+ repoStack: [{ label: options.repoLabel || 'root', workdir: options.repoWorkdir }],
22712
22993
  branchSort: DEFAULT_BRANCH_SORT_MODE,
22713
22994
  tagSort: DEFAULT_TAG_SORT_MODE,
22714
22995
  paletteFilter: '',
@@ -22721,6 +23002,7 @@ function createLogInkState(rows, options = {}) {
22721
23002
  filterMode: false,
22722
23003
  fullGraph: false,
22723
23004
  showHelp: false,
23005
+ helpScrollOffset: 0,
22724
23006
  showCommandPalette: false,
22725
23007
  workflowActionId: undefined,
22726
23008
  pendingConfirmationId: undefined,
@@ -22728,8 +23010,13 @@ function createLogInkState(rows, options = {}) {
22728
23010
  pendingMutationConfirmation: undefined,
22729
23011
  pendingKey: undefined,
22730
23012
  focus: 'commits',
22731
- sidebarTab: 'status',
22732
- userSidebarTab: 'status',
23013
+ // Default first-time tab is 'branches' — it's the most useful
23014
+ // landing surface in the workstation (current branch + recent
23015
+ // branches with ahead/behind, switch target, etc.). Users who
23016
+ // pick a different tab have their choice persisted per-repo via
23017
+ // sidebarPersistence.ts and won't see this default again.
23018
+ sidebarTab: 'branches',
23019
+ userSidebarTab: 'branches',
22733
23020
  sidebarHeaderFocused: false,
22734
23021
  statusGroupHeaderFocused: false,
22735
23022
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
@@ -22750,6 +23037,15 @@ function getSelectedInkCommit(state) {
22750
23037
  }
22751
23038
  return state.filteredCommits[state.selectedIndex];
22752
23039
  }
23040
+ /**
23041
+ * True when the user has drilled into a submodule (or deeper).
23042
+ * Drives the chrome breadcrumb's display and any future
23043
+ * frame-aware behavior that wants to know "are we in a nested
23044
+ * frame?" without inspecting the stack directly.
23045
+ */
23046
+ function isLogInkNestedRepo(state) {
23047
+ return state.repoStack.length > 1;
23048
+ }
22753
23049
  function applyLogInkAction(state, action) {
22754
23050
  switch (action.type) {
22755
23051
  case 'appendRows':
@@ -23172,6 +23468,14 @@ function applyLogInkAction(state, action) {
23172
23468
  return withPoppedView(state);
23173
23469
  case 'replaceView':
23174
23470
  return withReplacedView(state, action.value);
23471
+ case 'pushRepoFrame':
23472
+ return withPushedRepoFrame(state, {
23473
+ label: action.label,
23474
+ workdir: action.workdir,
23475
+ entryRange: action.entryRange,
23476
+ });
23477
+ case 'popRepoFrame':
23478
+ return withPoppedRepoFrame(state);
23175
23479
  case 'navigateHome': {
23176
23480
  if (state.viewStack.length === 1 && topOfStack(state.viewStack) === HOME_VIEW) {
23177
23481
  return { ...state, pendingKey: undefined };
@@ -23343,6 +23647,7 @@ function applyLogInkAction(state, action) {
23343
23647
  filterMode: !state.filterMode,
23344
23648
  showCommandPalette: false,
23345
23649
  showHelp: false,
23650
+ helpScrollOffset: 0,
23346
23651
  pendingKey: undefined,
23347
23652
  };
23348
23653
  case 'toggleGraph':
@@ -23351,19 +23656,35 @@ function applyLogInkAction(state, action) {
23351
23656
  fullGraph: !state.fullGraph,
23352
23657
  pendingKey: undefined,
23353
23658
  };
23354
- case 'toggleHelp':
23659
+ case 'toggleHelp': {
23660
+ const opening = !state.showHelp;
23355
23661
  return {
23356
23662
  ...state,
23357
- showHelp: !state.showHelp,
23663
+ showHelp: opening,
23664
+ // Reset scroll position when toggling either direction so the
23665
+ // next open always starts at the top — feels more predictable
23666
+ // than picking up where the user last scrolled.
23667
+ helpScrollOffset: 0,
23358
23668
  showCommandPalette: false,
23359
23669
  pendingKey: undefined,
23360
23670
  };
23671
+ }
23672
+ case 'scrollHelp':
23673
+ // No upper-bound clamp here — the renderer caps the offset
23674
+ // against the actual content height at render time. The
23675
+ // reducer just prevents going below 0 so callers can safely
23676
+ // pass negative deltas without us going past the top.
23677
+ return {
23678
+ ...state,
23679
+ helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta),
23680
+ };
23361
23681
  case 'toggleCommandPalette': {
23362
23682
  const opening = !state.showCommandPalette;
23363
23683
  return {
23364
23684
  ...state,
23365
23685
  showCommandPalette: opening,
23366
23686
  showHelp: false,
23687
+ helpScrollOffset: 0,
23367
23688
  // Reset palette interaction state on every open/close so the next
23368
23689
  // session starts from a clean slate.
23369
23690
  paletteFilter: '',
@@ -24010,6 +24331,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
24010
24331
  value: 'open branches / tags / history and press m on the cursored ref',
24011
24332
  })];
24012
24333
  case 'navigateBack':
24334
+ // Mirror the Esc / `<` semantics (#931): drain the frame's view
24335
+ // stack first, then pop the frame itself when nested.
24336
+ if (state.viewStack.length > 1) {
24337
+ return [action({ type: 'popView' })];
24338
+ }
24339
+ if (isLogInkNestedRepo(state)) {
24340
+ return [action({ type: 'popRepoFrame' })];
24341
+ }
24013
24342
  return [action({ type: 'popView' })];
24014
24343
  case 'openSelected': {
24015
24344
  // From history → diff for selected commit; from status → diff for
@@ -24557,8 +24886,37 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24557
24886
  }
24558
24887
  return [];
24559
24888
  }
24560
- if (key.escape && state.showHelp) {
24561
- return [action({ type: 'toggleHelp' })];
24889
+ // Help-overlay key handling. While help is open we intercept ALL
24890
+ // keys here and return before they can fall through to scroll /
24891
+ // focus / navigation logic below. Without this, j/k while help is
24892
+ // open routes into `moveDetailFile`-style handlers, which mutates
24893
+ // focus state (`focus: 'detail'` → `'commits'` or `'sidebar'`) —
24894
+ // exactly the "scroll loses focus" bug.
24895
+ //
24896
+ // Allowed: Esc / ? (close), q (quit), j/k/arrows (scroll), Ctrl-d/u
24897
+ // (half-page). Everything else is swallowed by the trailing
24898
+ // `return []` so a stray keypress can't drop the user into the
24899
+ // wrong surface.
24900
+ if (state.showHelp) {
24901
+ if (key.escape || inputValue === '?') {
24902
+ return [action({ type: 'toggleHelp' })];
24903
+ }
24904
+ if (inputValue === 'q') {
24905
+ return [{ type: 'exit' }];
24906
+ }
24907
+ if (key.downArrow || inputValue === 'j') {
24908
+ return [action({ type: 'scrollHelp', delta: 1 })];
24909
+ }
24910
+ if (key.upArrow || inputValue === 'k') {
24911
+ return [action({ type: 'scrollHelp', delta: -1 })];
24912
+ }
24913
+ if (key.ctrl && inputValue === 'd') {
24914
+ return [action({ type: 'scrollHelp', delta: 10 })];
24915
+ }
24916
+ if (key.ctrl && inputValue === 'u') {
24917
+ return [action({ type: 'scrollHelp', delta: -10 })];
24918
+ }
24919
+ return [];
24562
24920
  }
24563
24921
  // #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
24564
24922
  // BEFORE the generic `popView` so we both clear the wizard state
@@ -24579,6 +24937,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24579
24937
  if (key.escape && state.viewStack.length > 1) {
24580
24938
  return [action({ type: 'popView' })];
24581
24939
  }
24940
+ // #931 — Esc auto-pop. When the user has drilled into a submodule
24941
+ // (nested repo frame) AND they're at the root of that frame's own
24942
+ // view stack, Esc walks back out to the parent repo. Ordered after
24943
+ // the view-stack pop above so Esc still drains a frame's view stack
24944
+ // before popping the frame itself — the user sees a predictable
24945
+ // "back, back, back" path out.
24946
+ if (key.escape && isLogInkNestedRepo(state)) {
24947
+ return [action({ type: 'popRepoFrame' })];
24948
+ }
24582
24949
  if (inputValue === 'q') {
24583
24950
  if (hasUnsavedComposeDraft(state)) {
24584
24951
  return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
@@ -24861,6 +25228,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24861
25228
  return [action({ type: 'toggleGraph' })];
24862
25229
  }
24863
25230
  if (inputValue === '<') {
25231
+ // #931 — `<` is the keymap-driven mirror of Esc auto-pop. When the
25232
+ // view stack has somewhere to go, pop a view; otherwise, if we're
25233
+ // in a nested submodule frame, walk back out to the parent. The
25234
+ // `popView` action is itself a no-op at the root of a frame's
25235
+ // view stack, so this ordering can't double-pop.
25236
+ if (state.viewStack.length > 1) {
25237
+ return [action({ type: 'popView' })];
25238
+ }
25239
+ if (isLogInkNestedRepo(state)) {
25240
+ return [action({ type: 'popRepoFrame' })];
25241
+ }
24864
25242
  return [action({ type: 'popView' })];
24865
25243
  }
24866
25244
  if (inputValue === 'G') {
@@ -25343,6 +25721,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25343
25721
  }
25344
25722
  }
25345
25723
  }
25724
+ // #931 PR 3b — Enter on a submodule file in a commit diff drills into
25725
+ // the submodule's history (the "spawn a coco ui scoped to the
25726
+ // submodule" mental model from the design doc). The runtime decides
25727
+ // whether the cursored file is a drill-in candidate and resolves the
25728
+ // workdir + entryRange ahead of time; the handler here only fires
25729
+ // when that target is populated. Ordered before the generic file-
25730
+ // list Enter handler so the drill-in takes precedence over the
25731
+ // detail-panel diff-refocus path.
25732
+ if (key.return &&
25733
+ state.activeView === 'diff' &&
25734
+ state.diffSource === 'commit' &&
25735
+ context.commitDiffSubmoduleDrillIn) {
25736
+ const target = context.commitDiffSubmoduleDrillIn;
25737
+ return [
25738
+ action({
25739
+ type: 'pushRepoFrame',
25740
+ label: target.label,
25741
+ workdir: target.workdir,
25742
+ entryRange: target.entryRange,
25743
+ }),
25744
+ action({ type: 'setStatus', value: `entering submodule ${target.label}` }),
25745
+ ];
25746
+ }
25747
+ // #931 PR 4 / #932 — Enter on a row in the dedicated submodules view
25748
+ // drills into that submodule's history. Same mental model as the
25749
+ // commit-diff drill-in (PR 3b) — pushing a frame is the equivalent
25750
+ // of `cd vendor/lib && coco ui`. No entry range here; the submodules
25751
+ // view doesn't carry diff context, so the frame lands on the
25752
+ // submodule's full history.
25753
+ if (key.return &&
25754
+ isSubmodulesActionTarget(state) &&
25755
+ context.submoduleViewDrillIn) {
25756
+ const target = context.submoduleViewDrillIn;
25757
+ return [
25758
+ action({
25759
+ type: 'pushRepoFrame',
25760
+ label: target.label,
25761
+ workdir: target.workdir,
25762
+ }),
25763
+ action({ type: 'setStatus', value: `entering submodule ${target.label}` }),
25764
+ ];
25765
+ }
25346
25766
  if (key.return &&
25347
25767
  state.activeView === 'history' &&
25348
25768
  state.focus === 'commits' &&
@@ -26081,6 +26501,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26081
26501
  events.push({ type: 'createManualCommit' });
26082
26502
  return events;
26083
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
+ }
26084
26525
  const workflowAction = getLogInkWorkflowActionByKey(inputValue);
26085
26526
  if (workflowAction?.requiresConfirmation) {
26086
26527
  return [action({ type: 'setPendingConfirmation', value: workflowAction.id })];
@@ -26250,6 +26691,199 @@ function pickSpinnerFrame(tick) {
26250
26691
  return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
26251
26692
  }
26252
26693
 
26694
+ /**
26695
+ * Build the initial `LogInkContextStatus` for a freshly-created frame
26696
+ * (#931). Every fetched key starts in `'loading'` so surfaces show the
26697
+ * loading hint immediately; `pullRequest` is the exception (#808) —
26698
+ * it's lazy-loaded on entry to the PR view, so we seed it `'idle'`
26699
+ * instead of leaving it stuck as a permanent "loading" flag in the
26700
+ * chrome.
26701
+ *
26702
+ * Extracted so the root runtime (built at boot inside `LogInkApp`) and
26703
+ * the per-frame factory below share one canonical seed. The status
26704
+ * surfaces depend on the exact `'pullRequest' = 'idle'` initialization
26705
+ * to avoid spurious loading hints; locking it down in one helper means
26706
+ * the two code paths can't drift.
26707
+ */
26708
+ function createInitialContextStatus() {
26709
+ return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
26710
+ }
26711
+ /**
26712
+ * Factory that builds a fresh `RepoFrameRuntime` for a newly-pushed
26713
+ * frame (#931). The frame's `workdir` (set by the push action) drives
26714
+ * which working tree the `SimpleGit` instance binds against:
26715
+ *
26716
+ * - **Has workdir** → `simpleGit(workdir)`. Production case for any
26717
+ * nested submodule frame.
26718
+ * - **No workdir** → falls back to `rootGit`. Defensive: only the
26719
+ * root frame is expected to lack a workdir, and the root frame's
26720
+ * runtime is built directly from `rootGit` in `LogInkApp`'s state
26721
+ * initializer — this fallback only kicks in if a future push path
26722
+ * forgets to pass `workdir`. Binding to the root keeps the session
26723
+ * functional (the user still sees data) at the cost of the frame
26724
+ * being a duplicate of the root.
26725
+ *
26726
+ * `context` starts empty; `contextStatus` starts in the same initial
26727
+ * "loading + pullRequest idle" shape the root frame seeds with. The
26728
+ * sync effect in `LogInkApp` is responsible for kicking off the
26729
+ * per-key context loads against the new frame's `git`; we don't do
26730
+ * that here so the factory stays pure and unit-testable without a
26731
+ * real repo on disk.
26732
+ */
26733
+ function createRepoFrameRuntime(frame, rootGit) {
26734
+ return {
26735
+ git: frame.workdir ? simpleGit.simpleGit(frame.workdir) : rootGit,
26736
+ context: {},
26737
+ contextStatus: createInitialContextStatus(),
26738
+ };
26739
+ }
26740
+
26741
+ /**
26742
+ * Pure resolver: given the cursored file + the active frame's
26743
+ * submodule overview + repo root, decide whether a commit-diff Enter
26744
+ * keystroke should drill into a submodule and, if so, what payload
26745
+ * the `pushRepoFrame` action should carry.
26746
+ *
26747
+ * Returns undefined when:
26748
+ * - We don't know the active repo root yet (boot still in flight).
26749
+ * - The file's path doesn't correspond to a registered submodule.
26750
+ * - The submodule overview hasn't loaded yet for the active frame.
26751
+ *
26752
+ * The `submoduleChange` on the file preview is the source of truth
26753
+ * for the entry range; we never need to re-run the diff to populate
26754
+ * the (oldSha, newSha) pair.
26755
+ */
26756
+ function resolveCommitDiffDrillInTarget(args) {
26757
+ const { selectedFile, submodules, activeRepoRoot } = args;
26758
+ if (!activeRepoRoot)
26759
+ return undefined;
26760
+ if (!submodules || !submodules.hasSubmodules)
26761
+ return undefined;
26762
+ const entry = findSubmoduleByPath(submodules, selectedFile.path);
26763
+ if (!entry)
26764
+ return undefined;
26765
+ return {
26766
+ label: entry.name,
26767
+ workdir: path.join(activeRepoRoot, entry.path),
26768
+ entryRange: deriveEntryRange(selectedFile.submoduleChange),
26769
+ };
26770
+ }
26771
+ /**
26772
+ * Convert the structured `SubmoduleChange` (from `extractSubmoduleChange`)
26773
+ * into the `entryRange` shape `LogInkRepoFrame` carries. Modified
26774
+ * submodules surface both shas; added / removed surface only one,
26775
+ * which isn't enough to scope a history range — those cases return
26776
+ * undefined and the frame lands on the submodule's full history.
26777
+ */
26778
+ function deriveEntryRange(change) {
26779
+ if (!change)
26780
+ return undefined;
26781
+ if (change.kind === 'modified') {
26782
+ return { oldSha: change.before, newSha: change.after };
26783
+ }
26784
+ return undefined;
26785
+ }
26786
+ /**
26787
+ * Pure resolver for the submodules-view drill-in (#931 PR 4 / #932).
26788
+ * Given the cursored row index + the submodule overview + the active
26789
+ * frame's repo root, build the `pushRepoFrame` payload Enter should
26790
+ * dispatch. Returns undefined when:
26791
+ *
26792
+ * - The active repo root hasn't loaded yet.
26793
+ * - The submodule overview hasn't loaded (or is empty).
26794
+ * - The cursor is past the end of the entries (race between a
26795
+ * refresh that removed a submodule and a key press still in
26796
+ * flight against the old length).
26797
+ * - The cursored entry has no `path` recorded. The `.gitmodules`
26798
+ * parser already filters these out upstream, but the resolver
26799
+ * defends against it so the cursor can't yank the user into a
26800
+ * workdir-less frame.
26801
+ */
26802
+ function resolveSubmoduleViewDrillInTarget(args) {
26803
+ const { selectedIndex, submodules, activeRepoRoot } = args;
26804
+ if (!activeRepoRoot)
26805
+ return undefined;
26806
+ if (!submodules || !submodules.hasSubmodules)
26807
+ return undefined;
26808
+ const entry = submodules.entries[selectedIndex];
26809
+ if (!entry || !entry.path)
26810
+ return undefined;
26811
+ return {
26812
+ label: entry.name,
26813
+ workdir: path.join(activeRepoRoot, entry.path),
26814
+ };
26815
+ }
26816
+
26817
+ /**
26818
+ * Reconcile the per-frame runtime list against the current view-model
26819
+ * stack. Three cases:
26820
+ *
26821
+ * - **No change** — same length, returns `prev` unchanged so React
26822
+ * reference equality skips downstream re-renders.
26823
+ * - **Pop** — stack shrunk, returns `prev.slice(0, stack.length)`.
26824
+ * The dropped runtimes are released to the GC; the surviving
26825
+ * runtimes (root + any intermediate frames) keep their cached
26826
+ * `git` + `context` so a re-push lands on warm state.
26827
+ * - **Push** — stack grew, builds a fresh runtime via the supplied
26828
+ * `createRuntime(frame, depth)` factory for each newly-deeper
26829
+ * frame. The caller is responsible for the factory's content;
26830
+ * this module never imports `simple-git` or `loadLogInkContext`
26831
+ * directly so it stays unit-testable without a real repo on disk.
26832
+ *
26833
+ * Returns `newlyAddedIndices` so the caller's effect knows which
26834
+ * frames need their initial context fetch kicked off. On a no-op or
26835
+ * pop, the list is empty.
26836
+ *
26837
+ * The reducer's `pushRepoFrame` / `popRepoFrame` actions are the only
26838
+ * things that mutate `state.repoStack`; both are monotone — push
26839
+ * appends one, pop drops one — so this helper never needs to handle
26840
+ * "frame at index i changed identity in place." If that invariant ever
26841
+ * loosens, this helper should error rather than silently mis-bind a
26842
+ * `SimpleGit` to the wrong working directory.
26843
+ */
26844
+ function syncRepoStackRuntimes(prev, stack, createRuntime) {
26845
+ if (stack.length < prev.length) {
26846
+ return { runtimes: prev.slice(0, stack.length), newlyAddedIndices: [] };
26847
+ }
26848
+ if (stack.length === prev.length) {
26849
+ return { runtimes: prev, newlyAddedIndices: [] };
26850
+ }
26851
+ const next = prev.slice();
26852
+ const newlyAddedIndices = [];
26853
+ for (let i = prev.length; i < stack.length; i += 1) {
26854
+ next.push(createRuntime(stack[i], i));
26855
+ newlyAddedIndices.push(i);
26856
+ }
26857
+ return { runtimes: next, newlyAddedIndices };
26858
+ }
26859
+ /**
26860
+ * Top-of-stack runtime — the one every active surface, loader, and
26861
+ * action target reads from. Undefined when the runtime list is empty
26862
+ * (which production code never produces — `createLogInkState` always
26863
+ * seeds a root frame, so the corresponding root runtime is built on
26864
+ * mount and the array is non-empty for the lifetime of the session).
26865
+ */
26866
+ function getActiveRepoFrameRuntime(runtimes) {
26867
+ return runtimes[runtimes.length - 1];
26868
+ }
26869
+ /**
26870
+ * Immutably update one frame's runtime entry. Used by the app shell's
26871
+ * loader effects when a frame's `context` or `contextStatus` changes
26872
+ * — replacing the entry in place lets React's referential equality
26873
+ * skip re-renders on unrelated frames.
26874
+ *
26875
+ * Out-of-range indices are no-ops (return `prev` unchanged) so the
26876
+ * caller doesn't have to guard against race-y stack changes between
26877
+ * the load kickoff and the load-complete callback.
26878
+ */
26879
+ function updateRepoFrameRuntime(runtimes, index, updater) {
26880
+ if (index < 0 || index >= runtimes.length)
26881
+ return runtimes;
26882
+ const next = runtimes.slice();
26883
+ next[index] = updater(next[index]);
26884
+ return next;
26885
+ }
26886
+
26253
26887
  /**
26254
26888
  * Persist the user's preferred diff view mode (unified vs side-by-side
26255
26889
  * split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
@@ -26990,6 +27624,106 @@ function pushCurrentBranch(git) {
26990
27624
  function setUpstream(git, localBranch, upstreamBranch) {
26991
27625
  return runAction$5(() => git.raw(['branch', '--set-upstream-to', upstreamBranch, localBranch]), `Set ${localBranch} upstream to ${upstreamBranch}`);
26992
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
+ }
26993
27727
 
26994
27728
  async function runAction$4(action, successMessage) {
26995
27729
  try {
@@ -28513,29 +29247,81 @@ function formatBranchDivergence(branch, options = {}) {
28513
29247
  parts.push(`↓${branch.behind}`);
28514
29248
  return `${parts.join(' ')} ${branch.upstream}`;
28515
29249
  }
28516
- /**
28517
- * Single-cell marker shown to the left of a branch name in lists.
28518
- *
28519
- * - `*` — current branch (regardless of remote state)
28520
- * - `◌` no upstream
28521
- * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
28522
- * - `↕` has upstream + diverged (any non-zero ahead/behind)
28523
- * - ` ` fallback / no info
28524
- *
28525
- * ASCII fallbacks (legible without box-drawing/arrow glyphs):
28526
- * - `?` for "no upstream", `=` for synced, `~` for diverged.
28527
- */
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
+ }
28528
29269
  function branchRowMarker(branch, options = {}) {
28529
- if (branch.current)
28530
- return '*';
28531
- if (!branch.upstream)
28532
- 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
+ }
28533
29276
  const ahead = branch.ahead ?? 0;
28534
29277
  const behind = branch.behind ?? 0;
28535
29278
  if (ahead === 0 && behind === 0) {
28536
- 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;
28537
29324
  }
28538
- return options.ascii ? '~' : '↕';
28539
29325
  }
28540
29326
  /**
28541
29327
  * Compact, human-friendly relative timestamp for the branch row.
@@ -28823,7 +29609,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
28823
29609
  ? ' loading commits'
28824
29610
  : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
28825
29611
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
28826
- const view = breadcrumb ? ` ${breadcrumb}` : '';
29612
+ const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
29613
+ // Repo breadcrumb (when nested) comes first so the user sees which
29614
+ // submodule they're in at a glance, then the view breadcrumb (when
29615
+ // pushed deeper than the root view). The truncate fallback in the
29616
+ // title row still applies — when both fight for space, the ellipsis
29617
+ // lands at the end of whichever segment overflows.
29618
+ const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
28827
29619
  // Mode indicator (P2.2) — surfaces the current input mode so users
28828
29620
  // never wonder why `q` doesn't quit while they're editing or filtering.
28829
29621
  const mode = state.commitCompose.editing
@@ -29137,7 +29929,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
29137
29929
  ];
29138
29930
  return [
29139
29931
  ...headerRows,
29140
- ...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),
29141
29933
  ];
29142
29934
  }
29143
29935
  if (tab === 'tags') {
@@ -29600,21 +30392,22 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
29600
30392
  const isSelected = index === selected;
29601
30393
  const cursor = isSelected ? '>' : ' ';
29602
30394
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
30395
+ const markerColor = getBranchRowMarkerColor(marker.kind, theme);
29603
30396
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
29604
30397
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
29605
30398
  // Split the row into spans so the timestamp stays dim even on the
29606
- // currently-selected (bold) row. The leading marker + name keep
29607
- // their per-window-derived column widths; the timestamp is
29608
- // 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.
29609
30401
  const namePadded = truncateCells(branch.shortName, nameColWidth).padEnd(nameColWidth);
29610
30402
  const timestampPadded = lastTouched.padEnd(8);
29611
30403
  const lineDim = !isSelected && !branch.current;
29612
- const head = `${cursor} ${marker} ${namePadded} `;
30404
+ const cursorAndPad = `${cursor} `;
30405
+ const trailingName = ` ${namePadded} `;
29613
30406
  const trailingDivergence = divergence ? ` ${divergence}` : '';
29614
30407
  // Truncate the assembled line to the actual panel width so a
29615
30408
  // narrow inspector / sidebar focus doesn't push branch rows
29616
30409
  // onto a second visual line (#830).
29617
- const fullText = `${head}${timestampPadded}${trailingDivergence}`;
30410
+ const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
29618
30411
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
29619
30412
  // If truncation chopped into the timestamp/divergence portion,
29620
30413
  // fall back to a single Text to keep the visible width honest.
@@ -29629,7 +30422,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
29629
30422
  key: `branch-${index}`,
29630
30423
  bold: isSelected,
29631
30424
  dimColor: lineDim,
29632
- }, 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);
29633
30434
  });
29634
30435
  return h(Box, {
29635
30436
  borderColor: focusBorderColor(theme, focused),
@@ -30471,30 +31272,52 @@ function filterChippedRefs(refs, chip) {
30471
31272
  return true;
30472
31273
  });
30473
31274
  }
30474
- function getBranchTipChip(refs) {
31275
+ /**
31276
+ * `remoteNames` lets the caller pass the repository's actual remote
31277
+ * names (e.g. `['origin', 'upstream']`) so refs are classified by
31278
+ * remote-prefix rather than by "contains a slash". Without it a local
31279
+ * feature branch like `feat/x` looks identical to a remote-tracking
31280
+ * `origin/x` and gets the wrong colour. When the list is omitted the
31281
+ * function falls back to the legacy slash-as-remote heuristic — the
31282
+ * sensible default before branch data has loaded and a back-compat
31283
+ * affordance for callers that have no remote data to hand.
31284
+ */
31285
+ function getBranchTipChip(refs, remoteNames) {
31286
+ // Empty list is treated the same as omitted: branch data hasn't
31287
+ // loaded yet, so we don't have ground truth and the legacy "slash =
31288
+ // remote" heuristic is the best guess for first paint.
31289
+ const hasRemoteList = Array.isArray(remoteNames) && remoteNames.length > 0;
31290
+ const isRemoteRef = (ref) => {
31291
+ if (!ref.includes('/'))
31292
+ return false;
31293
+ if (!hasRemoteList)
31294
+ return true;
31295
+ return remoteNames.some((remote) => remote && ref.startsWith(`${remote}/`));
31296
+ };
30475
31297
  for (const ref of refs) {
30476
31298
  if (ref.startsWith('HEAD -> ')) {
30477
31299
  const name = ref.slice('HEAD -> '.length).trim();
30478
31300
  if (name)
30479
- return { name, isHead: true };
31301
+ return { name, isHead: true, kind: 'head' };
30480
31302
  }
30481
31303
  }
30482
31304
  for (const ref of refs) {
30483
31305
  if (ref === 'HEAD' ||
30484
31306
  ref.startsWith('HEAD -> ') ||
30485
31307
  ref.startsWith('tag: ') ||
30486
- ref.includes('/')) {
31308
+ isRemoteRef(ref)) {
30487
31309
  continue;
30488
31310
  }
30489
- if (ref.trim())
30490
- return { name: ref.trim(), isHead: false };
31311
+ if (ref.trim()) {
31312
+ return { name: ref.trim(), isHead: false, kind: 'local' };
31313
+ }
30491
31314
  }
30492
31315
  for (const ref of refs) {
30493
31316
  if (ref.startsWith('tag: ') || ref === 'HEAD' || ref.startsWith('HEAD -> ')) {
30494
31317
  continue;
30495
31318
  }
30496
- if (ref.includes('/') && ref.trim()) {
30497
- return { name: ref.trim(), isHead: false };
31319
+ if (isRemoteRef(ref) && ref.trim()) {
31320
+ return { name: ref.trim(), isHead: false, kind: 'remote' };
30498
31321
  }
30499
31322
  }
30500
31323
  return undefined;
@@ -31257,8 +32080,12 @@ const BRANCH_CHIP_MAX_NAME_WIDTH = 20;
31257
32080
  * descriptor so the caller can pass it to `filterChippedRefs` and
31258
32081
  * avoid emitting the same branch a second time in the trailing list.
31259
32082
  */
31260
- function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31261
- const chip = getBranchTipChip(commit.refs);
32083
+ // Exported for unit / snapshot testing in branchTipChipRender.test.ts.
32084
+ // The function isn't part of the public surface of this module — the
32085
+ // rest of the file is internal — but the chip-rendering logic is
32086
+ // dense enough that structural snapshot tests pay for themselves.
32087
+ function renderBranchTipChip(h, Text, commit, theme, key, selected, remoteNames) {
32088
+ const chip = getBranchTipChip(commit.refs, remoteNames);
31262
32089
  if (!chip)
31263
32090
  return { node: null, width: 0, chip };
31264
32091
  const truncated = truncateCells(chip.name, BRANCH_CHIP_MAX_NAME_WIDTH);
@@ -31279,7 +32106,23 @@ function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31279
32106
  chip,
31280
32107
  };
31281
32108
  }
31282
- const accent = chip.isHead ? theme.colors.success : theme.colors.info;
32109
+ // Three-way colour assignment matches `BranchTipChipKind`:
32110
+ //
32111
+ // - HEAD → success (the user's current branch — bright green)
32112
+ // - local → info (other local branches — calm blue)
32113
+ // - remote → warning (remote-tracking refs like origin/main —
32114
+ // distinct so "where is upstream?" reads at a glance)
32115
+ //
32116
+ // Without the remote/local split, a chip on `origin/main` looked
32117
+ // identical to a local-branch chip, so users couldn't tell from the
32118
+ // commit list where their upstream actually pointed. The warning hue
32119
+ // (typically a muted yellow / orange) is purposeful: not alarming,
32120
+ // but visibly different from the local blue.
32121
+ const accent = chip.kind === 'head'
32122
+ ? theme.colors.success
32123
+ : chip.kind === 'remote'
32124
+ ? theme.colors.warning
32125
+ : theme.colors.info;
31283
32126
  return {
31284
32127
  node: h(Text, {}, h(Text, { key, inverse: true, color: accent, bold: chip.isHead }, body), h(Text, { key: `${key}-pad` }, ' ')),
31285
32128
  width: cellWidth(body) + 1,
@@ -31369,7 +32212,7 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
31369
32212
  * Truncation is per-segment so the variable-length message field gets
31370
32213
  * the leftover budget after fixed segments are accounted for.
31371
32214
  */
31372
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false) {
32215
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false, remoteNames) {
31373
32216
  // Total cells available to the row content. Earlier revisions used a
31374
32217
  // hardcoded 140 here, which let row content overflow whenever the
31375
32218
  // panel was narrower than that — Ink would wrap onto a second visual
@@ -31386,7 +32229,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
31386
32229
  // out whatever the chip already shows so the row doesn't print
31387
32230
  // `[main] feat: x [HEAD -> main]` with the same info on both ends.
31388
32231
  const chip = fullGraph
31389
- ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected)
32232
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected, remoteNames)
31390
32233
  : { node: null, width: 0, chip: undefined };
31391
32234
  const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
31392
32235
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + dateSegmentWidth + chip.width;
@@ -31448,7 +32291,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
31448
32291
  * line stays dim regardless of selection so it doesn't pull the eye
31449
32292
  * away from the subject.
31450
32293
  */
31451
- function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false) {
32294
+ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false, remoteNames) {
31452
32295
  const totalWidth = Math.max(20, panelWidth - 4);
31453
32296
  const accent = theme.noColor ? undefined : theme.colors.accent;
31454
32297
  const muted = theme.noColor ? undefined : theme.colors.muted;
@@ -31459,7 +32302,7 @@ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth,
31459
32302
  // same way as the single-line variant, but only in full-graph mode.
31460
32303
  const recentMarkerWidth = isRecent ? 2 : 0;
31461
32304
  const chip = fullGraph
31462
- ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected)
32305
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected, remoteNames)
31463
32306
  : { node: null, width: 0, chip: undefined };
31464
32307
  const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
31465
32308
  const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
@@ -31534,6 +32377,17 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31534
32377
  const { Box, Text } = components;
31535
32378
  const focused = state.focus === 'commits';
31536
32379
  const worktree = context.worktree;
32380
+ // Distinct remote names seen across the repo's remote-tracking
32381
+ // branches — `['origin']` for a typical fork, `['origin', 'upstream']`
32382
+ // when the user has both. Used to classify branch-tip chips so a
32383
+ // slashed local branch like `feat/x` doesn't get mis-coloured as
32384
+ // remote. When branch data hasn't loaded yet, `undefined` makes the
32385
+ // chip helper fall back to the legacy slash-based heuristic.
32386
+ const remoteNames = context.branches?.remoteBranches
32387
+ ? Array.from(new Set(context.branches.remoteBranches
32388
+ .map((branch) => branch.remote)
32389
+ .filter((remote) => Boolean(remote))))
32390
+ : undefined;
31537
32391
  // Set of just-landed commit hashes for the "new commit" marker.
31538
32392
  // Populated for ~5s after a split-apply or other commit-creating
31539
32393
  // operation; auto-cleared by the runtime so it doesn't linger.
@@ -31588,6 +32442,25 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31588
32442
  paddingX: 1,
31589
32443
  width,
31590
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
+ })()),
31591
32464
  // Server-side filter indicator (#776). Only rendered when the user
31592
32465
  // has an active path:/author: prefix; clears when they Ctrl+U.
31593
32466
  ...(state.historyFetchArgs
@@ -31642,9 +32515,9 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31642
32515
  }, truncateCells(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
31643
32516
  }
31644
32517
  if (rowMode === 'stacked') {
31645
- return renderStackedCommitHistoryRow(h, Text, Box, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, state.fullGraph, now, item.laneSegments, recentCommitsSet.has(item.commit.hash));
32518
+ return renderStackedCommitHistoryRow(h, Text, Box, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, state.fullGraph, now, item.laneSegments, recentCommitsSet.has(item.commit.hash), remoteNames);
31646
32519
  }
31647
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, density, state.fullGraph, Boolean(dateBucketingNow), now, item.laneSegments, recentCommitsSet.has(item.commit.hash));
32520
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, density, state.fullGraph, Boolean(dateBucketingNow), now, item.laneSegments, recentCommitsSet.has(item.commit.hash), remoteNames);
31648
32521
  }));
31649
32522
  }
31650
32523
 
@@ -31800,20 +32673,38 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
31800
32673
  }
31801
32674
  function renderHelpPanel(h, components, state, width, theme, focused) {
31802
32675
  const { Box, Text } = components;
31803
- const children = [
31804
- h(Text, { bold: true, key: 'title' }, panelTitle('Help', focused)),
31805
- ];
32676
+ // Build the full list of body rows (everything below the title).
32677
+ // Splitting into title + body lets us window the body by
32678
+ // `state.helpScrollOffset` while keeping the title pinned.
32679
+ const body = [];
31806
32680
  const sections = getLogInkHelpSections({
31807
32681
  activeView: state.activeView,
31808
32682
  focus: state.focus,
31809
32683
  });
31810
32684
  for (const section of sections) {
31811
- children.push(h(Text, { key: `${section.title}-spacer` }, ''));
31812
- children.push(h(Text, { bold: true, key: section.title }, section.title));
32685
+ body.push(h(Text, { key: `${section.title}-spacer` }, ''));
32686
+ body.push(h(Text, { bold: true, key: section.title }, section.title));
31813
32687
  section.bindings.forEach((binding) => {
31814
- children.push(h(Text, { key: `${section.title}:${binding.id}` }, truncateCells(`${formatBindingKeys(binding).padEnd(14)} ${binding.description}`, width - 4)));
32688
+ body.push(h(Text, { key: `${section.title}:${binding.id}` }, truncateCells(`${formatBindingKeys(binding).padEnd(14)} ${binding.description}`, width - 4)));
31815
32689
  });
31816
32690
  }
32691
+ // Clamp the offset against actual content length. The reducer
32692
+ // only floor-clamps at 0; here we ceiling-clamp so j past EOF
32693
+ // sticks at the last row rather than scrolling into emptiness.
32694
+ // Reserve one row at the bottom so the user can always see the
32695
+ // tail of the last section.
32696
+ const maxOffset = Math.max(0, body.length - 1);
32697
+ const offset = Math.min(state.helpScrollOffset, maxOffset);
32698
+ const children = [
32699
+ h(Text, { bold: true, key: 'title' }, panelTitle('Help', focused)),
32700
+ ];
32701
+ // Visual hint that there's content scrolled above. The dim style
32702
+ // matches the rest of the chrome's "metadata" voice and avoids
32703
+ // stealing attention from the bindings themselves.
32704
+ if (offset > 0) {
32705
+ children.push(h(Text, { key: 'more-above', dimColor: true }, '↑ more above'));
32706
+ }
32707
+ children.push(...body.slice(offset));
31817
32708
  return h(Box, {
31818
32709
  borderColor: focusBorderColor(theme, focused),
31819
32710
  borderStyle: theme.borderStyle,
@@ -34549,7 +35440,7 @@ function enrichFilterActionWithRectification(action, state, context) {
34549
35440
  }
34550
35441
  }
34551
35442
  function LogInkApp(deps) {
34552
- const { appLabel, clipboardRunner, dateBucketingEnabled, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
35443
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
34553
35444
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
34554
35445
  const h = React.createElement;
34555
35446
  const { exit } = useApp();
@@ -34580,16 +35471,98 @@ function LogInkApp(deps) {
34580
35471
  // immediately while the chrome still flags the refresh.
34581
35472
  bootLoading: Boolean(loadRows),
34582
35473
  }));
34583
- const [context, setContext] = React.useState({});
34584
- const [contextStatus, setContextStatus] = React.useState(() => {
34585
- // Boot starts every fetched key in 'loading' so the surfaces show
34586
- // their loading hints immediately. `pullRequest` is the exception
34587
- // (#808) it isn't part of the boot fetch entries; it lazy-loads
34588
- // when the user enters the PR view. Marking it 'idle' avoids a
34589
- // permanent "loading" flag in the chrome and lets the dedicated
34590
- // PR view's own load effect drive its loading state.
34591
- return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
34592
- });
35474
+ // Nested-repo runtime stack (#931). Each frame holds the live
35475
+ // `SimpleGit`, the loaded `LogInkContext`, and the per-key load
35476
+ // status the chrome reads. The active (top-of-stack) entry drives
35477
+ // every loader and surface; popping a frame restores the parent's
35478
+ // cached entry so a drill-in / drill-out round trip doesn't re-pay
35479
+ // the context load cost. Seeded with a single root runtime against
35480
+ // the cwd `coco ui` was launched in.
35481
+ const [runtimes, setRuntimes] = React.useState(() => [{
35482
+ git: rootGit,
35483
+ context: {},
35484
+ contextStatus: createInitialContextStatus(),
35485
+ }]);
35486
+ // Sync `runtimes` against the view-model stack on every push / pop.
35487
+ // The sync is monotone — push appends a new runtime via the factory,
35488
+ // pop slices off the top runtime; the parent's cached state survives.
35489
+ // The factory is wrapped to capture `rootGit` so a defensively-pushed
35490
+ // frame without a workdir still has a working `SimpleGit` bound.
35491
+ React.useEffect(() => {
35492
+ setRuntimes((prev) => {
35493
+ const { runtimes: next } = syncRepoStackRuntimes(prev, state.repoStack, (frame) => createRepoFrameRuntime(frame, rootGit));
35494
+ return next;
35495
+ });
35496
+ }, [state.repoStack, rootGit]);
35497
+ // Active-frame projection (#931). `git`, `context`, `contextStatus`
35498
+ // — every existing closure / effect / surface reads these names; the
35499
+ // only thing this PR changes is where they come from. When the user
35500
+ // drills into a submodule, the top-of-stack runtime swaps, every
35501
+ // dep array that lists `git` re-fires, and the loaders refetch
35502
+ // against the submodule's working tree.
35503
+ const activeRuntime = getActiveRepoFrameRuntime(runtimes) ?? {
35504
+ git: rootGit,
35505
+ context: {},
35506
+ contextStatus: createInitialContextStatus(),
35507
+ };
35508
+ const git = activeRuntime.git;
35509
+ const context = activeRuntime.context;
35510
+ const contextStatus = activeRuntime.contextStatus;
35511
+ // Wrappers that delegate to the active frame's runtime entry so the
35512
+ // existing call sites stay byte-identical. Support both function-
35513
+ // updater and value-updater forms (the codebase uses both).
35514
+ const setContext = React.useCallback((arg) => {
35515
+ setRuntimes((prev) => {
35516
+ const depth = prev.length - 1;
35517
+ if (depth < 0)
35518
+ return prev;
35519
+ return updateRepoFrameRuntime(prev, depth, (frame) => ({
35520
+ ...frame,
35521
+ context: typeof arg === 'function'
35522
+ ? arg(frame.context)
35523
+ : arg,
35524
+ }));
35525
+ });
35526
+ }, []);
35527
+ const setContextStatus = React.useCallback((arg) => {
35528
+ setRuntimes((prev) => {
35529
+ const depth = prev.length - 1;
35530
+ if (depth < 0)
35531
+ return prev;
35532
+ return updateRepoFrameRuntime(prev, depth, (frame) => ({
35533
+ ...frame,
35534
+ contextStatus: typeof arg === 'function'
35535
+ ? arg(frame.contextStatus)
35536
+ : arg,
35537
+ }));
35538
+ });
35539
+ }, []);
35540
+ // #931 PR 3b — Absolute repo root for the active frame's `git`.
35541
+ // Resolved asynchronously after every `git` swap (push / pop /
35542
+ // boot) so the commit-diff drill-in helper can construct absolute
35543
+ // workdirs for submodule paths recorded in `.gitmodules` (which
35544
+ // are repo-relative). Undefined during the brief moment between
35545
+ // git swap and the revparse callback resolving.
35546
+ const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
35547
+ React.useEffect(() => {
35548
+ let cancelled = false;
35549
+ void (async () => {
35550
+ try {
35551
+ const root = (await git.revparse(['--show-toplevel'])).trim();
35552
+ if (!cancelled && root) {
35553
+ setActiveRepoRoot(root);
35554
+ }
35555
+ }
35556
+ catch {
35557
+ if (!cancelled) {
35558
+ setActiveRepoRoot(undefined);
35559
+ }
35560
+ }
35561
+ })();
35562
+ return () => {
35563
+ cancelled = true;
35564
+ };
35565
+ }, [git]);
34593
35566
  const [detail, setDetail] = React.useState(undefined);
34594
35567
  const [detailLoading, setDetailLoading] = React.useState(false);
34595
35568
  const [filePreview, setFilePreview] = React.useState(undefined);
@@ -35109,9 +36082,26 @@ function LogInkApp(deps) {
35109
36082
  selectedWorktreeFile?.worktreeStatus,
35110
36083
  state.activeView,
35111
36084
  ]);
36085
+ // #931 PR 5 — Cache-aware boot load. The frame's `git` instance is
36086
+ // the dep that drives this effect; on push, the new frame's runtime
36087
+ // starts every key in `'loading'` and we fetch fresh. On pop, the
36088
+ // parent's runtime carries cached context across the drill-out cycle
36089
+ // (`'ready'` for already-loaded keys), and the per-key gate below
36090
+ // skips the fetch so the user's drill-out is instant + flicker-free.
36091
+ //
36092
+ // `contextStatusRef` reads the latest status without putting
36093
+ // `contextStatus` in the effect deps — including it would re-fire
36094
+ // the effect on every per-key 'ready' write the effect itself
36095
+ // produces, causing duplicate in-flight fetches for not-yet-completed
36096
+ // keys. The ref pattern gives us "read latest" semantics with the
36097
+ // effect still gated on git swaps only.
36098
+ const contextStatusRef = React.useRef(contextStatus);
36099
+ contextStatusRef.current = contextStatus;
35112
36100
  React.useEffect(() => {
35113
36101
  let active = true;
35114
36102
  loadLogInkContextEntries(git).forEach(({ key, load }) => {
36103
+ if (contextStatusRef.current[key] === 'ready')
36104
+ return;
35115
36105
  void load().then((value) => {
35116
36106
  if (!active) {
35117
36107
  return;
@@ -36151,16 +37141,20 @@ function LogInkApp(deps) {
36151
37141
  return;
36152
37142
  }
36153
37143
  // Success — close the overlay, reset compose (the staged set is
36154
- // now empty since the plan committed everything), and pop the
36155
- // compose view so the user lands on whatever was beneath (usually
36156
- // 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.
36157
37155
  dispatch({ type: 'clearSplitPlan' });
36158
37156
  dispatch({ type: 'commitCompose', action: { type: 'reset' } });
36159
- // Only pop if compose is on top — the apply could have been
36160
- // invoked from a deeper stack and we don't want to over-pop.
36161
- if (state.activeView === 'compose' && state.viewStack.length > 1) {
36162
- dispatch({ type: 'popView' });
36163
- }
37157
+ dispatch({ type: 'navigateHome' });
36164
37158
  // Refresh BEFORE setting the final status so we can peek at the
36165
37159
  // post-apply worktree state and craft a directive next-step hint
36166
37160
  // ("X unstaged + Y untracked remaining — press gs to stage / I
@@ -36210,7 +37204,7 @@ function LogInkApp(deps) {
36210
37204
  }
36211
37205
  const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
36212
37206
  dispatch({ type: 'setStatus', value: successMessage, kind: 'success' });
36213
- }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.activeView, state.splitPlan, state.viewStack.length]);
37207
+ }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
36214
37208
  // Esc inside the overlay — close without applying. Status line gets
36215
37209
  // a confirmation so the user knows the operation was abandoned.
36216
37210
  const cancelCommitSplit = React.useCallback(() => {
@@ -36717,6 +37711,41 @@ function LogInkApp(deps) {
36717
37711
  'fetch-remotes': async () => fetchRemotes(git),
36718
37712
  'pull-current-branch': async () => pullCurrentBranch(git),
36719
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
+ },
36720
37749
  'rename-branch': async () => {
36721
37750
  const newName = payload?.trim();
36722
37751
  if (!newName)
@@ -37526,6 +38555,37 @@ function LogInkApp(deps) {
37526
38555
  commitDiffSelectedSha: state.diffSource === 'commit'
37527
38556
  ? selected?.hash
37528
38557
  : undefined,
38558
+ // #931 PR 3b — Submodule drill-in target for the cursored file
38559
+ // in a commit diff. Resolved per-render so the Enter handler in
38560
+ // `inkInput.ts` doesn't have to re-walk the submodule overview;
38561
+ // undefined whenever the cursored file isn't a registered
38562
+ // submodule (or the overview / repo root haven't loaded yet).
38563
+ commitDiffSubmoduleDrillIn: state.diffSource === 'commit' && selectedDetailFile
38564
+ ? resolveCommitDiffDrillInTarget({
38565
+ selectedFile: {
38566
+ path: selectedDetailFile.path,
38567
+ submoduleChange: filePreview?.path === selectedDetailFile.path
38568
+ ? filePreview.submoduleChange
38569
+ : undefined,
38570
+ },
38571
+ submodules: context.submodules,
38572
+ activeRepoRoot,
38573
+ })
38574
+ : undefined,
38575
+ // #931 PR 4 / #932 — Submodule drill-in target for the cursored
38576
+ // row in the dedicated submodules view. Resolved per-render so
38577
+ // the Enter handler in `inkInput.ts` doesn't have to re-walk the
38578
+ // submodule overview. Gated on `activeView === 'submodules'` so
38579
+ // a stale resolution from a different view can't accidentally
38580
+ // fire — the runtime only ever populates it when the user is
38581
+ // actually on the view.
38582
+ submoduleViewDrillIn: state.activeView === 'submodules'
38583
+ ? resolveSubmoduleViewDrillInTarget({
38584
+ selectedIndex: state.selectedSubmoduleIndex,
38585
+ submodules: context.submodules,
38586
+ activeRepoRoot,
38587
+ })
38588
+ : undefined,
37529
38589
  worktreeDirty,
37530
38590
  conflictFileCount: context.operation?.conflictedFiles.length,
37531
38591
  conflictSelectedPath: (() => {
@@ -38274,6 +39334,27 @@ function formatCommitDetail(detail, format) {
38274
39334
  ].join('\n');
38275
39335
  }
38276
39336
 
39337
+ /**
39338
+ * Friendly empty-repo message for the non-interactive log path.
39339
+ *
39340
+ * In `--json` mode we emit an empty array so machine consumers see a
39341
+ * well-defined "no commits" result without a parse error. In table
39342
+ * mode we print a human one-liner that names the next-step commands
39343
+ * the user is likely after. Either way we exit 0 — "no commits" is
39344
+ * a valid repo state, not a failure.
39345
+ */
39346
+ function formatEmptyRepoResult(format) {
39347
+ if (format === 'json') {
39348
+ return '[]';
39349
+ }
39350
+ return [
39351
+ "No commits yet — this looks like a fresh `git init`'d repo.",
39352
+ '',
39353
+ 'Get started:',
39354
+ ' • `coco commit` to draft your first commit message with AI',
39355
+ ' • `git commit -m "chore: initial commit"` to commit by hand',
39356
+ ].join('\n');
39357
+ }
38277
39358
  const handler$3 = async (argv) => {
38278
39359
  // `--repo <dir>` (alias `--cwd`) — apply the global flag via the
38279
39360
  // shared helper. After this returns, `process.cwd()` and the git
@@ -38289,6 +39370,22 @@ const handler$3 = async (argv) => {
38289
39370
  });
38290
39371
  return;
38291
39372
  }
39373
+ // Empty-repo short-circuit. Without this, the underlying `git log`
39374
+ // crashes the command and the user sees a raw "fatal: your current
39375
+ // branch 'main' does not have any commits yet" + a generic "Failed
39376
+ // to execute command" banner. We catch the unborn-HEAD state and
39377
+ // emit a friendly next-step hint (or an empty array in JSON mode)
39378
+ // and exit 0 — "no commits" is a valid repo state, not an error.
39379
+ //
39380
+ // Only applies to the non-interactive path: the TUI runtime gets
39381
+ // its own empty-state rendering inside the workstation.
39382
+ if (!argv.interactive && (await isEmptyRepo(git))) {
39383
+ await handleResult({
39384
+ result: formatEmptyRepoResult(format),
39385
+ mode: 'stdout',
39386
+ });
39387
+ return;
39388
+ }
38292
39389
  // Interactive path defers the commit log fetch into the runtime
38293
39390
  // (#808) so the TUI mounts immediately with a "Loading commits…"
38294
39391
  // placeholder. The non-interactive (stdout) path still needs rows