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
@@ -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.51.0";
64
+ const BUILD_VERSION = "0.53.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -8212,9 +8212,40 @@ async function getCommitLogAgainstTag({ git, logger, targetTag, }) {
8212
8212
  return [];
8213
8213
  }
8214
8214
 
8215
+ async function refExists(git, ref) {
8216
+ try {
8217
+ // `--verify --quiet` suppresses stderr noise on a missing ref and
8218
+ // emits the resolved sha on stdout when it exists. simple-git
8219
+ // returns an empty string (rather than throwing) when git exits
8220
+ // 1 under `--quiet`, so the presence/absence check is on the
8221
+ // output, not on whether the call rejected.
8222
+ const out = await git.raw(['rev-parse', '--verify', '--quiet', ref]);
8223
+ return out.trim().length > 0;
8224
+ }
8225
+ catch {
8226
+ return false;
8227
+ }
8228
+ }
8215
8229
  /**
8216
8230
  * Retrieves the commit log for the current branch.
8217
8231
  *
8232
+ * Edge states that are not errors and should not be reported as such:
8233
+ *
8234
+ * - Detached HEAD (including mid-rebase and mid-bisect, which both
8235
+ * leave HEAD detached). There is no "current branch" to compare
8236
+ * against; the helper logs a yellow status line and returns [].
8237
+ * - Comparison ref missing — e.g. the repo has no `origin` remote,
8238
+ * so `origin/main` does not resolve; or the local comparison
8239
+ * branch (`main`) simply does not exist. Previously this threw
8240
+ * and surfaced as a red "Encountered an error" banner. Now we
8241
+ * probe the ref up front and report a clean status line.
8242
+ * - Empty rev-list output. The previous yellow "Unable to determine
8243
+ * first and last commit" wording read like an error; it's just
8244
+ * "no commits ahead of the comparison ref", which is the normal
8245
+ * outcome when the branch is at or behind its baseline.
8246
+ *
8247
+ * The catch block is reserved for genuinely unexpected git failures.
8248
+ *
8218
8249
  * @param {Object} options - The options for retrieving the commit log.
8219
8250
  * @param {SimpleGit} options.git - The SimpleGit instance.
8220
8251
  * @param {Logger} options.logger - The logger for logging messages.
@@ -8223,43 +8254,53 @@ async function getCommitLogAgainstTag({ git, logger, targetTag, }) {
8223
8254
  * @returns {Promise<CommitDetails[]>} The array of commit messages in the commit log.
8224
8255
  */
8225
8256
  async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main', comparisonRemote = 'origin', }) {
8257
+ const branchName = await getCurrentBranchName({ git });
8258
+ // Detached HEAD: `git rev-parse --abbrev-ref HEAD` returns the literal
8259
+ // string 'HEAD' in this state. Also covers mid-rebase and mid-bisect,
8260
+ // which both detach HEAD onto the picked / midpoint commit. There's
8261
+ // no branch to compare against, so don't pretend there was an error.
8262
+ if (!branchName || branchName === 'HEAD') {
8263
+ logger?.log('HEAD is detached (or a rebase / bisect is in progress) — no branch context to compare against.', { color: 'yellow' });
8264
+ return [];
8265
+ }
8226
8266
  try {
8227
- const branchName = await getCurrentBranchName({ git });
8228
- const hasCommits = (await git.raw(['rev-list', '--count', branchName])) !== '0';
8229
- if (!hasCommits) {
8230
- logger?.log('No commits on the current branch.');
8231
- return [];
8232
- }
8233
- let uniqueCommits;
8267
+ let comparisonRef;
8234
8268
  if (comparisonBranch === branchName) {
8235
- // If the comparison branch is the same as the current branch, we compare against the remote.
8236
- uniqueCommits = (await git.raw(['rev-list', `${comparisonRemote}/${comparisonBranch}..${branchName}`]))
8237
- .split('\n')
8238
- .filter(Boolean)
8239
- .reverse();
8269
+ // Same branch as the comparison target compare against the
8270
+ // remote-tracking ref. If the remote (or the ref) does not
8271
+ // exist, fall back to a clean status line rather than throwing.
8272
+ const remoteRef = `${comparisonRemote}/${comparisonBranch}`;
8273
+ if (!(await refExists(git, remoteRef))) {
8274
+ logger?.log(`No "${remoteRef}" ref to compare against — skipping changelog for "${branchName}".`, { color: 'yellow' });
8275
+ return [];
8276
+ }
8277
+ comparisonRef = remoteRef;
8240
8278
  }
8241
8279
  else {
8242
- // Your existing code for different branches
8243
- uniqueCommits = (await git.raw(['rev-list', `${comparisonBranch}..${branchName}`]))
8244
- .split('\n')
8245
- .filter(Boolean)
8246
- .reverse();
8280
+ if (!(await refExists(git, comparisonBranch))) {
8281
+ logger?.log(`Comparison branch "${comparisonBranch}" does not exist — skipping changelog for "${branchName}".`, { color: 'yellow' });
8282
+ return [];
8283
+ }
8284
+ comparisonRef = comparisonBranch;
8247
8285
  }
8248
- logger?.verbose(`Found ${uniqueCommits.length} unique commits on "${branchName}"`, {
8249
- color: 'blue',
8250
- });
8286
+ const uniqueCommits = (await git.raw(['rev-list', `${comparisonRef}..${branchName}`]))
8287
+ .split('\n')
8288
+ .filter(Boolean)
8289
+ .reverse();
8290
+ logger?.verbose(`Found ${uniqueCommits.length} unique commits on "${branchName}" vs "${comparisonRef}"`, { color: 'blue' });
8251
8291
  const firstCommit = uniqueCommits[0];
8252
8292
  const lastCommit = uniqueCommits[uniqueCommits.length - 1];
8253
8293
  if (!firstCommit || !lastCommit) {
8254
- logger?.log('Unable to determine first and last commit on the current branch', {
8255
- color: 'yellow',
8256
- });
8294
+ // Empty rev-list output is the normal outcome when the branch is
8295
+ // at or behind its baseline. Not an error.
8296
+ logger?.log(`No commits on "${branchName}" ahead of "${comparisonRef}".`);
8257
8297
  return [];
8258
8298
  }
8259
8299
  return await getCommitLogRangeDetails(firstCommit, lastCommit, { git, noMerges: true });
8260
8300
  }
8261
8301
  catch (error) {
8262
8302
  logger?.log('Encountered an error getting commit log from current branch', { color: 'red' });
8303
+ logger?.verbose(error instanceof Error ? error.message : String(error), { color: 'red' });
8263
8304
  }
8264
8305
  return [];
8265
8306
  }
@@ -14327,7 +14368,13 @@ const handler$9 = async (argv, logger) => {
14327
14368
  return `## Diff for ${data.branch}\n\n${diffSummary}`;
14328
14369
  }
14329
14370
  if (!data.commits || data.commits.length === 0) {
14330
- return `## ${data.branch}\n\nNo commits found.`;
14371
+ // Short-circuit with an empty context so the review loop drops
14372
+ // into `noResult` instead of spending an LLM call summarising
14373
+ // "No commits found." into a fake changelog entry. The
14374
+ // upstream helper (getCommitLogCurrentBranch) already logged
14375
+ // the reason (detached HEAD, missing comparison ref, branch at
14376
+ // baseline, etc.) in a friendly status line.
14377
+ return '';
14331
14378
  }
14332
14379
  let result = `## ${data.branch}\n\n`;
14333
14380
  result += data.commits.map(commit => {
@@ -14404,10 +14451,15 @@ const handler$9 = async (argv, logger) => {
14404
14451
  },
14405
14452
  noResult: async () => {
14406
14453
  if (config.range) {
14407
- logger.log(`No commits found in the provided range.`, { color: 'red' });
14454
+ logger.log(`No commits found in the provided range.`, { color: 'yellow' });
14408
14455
  commandExit(0);
14409
14456
  }
14410
- logger.log(`No commits found in the current branch.`, { color: 'red' });
14457
+ // Yellow rather than red — for the no-commits-on-current-branch
14458
+ // case the upstream helper has already explained the reason in
14459
+ // a friendly status line (detached HEAD, no comparison ref,
14460
+ // branch at baseline). This is the trailing summary, not an
14461
+ // error.
14462
+ logger.log(`No commits found in the current branch.`, { color: 'yellow' });
14411
14463
  commandExit(0);
14412
14464
  },
14413
14465
  });
@@ -15560,6 +15612,7 @@ Structural rules:
15560
15612
  - Only use hunk IDs LITERALLY copied from the "Staged hunk inventory" section below. Do not invent or guess hunk IDs.
15561
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.
15562
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).
15563
15616
 
15564
15617
  Commit message style:
15565
15618
  - Write each "title" in the imperative mood ("add", not "added"), under 72 chars.
@@ -17892,6 +17945,38 @@ const builder$4 = (yargs) => {
17892
17945
  return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
17893
17946
  };
17894
17947
 
17948
+ /**
17949
+ * Detect whether the repository has any commits yet.
17950
+ *
17951
+ * A "fresh" repo (one created by `git init` with no commits) has an
17952
+ * **unborn HEAD** — the `main` (or configured-default) branch ref
17953
+ * exists symbolically but doesn't point at any object. Any plumbing
17954
+ * command that tries to resolve HEAD (`git log`, `git show`, `git
17955
+ * rev-list`) fails fatally on such a repo with `fatal: your current
17956
+ * branch '<X>' does not have any commits yet`.
17957
+ *
17958
+ * Without an explicit pre-check, callers crash with that raw error
17959
+ * (see {@link ../utils/commandExecutor} — the generic-error path
17960
+ * just prints whatever was thrown). This helper lets a command
17961
+ * short-circuit to a friendly "no commits yet" message instead.
17962
+ *
17963
+ * Implementation uses `git rev-parse --verify HEAD` because it's the
17964
+ * cheapest "does HEAD resolve?" probe — no log walk, no working-tree
17965
+ * scan. Returns `true` when rev-parse rejects (unborn HEAD) and
17966
+ * `false` when it succeeds.
17967
+ *
17968
+ * @returns `true` when HEAD is unborn (no commits); `false` otherwise.
17969
+ */
17970
+ async function isEmptyRepo(git) {
17971
+ try {
17972
+ await git.revparse(['--verify', 'HEAD']);
17973
+ return false;
17974
+ }
17975
+ catch {
17976
+ return true;
17977
+ }
17978
+ }
17979
+
17895
17980
  /**
17896
17981
  * Git LFS pointer parsing + diff summarization (#884).
17897
17982
  *
@@ -18310,6 +18395,19 @@ function buildToggleGraphArgs(argv, fullGraph) {
18310
18395
  return { ...argv, view: argv.view ?? 'compact' };
18311
18396
  }
18312
18397
  async function getLogRows(git, argv, options = {}) {
18398
+ // Unborn HEAD short-circuit. Without this, `git log` on a freshly
18399
+ // `git init`'d repo throws "fatal: your current branch 'main' does
18400
+ // not have any commits yet" — fine when the caller can catch and
18401
+ // translate, painful otherwise (the workstation runtime surfaces it
18402
+ // as "Failed to load commits: fatal: ..." in the status line).
18403
+ //
18404
+ // Returning [] is the natural contract: callers that already render
18405
+ // an empty-history surface (`formatLogInkHistoryEmpty`) get the
18406
+ // right experience automatically; `coco log` retains its own
18407
+ // friendlier message via the handler's isEmptyRepo check.
18408
+ if (await isEmptyRepo(git)) {
18409
+ return [];
18410
+ }
18313
18411
  return parseLogOutput(await git.raw(buildLogArgs(argv, options)));
18314
18412
  }
18315
18413
  async function getCommitDetail(git, commit) {
@@ -18381,6 +18479,7 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
18381
18479
  deletions: file.deletions,
18382
18480
  },
18383
18481
  hunks: finalHunks,
18482
+ submoduleChange,
18384
18483
  };
18385
18484
  }
18386
18485
 
@@ -20970,6 +21069,37 @@ function getLogInkWorkflowActions() {
20970
21069
  kind: 'normal',
20971
21070
  requiresConfirmation: false,
20972
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
+ },
20973
21103
  {
20974
21104
  // Per-view-only — the inkInput handler scopes this to the tags
20975
21105
  // surface so we don't expose `R` as a remote-delete from elsewhere.
@@ -21793,6 +21923,56 @@ function formatLogInkBreadcrumb(viewStack) {
21793
21923
  // they're nested deeper than the root view.
21794
21924
  return `${viewStack.join(' › ')} ← <`;
21795
21925
  }
21926
+ /**
21927
+ * Render the nested-repo navigation stack (#931) as a breadcrumb suitable
21928
+ * for the chrome header. Returns an empty string for a root-only stack
21929
+ * so the header stays compact when nothing has been pushed.
21930
+ *
21931
+ * The trailing `← esc` reminds the user that Esc is the way out — same
21932
+ * shape as the view breadcrumb's `← <` so the two read consistently.
21933
+ * The repo breadcrumb shows in addition to the view breadcrumb when
21934
+ * both stacks are non-trivial; the chrome layer is responsible for
21935
+ * laying them out side by side.
21936
+ *
21937
+ * Examples:
21938
+ * `[root]` → ''
21939
+ * `[coco, vendor/lib]` → 'coco › vendor/lib ← esc'
21940
+ * `[coco, vendor/lib, deep]` → 'coco › vendor/lib › deep ← esc'
21941
+ */
21942
+ function formatLogInkRepoBreadcrumb(repoStack) {
21943
+ if (repoStack.length <= 1) {
21944
+ return '';
21945
+ }
21946
+ return `${repoStack.map((frame) => frame.label).join(' › ')} ← esc`;
21947
+ }
21948
+ /**
21949
+ * Combine the repo-stack and view-stack breadcrumb segments for the
21950
+ * header chrome (#931). Each segment is independently rendered by its
21951
+ * formatter and may be empty; this helper interleaves the leading
21952
+ * spacing so the header builder doesn't have to branch on four cases.
21953
+ *
21954
+ * repoCrumb='' viewCrumb='' → ''
21955
+ * repoCrumb='X' viewCrumb='' → ' X'
21956
+ * repoCrumb='' viewCrumb='Y' → ' Y'
21957
+ * repoCrumb='X' viewCrumb='Y' → ' X Y'
21958
+ *
21959
+ * Two leading spaces match the existing chrome — they separate the
21960
+ * breadcrumb from the trailing repo/branch segment in the title row.
21961
+ * Four spaces between segments give the repo crumb visual breathing
21962
+ * room before the view crumb begins.
21963
+ */
21964
+ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
21965
+ if (repoCrumb && viewCrumb) {
21966
+ return ` ${repoCrumb} ${viewCrumb}`;
21967
+ }
21968
+ if (repoCrumb) {
21969
+ return ` ${repoCrumb}`;
21970
+ }
21971
+ if (viewCrumb) {
21972
+ return ` ${viewCrumb}`;
21973
+ }
21974
+ return '';
21975
+ }
21796
21976
  function getLogInkFooterHints(options) {
21797
21977
  if (options.pendingKey) {
21798
21978
  const continuations = getLogInkChordContinuations(options.pendingKey);
@@ -21860,8 +22040,22 @@ function getLogInkFooterHints(options) {
21860
22040
  // "enter open" hint that drills into the dedicated view.
21861
22041
  const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
21862
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.
21863
22053
  return {
21864
- 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
+ ],
21865
22059
  global: NORMAL_GLOBAL_HINTS,
21866
22060
  };
21867
22061
  }
@@ -22547,6 +22741,93 @@ function withPoppedView(state) {
22547
22741
  pendingKey: undefined,
22548
22742
  };
22549
22743
  }
22744
+ /**
22745
+ * Push a nested-repo frame onto `state.repoStack` (#931). Snapshots
22746
+ * the active view position into the new frame's `parentReturn` so a
22747
+ * subsequent pop lands the user back where they came from, then
22748
+ * resets the per-frame navigation state (active view, view stack,
22749
+ * row / file / submodule cursors, filter) so the nested frame opens
22750
+ * in a clean slate — the mental equivalent of a fresh `coco ui`
22751
+ * launched against the submodule's working dir.
22752
+ *
22753
+ * Carry-over preferences (sidebar tab, branch / tag sort, palette
22754
+ * recents, inspector tab, diff view mode) are intentionally left
22755
+ * untouched. They're user-level choices that should persist across
22756
+ * frames, the same way they persist across view pushes today.
22757
+ *
22758
+ * Live runtime objects (`SimpleGit`, loaded `LogInkContext`) live
22759
+ * outside the reducer in `app.ts`'s parallel ref structure — this
22760
+ * helper only manages the pure view-model side of the push.
22761
+ */
22762
+ function withPushedRepoFrame(state, payload) {
22763
+ const newFrame = {
22764
+ label: payload.label,
22765
+ workdir: payload.workdir,
22766
+ entryRange: payload.entryRange,
22767
+ parentReturn: {
22768
+ activeView: state.activeView,
22769
+ selectedIndex: state.selectedIndex,
22770
+ selectedFileIndex: state.selectedFileIndex,
22771
+ selectedSubmoduleIndex: state.selectedSubmoduleIndex,
22772
+ filter: state.filter,
22773
+ },
22774
+ };
22775
+ return {
22776
+ ...state,
22777
+ repoStack: [...state.repoStack, newFrame],
22778
+ activeView: 'history',
22779
+ viewStack: ['history'],
22780
+ selectedIndex: 0,
22781
+ selectedFileIndex: 0,
22782
+ selectedSubmoduleIndex: 0,
22783
+ filter: '',
22784
+ filterMode: false,
22785
+ pendingCommitFocused: false,
22786
+ pendingKey: undefined,
22787
+ pendingConfirmationId: undefined,
22788
+ pendingConfirmationPayload: undefined,
22789
+ pendingMutationConfirmation: undefined,
22790
+ };
22791
+ }
22792
+ /**
22793
+ * Pop the top repo frame off `state.repoStack` (#931) and restore
22794
+ * the parent's view position from the captured `parentReturn`. A
22795
+ * no-op when the stack is already at its single root frame so this
22796
+ * action is safe to dispatch from generic input handlers (e.g. the
22797
+ * Esc auto-pop wiring that lands in a follow-up PR).
22798
+ *
22799
+ * The defensive `parentReturn` fallback handles the never-supposed-
22800
+ * to-happen case where a non-root frame somehow has no return state
22801
+ * recorded — drop the frame but leave the user's view position
22802
+ * alone rather than crash mid-session.
22803
+ */
22804
+ function withPoppedRepoFrame(state) {
22805
+ if (state.repoStack.length <= 1) {
22806
+ return { ...state, pendingKey: undefined };
22807
+ }
22808
+ const topFrame = state.repoStack[state.repoStack.length - 1];
22809
+ const ret = topFrame.parentReturn;
22810
+ const repoStack = state.repoStack.slice(0, -1);
22811
+ if (!ret) {
22812
+ return { ...state, repoStack, pendingKey: undefined };
22813
+ }
22814
+ return {
22815
+ ...state,
22816
+ repoStack,
22817
+ activeView: ret.activeView,
22818
+ viewStack: [ret.activeView],
22819
+ selectedIndex: ret.selectedIndex,
22820
+ selectedFileIndex: ret.selectedFileIndex,
22821
+ selectedSubmoduleIndex: ret.selectedSubmoduleIndex,
22822
+ filter: ret.filter,
22823
+ filterMode: false,
22824
+ pendingCommitFocused: false,
22825
+ pendingKey: undefined,
22826
+ pendingConfirmationId: undefined,
22827
+ pendingConfirmationPayload: undefined,
22828
+ pendingMutationConfirmation: undefined,
22829
+ };
22830
+ }
22550
22831
  function withReplacedView(state, value) {
22551
22832
  if (topOfStack(state.viewStack) === value) {
22552
22833
  return { ...state, pendingKey: undefined };
@@ -22691,7 +22972,7 @@ function createLogInkState(rows, options = {}) {
22691
22972
  selectedPullRequestTriageIndex: 0,
22692
22973
  selectedIssueFilter: 'open',
22693
22974
  selectedPullRequestFilter: 'open',
22694
- repoStack: [{ label: options.repoLabel || 'root' }],
22975
+ repoStack: [{ label: options.repoLabel || 'root', workdir: options.repoWorkdir }],
22695
22976
  branchSort: DEFAULT_BRANCH_SORT_MODE,
22696
22977
  tagSort: DEFAULT_TAG_SORT_MODE,
22697
22978
  paletteFilter: '',
@@ -22704,6 +22985,7 @@ function createLogInkState(rows, options = {}) {
22704
22985
  filterMode: false,
22705
22986
  fullGraph: false,
22706
22987
  showHelp: false,
22988
+ helpScrollOffset: 0,
22707
22989
  showCommandPalette: false,
22708
22990
  workflowActionId: undefined,
22709
22991
  pendingConfirmationId: undefined,
@@ -22711,8 +22993,13 @@ function createLogInkState(rows, options = {}) {
22711
22993
  pendingMutationConfirmation: undefined,
22712
22994
  pendingKey: undefined,
22713
22995
  focus: 'commits',
22714
- sidebarTab: 'status',
22715
- userSidebarTab: 'status',
22996
+ // Default first-time tab is 'branches' — it's the most useful
22997
+ // landing surface in the workstation (current branch + recent
22998
+ // branches with ahead/behind, switch target, etc.). Users who
22999
+ // pick a different tab have their choice persisted per-repo via
23000
+ // sidebarPersistence.ts and won't see this default again.
23001
+ sidebarTab: 'branches',
23002
+ userSidebarTab: 'branches',
22716
23003
  sidebarHeaderFocused: false,
22717
23004
  statusGroupHeaderFocused: false,
22718
23005
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
@@ -22733,6 +23020,15 @@ function getSelectedInkCommit(state) {
22733
23020
  }
22734
23021
  return state.filteredCommits[state.selectedIndex];
22735
23022
  }
23023
+ /**
23024
+ * True when the user has drilled into a submodule (or deeper).
23025
+ * Drives the chrome breadcrumb's display and any future
23026
+ * frame-aware behavior that wants to know "are we in a nested
23027
+ * frame?" without inspecting the stack directly.
23028
+ */
23029
+ function isLogInkNestedRepo(state) {
23030
+ return state.repoStack.length > 1;
23031
+ }
22736
23032
  function applyLogInkAction(state, action) {
22737
23033
  switch (action.type) {
22738
23034
  case 'appendRows':
@@ -23155,6 +23451,14 @@ function applyLogInkAction(state, action) {
23155
23451
  return withPoppedView(state);
23156
23452
  case 'replaceView':
23157
23453
  return withReplacedView(state, action.value);
23454
+ case 'pushRepoFrame':
23455
+ return withPushedRepoFrame(state, {
23456
+ label: action.label,
23457
+ workdir: action.workdir,
23458
+ entryRange: action.entryRange,
23459
+ });
23460
+ case 'popRepoFrame':
23461
+ return withPoppedRepoFrame(state);
23158
23462
  case 'navigateHome': {
23159
23463
  if (state.viewStack.length === 1 && topOfStack(state.viewStack) === HOME_VIEW) {
23160
23464
  return { ...state, pendingKey: undefined };
@@ -23326,6 +23630,7 @@ function applyLogInkAction(state, action) {
23326
23630
  filterMode: !state.filterMode,
23327
23631
  showCommandPalette: false,
23328
23632
  showHelp: false,
23633
+ helpScrollOffset: 0,
23329
23634
  pendingKey: undefined,
23330
23635
  };
23331
23636
  case 'toggleGraph':
@@ -23334,19 +23639,35 @@ function applyLogInkAction(state, action) {
23334
23639
  fullGraph: !state.fullGraph,
23335
23640
  pendingKey: undefined,
23336
23641
  };
23337
- case 'toggleHelp':
23642
+ case 'toggleHelp': {
23643
+ const opening = !state.showHelp;
23338
23644
  return {
23339
23645
  ...state,
23340
- showHelp: !state.showHelp,
23646
+ showHelp: opening,
23647
+ // Reset scroll position when toggling either direction so the
23648
+ // next open always starts at the top — feels more predictable
23649
+ // than picking up where the user last scrolled.
23650
+ helpScrollOffset: 0,
23341
23651
  showCommandPalette: false,
23342
23652
  pendingKey: undefined,
23343
23653
  };
23654
+ }
23655
+ case 'scrollHelp':
23656
+ // No upper-bound clamp here — the renderer caps the offset
23657
+ // against the actual content height at render time. The
23658
+ // reducer just prevents going below 0 so callers can safely
23659
+ // pass negative deltas without us going past the top.
23660
+ return {
23661
+ ...state,
23662
+ helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta),
23663
+ };
23344
23664
  case 'toggleCommandPalette': {
23345
23665
  const opening = !state.showCommandPalette;
23346
23666
  return {
23347
23667
  ...state,
23348
23668
  showCommandPalette: opening,
23349
23669
  showHelp: false,
23670
+ helpScrollOffset: 0,
23350
23671
  // Reset palette interaction state on every open/close so the next
23351
23672
  // session starts from a clean slate.
23352
23673
  paletteFilter: '',
@@ -23993,6 +24314,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
23993
24314
  value: 'open branches / tags / history and press m on the cursored ref',
23994
24315
  })];
23995
24316
  case 'navigateBack':
24317
+ // Mirror the Esc / `<` semantics (#931): drain the frame's view
24318
+ // stack first, then pop the frame itself when nested.
24319
+ if (state.viewStack.length > 1) {
24320
+ return [action({ type: 'popView' })];
24321
+ }
24322
+ if (isLogInkNestedRepo(state)) {
24323
+ return [action({ type: 'popRepoFrame' })];
24324
+ }
23996
24325
  return [action({ type: 'popView' })];
23997
24326
  case 'openSelected': {
23998
24327
  // From history → diff for selected commit; from status → diff for
@@ -24540,8 +24869,37 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24540
24869
  }
24541
24870
  return [];
24542
24871
  }
24543
- if (key.escape && state.showHelp) {
24544
- return [action({ type: 'toggleHelp' })];
24872
+ // Help-overlay key handling. While help is open we intercept ALL
24873
+ // keys here and return before they can fall through to scroll /
24874
+ // focus / navigation logic below. Without this, j/k while help is
24875
+ // open routes into `moveDetailFile`-style handlers, which mutates
24876
+ // focus state (`focus: 'detail'` → `'commits'` or `'sidebar'`) —
24877
+ // exactly the "scroll loses focus" bug.
24878
+ //
24879
+ // Allowed: Esc / ? (close), q (quit), j/k/arrows (scroll), Ctrl-d/u
24880
+ // (half-page). Everything else is swallowed by the trailing
24881
+ // `return []` so a stray keypress can't drop the user into the
24882
+ // wrong surface.
24883
+ if (state.showHelp) {
24884
+ if (key.escape || inputValue === '?') {
24885
+ return [action({ type: 'toggleHelp' })];
24886
+ }
24887
+ if (inputValue === 'q') {
24888
+ return [{ type: 'exit' }];
24889
+ }
24890
+ if (key.downArrow || inputValue === 'j') {
24891
+ return [action({ type: 'scrollHelp', delta: 1 })];
24892
+ }
24893
+ if (key.upArrow || inputValue === 'k') {
24894
+ return [action({ type: 'scrollHelp', delta: -1 })];
24895
+ }
24896
+ if (key.ctrl && inputValue === 'd') {
24897
+ return [action({ type: 'scrollHelp', delta: 10 })];
24898
+ }
24899
+ if (key.ctrl && inputValue === 'u') {
24900
+ return [action({ type: 'scrollHelp', delta: -10 })];
24901
+ }
24902
+ return [];
24545
24903
  }
24546
24904
  // #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
24547
24905
  // BEFORE the generic `popView` so we both clear the wizard state
@@ -24562,6 +24920,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24562
24920
  if (key.escape && state.viewStack.length > 1) {
24563
24921
  return [action({ type: 'popView' })];
24564
24922
  }
24923
+ // #931 — Esc auto-pop. When the user has drilled into a submodule
24924
+ // (nested repo frame) AND they're at the root of that frame's own
24925
+ // view stack, Esc walks back out to the parent repo. Ordered after
24926
+ // the view-stack pop above so Esc still drains a frame's view stack
24927
+ // before popping the frame itself — the user sees a predictable
24928
+ // "back, back, back" path out.
24929
+ if (key.escape && isLogInkNestedRepo(state)) {
24930
+ return [action({ type: 'popRepoFrame' })];
24931
+ }
24565
24932
  if (inputValue === 'q') {
24566
24933
  if (hasUnsavedComposeDraft(state)) {
24567
24934
  return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
@@ -24844,6 +25211,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24844
25211
  return [action({ type: 'toggleGraph' })];
24845
25212
  }
24846
25213
  if (inputValue === '<') {
25214
+ // #931 — `<` is the keymap-driven mirror of Esc auto-pop. When the
25215
+ // view stack has somewhere to go, pop a view; otherwise, if we're
25216
+ // in a nested submodule frame, walk back out to the parent. The
25217
+ // `popView` action is itself a no-op at the root of a frame's
25218
+ // view stack, so this ordering can't double-pop.
25219
+ if (state.viewStack.length > 1) {
25220
+ return [action({ type: 'popView' })];
25221
+ }
25222
+ if (isLogInkNestedRepo(state)) {
25223
+ return [action({ type: 'popRepoFrame' })];
25224
+ }
24847
25225
  return [action({ type: 'popView' })];
24848
25226
  }
24849
25227
  if (inputValue === 'G') {
@@ -25326,6 +25704,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
25326
25704
  }
25327
25705
  }
25328
25706
  }
25707
+ // #931 PR 3b — Enter on a submodule file in a commit diff drills into
25708
+ // the submodule's history (the "spawn a coco ui scoped to the
25709
+ // submodule" mental model from the design doc). The runtime decides
25710
+ // whether the cursored file is a drill-in candidate and resolves the
25711
+ // workdir + entryRange ahead of time; the handler here only fires
25712
+ // when that target is populated. Ordered before the generic file-
25713
+ // list Enter handler so the drill-in takes precedence over the
25714
+ // detail-panel diff-refocus path.
25715
+ if (key.return &&
25716
+ state.activeView === 'diff' &&
25717
+ state.diffSource === 'commit' &&
25718
+ context.commitDiffSubmoduleDrillIn) {
25719
+ const target = context.commitDiffSubmoduleDrillIn;
25720
+ return [
25721
+ action({
25722
+ type: 'pushRepoFrame',
25723
+ label: target.label,
25724
+ workdir: target.workdir,
25725
+ entryRange: target.entryRange,
25726
+ }),
25727
+ action({ type: 'setStatus', value: `entering submodule ${target.label}` }),
25728
+ ];
25729
+ }
25730
+ // #931 PR 4 / #932 — Enter on a row in the dedicated submodules view
25731
+ // drills into that submodule's history. Same mental model as the
25732
+ // commit-diff drill-in (PR 3b) — pushing a frame is the equivalent
25733
+ // of `cd vendor/lib && coco ui`. No entry range here; the submodules
25734
+ // view doesn't carry diff context, so the frame lands on the
25735
+ // submodule's full history.
25736
+ if (key.return &&
25737
+ isSubmodulesActionTarget(state) &&
25738
+ context.submoduleViewDrillIn) {
25739
+ const target = context.submoduleViewDrillIn;
25740
+ return [
25741
+ action({
25742
+ type: 'pushRepoFrame',
25743
+ label: target.label,
25744
+ workdir: target.workdir,
25745
+ }),
25746
+ action({ type: 'setStatus', value: `entering submodule ${target.label}` }),
25747
+ ];
25748
+ }
25329
25749
  if (key.return &&
25330
25750
  state.activeView === 'history' &&
25331
25751
  state.focus === 'commits' &&
@@ -26064,6 +26484,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
26064
26484
  events.push({ type: 'createManualCommit' });
26065
26485
  return events;
26066
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
+ }
26067
26508
  const workflowAction = getLogInkWorkflowActionByKey(inputValue);
26068
26509
  if (workflowAction?.requiresConfirmation) {
26069
26510
  return [action({ type: 'setPendingConfirmation', value: workflowAction.id })];
@@ -26233,6 +26674,199 @@ function pickSpinnerFrame(tick) {
26233
26674
  return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
26234
26675
  }
26235
26676
 
26677
+ /**
26678
+ * Build the initial `LogInkContextStatus` for a freshly-created frame
26679
+ * (#931). Every fetched key starts in `'loading'` so surfaces show the
26680
+ * loading hint immediately; `pullRequest` is the exception (#808) —
26681
+ * it's lazy-loaded on entry to the PR view, so we seed it `'idle'`
26682
+ * instead of leaving it stuck as a permanent "loading" flag in the
26683
+ * chrome.
26684
+ *
26685
+ * Extracted so the root runtime (built at boot inside `LogInkApp`) and
26686
+ * the per-frame factory below share one canonical seed. The status
26687
+ * surfaces depend on the exact `'pullRequest' = 'idle'` initialization
26688
+ * to avoid spurious loading hints; locking it down in one helper means
26689
+ * the two code paths can't drift.
26690
+ */
26691
+ function createInitialContextStatus() {
26692
+ return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
26693
+ }
26694
+ /**
26695
+ * Factory that builds a fresh `RepoFrameRuntime` for a newly-pushed
26696
+ * frame (#931). The frame's `workdir` (set by the push action) drives
26697
+ * which working tree the `SimpleGit` instance binds against:
26698
+ *
26699
+ * - **Has workdir** → `simpleGit(workdir)`. Production case for any
26700
+ * nested submodule frame.
26701
+ * - **No workdir** → falls back to `rootGit`. Defensive: only the
26702
+ * root frame is expected to lack a workdir, and the root frame's
26703
+ * runtime is built directly from `rootGit` in `LogInkApp`'s state
26704
+ * initializer — this fallback only kicks in if a future push path
26705
+ * forgets to pass `workdir`. Binding to the root keeps the session
26706
+ * functional (the user still sees data) at the cost of the frame
26707
+ * being a duplicate of the root.
26708
+ *
26709
+ * `context` starts empty; `contextStatus` starts in the same initial
26710
+ * "loading + pullRequest idle" shape the root frame seeds with. The
26711
+ * sync effect in `LogInkApp` is responsible for kicking off the
26712
+ * per-key context loads against the new frame's `git`; we don't do
26713
+ * that here so the factory stays pure and unit-testable without a
26714
+ * real repo on disk.
26715
+ */
26716
+ function createRepoFrameRuntime(frame, rootGit) {
26717
+ return {
26718
+ git: frame.workdir ? simpleGit(frame.workdir) : rootGit,
26719
+ context: {},
26720
+ contextStatus: createInitialContextStatus(),
26721
+ };
26722
+ }
26723
+
26724
+ /**
26725
+ * Pure resolver: given the cursored file + the active frame's
26726
+ * submodule overview + repo root, decide whether a commit-diff Enter
26727
+ * keystroke should drill into a submodule and, if so, what payload
26728
+ * the `pushRepoFrame` action should carry.
26729
+ *
26730
+ * Returns undefined when:
26731
+ * - We don't know the active repo root yet (boot still in flight).
26732
+ * - The file's path doesn't correspond to a registered submodule.
26733
+ * - The submodule overview hasn't loaded yet for the active frame.
26734
+ *
26735
+ * The `submoduleChange` on the file preview is the source of truth
26736
+ * for the entry range; we never need to re-run the diff to populate
26737
+ * the (oldSha, newSha) pair.
26738
+ */
26739
+ function resolveCommitDiffDrillInTarget(args) {
26740
+ const { selectedFile, submodules, activeRepoRoot } = args;
26741
+ if (!activeRepoRoot)
26742
+ return undefined;
26743
+ if (!submodules || !submodules.hasSubmodules)
26744
+ return undefined;
26745
+ const entry = findSubmoduleByPath(submodules, selectedFile.path);
26746
+ if (!entry)
26747
+ return undefined;
26748
+ return {
26749
+ label: entry.name,
26750
+ workdir: join$1(activeRepoRoot, entry.path),
26751
+ entryRange: deriveEntryRange(selectedFile.submoduleChange),
26752
+ };
26753
+ }
26754
+ /**
26755
+ * Convert the structured `SubmoduleChange` (from `extractSubmoduleChange`)
26756
+ * into the `entryRange` shape `LogInkRepoFrame` carries. Modified
26757
+ * submodules surface both shas; added / removed surface only one,
26758
+ * which isn't enough to scope a history range — those cases return
26759
+ * undefined and the frame lands on the submodule's full history.
26760
+ */
26761
+ function deriveEntryRange(change) {
26762
+ if (!change)
26763
+ return undefined;
26764
+ if (change.kind === 'modified') {
26765
+ return { oldSha: change.before, newSha: change.after };
26766
+ }
26767
+ return undefined;
26768
+ }
26769
+ /**
26770
+ * Pure resolver for the submodules-view drill-in (#931 PR 4 / #932).
26771
+ * Given the cursored row index + the submodule overview + the active
26772
+ * frame's repo root, build the `pushRepoFrame` payload Enter should
26773
+ * dispatch. Returns undefined when:
26774
+ *
26775
+ * - The active repo root hasn't loaded yet.
26776
+ * - The submodule overview hasn't loaded (or is empty).
26777
+ * - The cursor is past the end of the entries (race between a
26778
+ * refresh that removed a submodule and a key press still in
26779
+ * flight against the old length).
26780
+ * - The cursored entry has no `path` recorded. The `.gitmodules`
26781
+ * parser already filters these out upstream, but the resolver
26782
+ * defends against it so the cursor can't yank the user into a
26783
+ * workdir-less frame.
26784
+ */
26785
+ function resolveSubmoduleViewDrillInTarget(args) {
26786
+ const { selectedIndex, submodules, activeRepoRoot } = args;
26787
+ if (!activeRepoRoot)
26788
+ return undefined;
26789
+ if (!submodules || !submodules.hasSubmodules)
26790
+ return undefined;
26791
+ const entry = submodules.entries[selectedIndex];
26792
+ if (!entry || !entry.path)
26793
+ return undefined;
26794
+ return {
26795
+ label: entry.name,
26796
+ workdir: join$1(activeRepoRoot, entry.path),
26797
+ };
26798
+ }
26799
+
26800
+ /**
26801
+ * Reconcile the per-frame runtime list against the current view-model
26802
+ * stack. Three cases:
26803
+ *
26804
+ * - **No change** — same length, returns `prev` unchanged so React
26805
+ * reference equality skips downstream re-renders.
26806
+ * - **Pop** — stack shrunk, returns `prev.slice(0, stack.length)`.
26807
+ * The dropped runtimes are released to the GC; the surviving
26808
+ * runtimes (root + any intermediate frames) keep their cached
26809
+ * `git` + `context` so a re-push lands on warm state.
26810
+ * - **Push** — stack grew, builds a fresh runtime via the supplied
26811
+ * `createRuntime(frame, depth)` factory for each newly-deeper
26812
+ * frame. The caller is responsible for the factory's content;
26813
+ * this module never imports `simple-git` or `loadLogInkContext`
26814
+ * directly so it stays unit-testable without a real repo on disk.
26815
+ *
26816
+ * Returns `newlyAddedIndices` so the caller's effect knows which
26817
+ * frames need their initial context fetch kicked off. On a no-op or
26818
+ * pop, the list is empty.
26819
+ *
26820
+ * The reducer's `pushRepoFrame` / `popRepoFrame` actions are the only
26821
+ * things that mutate `state.repoStack`; both are monotone — push
26822
+ * appends one, pop drops one — so this helper never needs to handle
26823
+ * "frame at index i changed identity in place." If that invariant ever
26824
+ * loosens, this helper should error rather than silently mis-bind a
26825
+ * `SimpleGit` to the wrong working directory.
26826
+ */
26827
+ function syncRepoStackRuntimes(prev, stack, createRuntime) {
26828
+ if (stack.length < prev.length) {
26829
+ return { runtimes: prev.slice(0, stack.length), newlyAddedIndices: [] };
26830
+ }
26831
+ if (stack.length === prev.length) {
26832
+ return { runtimes: prev, newlyAddedIndices: [] };
26833
+ }
26834
+ const next = prev.slice();
26835
+ const newlyAddedIndices = [];
26836
+ for (let i = prev.length; i < stack.length; i += 1) {
26837
+ next.push(createRuntime(stack[i], i));
26838
+ newlyAddedIndices.push(i);
26839
+ }
26840
+ return { runtimes: next, newlyAddedIndices };
26841
+ }
26842
+ /**
26843
+ * Top-of-stack runtime — the one every active surface, loader, and
26844
+ * action target reads from. Undefined when the runtime list is empty
26845
+ * (which production code never produces — `createLogInkState` always
26846
+ * seeds a root frame, so the corresponding root runtime is built on
26847
+ * mount and the array is non-empty for the lifetime of the session).
26848
+ */
26849
+ function getActiveRepoFrameRuntime(runtimes) {
26850
+ return runtimes[runtimes.length - 1];
26851
+ }
26852
+ /**
26853
+ * Immutably update one frame's runtime entry. Used by the app shell's
26854
+ * loader effects when a frame's `context` or `contextStatus` changes
26855
+ * — replacing the entry in place lets React's referential equality
26856
+ * skip re-renders on unrelated frames.
26857
+ *
26858
+ * Out-of-range indices are no-ops (return `prev` unchanged) so the
26859
+ * caller doesn't have to guard against race-y stack changes between
26860
+ * the load kickoff and the load-complete callback.
26861
+ */
26862
+ function updateRepoFrameRuntime(runtimes, index, updater) {
26863
+ if (index < 0 || index >= runtimes.length)
26864
+ return runtimes;
26865
+ const next = runtimes.slice();
26866
+ next[index] = updater(next[index]);
26867
+ return next;
26868
+ }
26869
+
26236
26870
  /**
26237
26871
  * Persist the user's preferred diff view mode (unified vs side-by-side
26238
26872
  * split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
@@ -26973,6 +27607,106 @@ function pushCurrentBranch(git) {
26973
27607
  function setUpstream(git, localBranch, upstreamBranch) {
26974
27608
  return runAction$5(() => git.raw(['branch', '--set-upstream-to', upstreamBranch, localBranch]), `Set ${localBranch} upstream to ${upstreamBranch}`);
26975
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
+ }
26976
27710
 
26977
27711
  async function runAction$4(action, successMessage) {
26978
27712
  try {
@@ -28496,29 +29230,81 @@ function formatBranchDivergence(branch, options = {}) {
28496
29230
  parts.push(`↓${branch.behind}`);
28497
29231
  return `${parts.join(' ')} ${branch.upstream}`;
28498
29232
  }
28499
- /**
28500
- * Single-cell marker shown to the left of a branch name in lists.
28501
- *
28502
- * - `*` — current branch (regardless of remote state)
28503
- * - `◌` no upstream
28504
- * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
28505
- * - `↕` has upstream + diverged (any non-zero ahead/behind)
28506
- * - ` ` fallback / no info
28507
- *
28508
- * ASCII fallbacks (legible without box-drawing/arrow glyphs):
28509
- * - `?` for "no upstream", `=` for synced, `~` for diverged.
28510
- */
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
+ }
28511
29252
  function branchRowMarker(branch, options = {}) {
28512
- if (branch.current)
28513
- return '*';
28514
- if (!branch.upstream)
28515
- 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
+ }
28516
29259
  const ahead = branch.ahead ?? 0;
28517
29260
  const behind = branch.behind ?? 0;
28518
29261
  if (ahead === 0 && behind === 0) {
28519
- 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;
28520
29307
  }
28521
- return options.ascii ? '~' : '↕';
28522
29308
  }
28523
29309
  /**
28524
29310
  * Compact, human-friendly relative timestamp for the branch row.
@@ -28806,7 +29592,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
28806
29592
  ? ' loading commits'
28807
29593
  : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
28808
29594
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
28809
- const view = breadcrumb ? ` ${breadcrumb}` : '';
29595
+ const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
29596
+ // Repo breadcrumb (when nested) comes first so the user sees which
29597
+ // submodule they're in at a glance, then the view breadcrumb (when
29598
+ // pushed deeper than the root view). The truncate fallback in the
29599
+ // title row still applies — when both fight for space, the ellipsis
29600
+ // lands at the end of whichever segment overflows.
29601
+ const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
28810
29602
  // Mode indicator (P2.2) — surfaces the current input mode so users
28811
29603
  // never wonder why `q` doesn't quit while they're editing or filtering.
28812
29604
  const mode = state.commitCompose.editing
@@ -29120,7 +29912,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
29120
29912
  ];
29121
29913
  return [
29122
29914
  ...headerRows,
29123
- ...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),
29124
29916
  ];
29125
29917
  }
29126
29918
  if (tab === 'tags') {
@@ -29583,21 +30375,22 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
29583
30375
  const isSelected = index === selected;
29584
30376
  const cursor = isSelected ? '>' : ' ';
29585
30377
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
30378
+ const markerColor = getBranchRowMarkerColor(marker.kind, theme);
29586
30379
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
29587
30380
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
29588
30381
  // Split the row into spans so the timestamp stays dim even on the
29589
- // currently-selected (bold) row. The leading marker + name keep
29590
- // their per-window-derived column widths; the timestamp is
29591
- // 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.
29592
30384
  const namePadded = truncateCells(branch.shortName, nameColWidth).padEnd(nameColWidth);
29593
30385
  const timestampPadded = lastTouched.padEnd(8);
29594
30386
  const lineDim = !isSelected && !branch.current;
29595
- const head = `${cursor} ${marker} ${namePadded} `;
30387
+ const cursorAndPad = `${cursor} `;
30388
+ const trailingName = ` ${namePadded} `;
29596
30389
  const trailingDivergence = divergence ? ` ${divergence}` : '';
29597
30390
  // Truncate the assembled line to the actual panel width so a
29598
30391
  // narrow inspector / sidebar focus doesn't push branch rows
29599
30392
  // onto a second visual line (#830).
29600
- const fullText = `${head}${timestampPadded}${trailingDivergence}`;
30393
+ const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
29601
30394
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
29602
30395
  // If truncation chopped into the timestamp/divergence portion,
29603
30396
  // fall back to a single Text to keep the visible width honest.
@@ -29612,7 +30405,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
29612
30405
  key: `branch-${index}`,
29613
30406
  bold: isSelected,
29614
30407
  dimColor: lineDim,
29615
- }, 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);
29616
30417
  });
29617
30418
  return h(Box, {
29618
30419
  borderColor: focusBorderColor(theme, focused),
@@ -30454,30 +31255,52 @@ function filterChippedRefs(refs, chip) {
30454
31255
  return true;
30455
31256
  });
30456
31257
  }
30457
- function getBranchTipChip(refs) {
31258
+ /**
31259
+ * `remoteNames` lets the caller pass the repository's actual remote
31260
+ * names (e.g. `['origin', 'upstream']`) so refs are classified by
31261
+ * remote-prefix rather than by "contains a slash". Without it a local
31262
+ * feature branch like `feat/x` looks identical to a remote-tracking
31263
+ * `origin/x` and gets the wrong colour. When the list is omitted the
31264
+ * function falls back to the legacy slash-as-remote heuristic — the
31265
+ * sensible default before branch data has loaded and a back-compat
31266
+ * affordance for callers that have no remote data to hand.
31267
+ */
31268
+ function getBranchTipChip(refs, remoteNames) {
31269
+ // Empty list is treated the same as omitted: branch data hasn't
31270
+ // loaded yet, so we don't have ground truth and the legacy "slash =
31271
+ // remote" heuristic is the best guess for first paint.
31272
+ const hasRemoteList = Array.isArray(remoteNames) && remoteNames.length > 0;
31273
+ const isRemoteRef = (ref) => {
31274
+ if (!ref.includes('/'))
31275
+ return false;
31276
+ if (!hasRemoteList)
31277
+ return true;
31278
+ return remoteNames.some((remote) => remote && ref.startsWith(`${remote}/`));
31279
+ };
30458
31280
  for (const ref of refs) {
30459
31281
  if (ref.startsWith('HEAD -> ')) {
30460
31282
  const name = ref.slice('HEAD -> '.length).trim();
30461
31283
  if (name)
30462
- return { name, isHead: true };
31284
+ return { name, isHead: true, kind: 'head' };
30463
31285
  }
30464
31286
  }
30465
31287
  for (const ref of refs) {
30466
31288
  if (ref === 'HEAD' ||
30467
31289
  ref.startsWith('HEAD -> ') ||
30468
31290
  ref.startsWith('tag: ') ||
30469
- ref.includes('/')) {
31291
+ isRemoteRef(ref)) {
30470
31292
  continue;
30471
31293
  }
30472
- if (ref.trim())
30473
- return { name: ref.trim(), isHead: false };
31294
+ if (ref.trim()) {
31295
+ return { name: ref.trim(), isHead: false, kind: 'local' };
31296
+ }
30474
31297
  }
30475
31298
  for (const ref of refs) {
30476
31299
  if (ref.startsWith('tag: ') || ref === 'HEAD' || ref.startsWith('HEAD -> ')) {
30477
31300
  continue;
30478
31301
  }
30479
- if (ref.includes('/') && ref.trim()) {
30480
- return { name: ref.trim(), isHead: false };
31302
+ if (isRemoteRef(ref) && ref.trim()) {
31303
+ return { name: ref.trim(), isHead: false, kind: 'remote' };
30481
31304
  }
30482
31305
  }
30483
31306
  return undefined;
@@ -31240,8 +32063,12 @@ const BRANCH_CHIP_MAX_NAME_WIDTH = 20;
31240
32063
  * descriptor so the caller can pass it to `filterChippedRefs` and
31241
32064
  * avoid emitting the same branch a second time in the trailing list.
31242
32065
  */
31243
- function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31244
- const chip = getBranchTipChip(commit.refs);
32066
+ // Exported for unit / snapshot testing in branchTipChipRender.test.ts.
32067
+ // The function isn't part of the public surface of this module — the
32068
+ // rest of the file is internal — but the chip-rendering logic is
32069
+ // dense enough that structural snapshot tests pay for themselves.
32070
+ function renderBranchTipChip(h, Text, commit, theme, key, selected, remoteNames) {
32071
+ const chip = getBranchTipChip(commit.refs, remoteNames);
31245
32072
  if (!chip)
31246
32073
  return { node: null, width: 0, chip };
31247
32074
  const truncated = truncateCells(chip.name, BRANCH_CHIP_MAX_NAME_WIDTH);
@@ -31262,7 +32089,23 @@ function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31262
32089
  chip,
31263
32090
  };
31264
32091
  }
31265
- const accent = chip.isHead ? theme.colors.success : theme.colors.info;
32092
+ // Three-way colour assignment matches `BranchTipChipKind`:
32093
+ //
32094
+ // - HEAD → success (the user's current branch — bright green)
32095
+ // - local → info (other local branches — calm blue)
32096
+ // - remote → warning (remote-tracking refs like origin/main —
32097
+ // distinct so "where is upstream?" reads at a glance)
32098
+ //
32099
+ // Without the remote/local split, a chip on `origin/main` looked
32100
+ // identical to a local-branch chip, so users couldn't tell from the
32101
+ // commit list where their upstream actually pointed. The warning hue
32102
+ // (typically a muted yellow / orange) is purposeful: not alarming,
32103
+ // but visibly different from the local blue.
32104
+ const accent = chip.kind === 'head'
32105
+ ? theme.colors.success
32106
+ : chip.kind === 'remote'
32107
+ ? theme.colors.warning
32108
+ : theme.colors.info;
31266
32109
  return {
31267
32110
  node: h(Text, {}, h(Text, { key, inverse: true, color: accent, bold: chip.isHead }, body), h(Text, { key: `${key}-pad` }, ' ')),
31268
32111
  width: cellWidth(body) + 1,
@@ -31352,7 +32195,7 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
31352
32195
  * Truncation is per-segment so the variable-length message field gets
31353
32196
  * the leftover budget after fixed segments are accounted for.
31354
32197
  */
31355
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false) {
32198
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false, remoteNames) {
31356
32199
  // Total cells available to the row content. Earlier revisions used a
31357
32200
  // hardcoded 140 here, which let row content overflow whenever the
31358
32201
  // panel was narrower than that — Ink would wrap onto a second visual
@@ -31369,7 +32212,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
31369
32212
  // out whatever the chip already shows so the row doesn't print
31370
32213
  // `[main] feat: x [HEAD -> main]` with the same info on both ends.
31371
32214
  const chip = fullGraph
31372
- ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected)
32215
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected, remoteNames)
31373
32216
  : { node: null, width: 0, chip: undefined };
31374
32217
  const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
31375
32218
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + dateSegmentWidth + chip.width;
@@ -31431,7 +32274,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
31431
32274
  * line stays dim regardless of selection so it doesn't pull the eye
31432
32275
  * away from the subject.
31433
32276
  */
31434
- function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false) {
32277
+ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false, remoteNames) {
31435
32278
  const totalWidth = Math.max(20, panelWidth - 4);
31436
32279
  const accent = theme.noColor ? undefined : theme.colors.accent;
31437
32280
  const muted = theme.noColor ? undefined : theme.colors.muted;
@@ -31442,7 +32285,7 @@ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth,
31442
32285
  // same way as the single-line variant, but only in full-graph mode.
31443
32286
  const recentMarkerWidth = isRecent ? 2 : 0;
31444
32287
  const chip = fullGraph
31445
- ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected)
32288
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected, remoteNames)
31446
32289
  : { node: null, width: 0, chip: undefined };
31447
32290
  const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
31448
32291
  const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
@@ -31517,6 +32360,17 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31517
32360
  const { Box, Text } = components;
31518
32361
  const focused = state.focus === 'commits';
31519
32362
  const worktree = context.worktree;
32363
+ // Distinct remote names seen across the repo's remote-tracking
32364
+ // branches — `['origin']` for a typical fork, `['origin', 'upstream']`
32365
+ // when the user has both. Used to classify branch-tip chips so a
32366
+ // slashed local branch like `feat/x` doesn't get mis-coloured as
32367
+ // remote. When branch data hasn't loaded yet, `undefined` makes the
32368
+ // chip helper fall back to the legacy slash-based heuristic.
32369
+ const remoteNames = context.branches?.remoteBranches
32370
+ ? Array.from(new Set(context.branches.remoteBranches
32371
+ .map((branch) => branch.remote)
32372
+ .filter((remote) => Boolean(remote))))
32373
+ : undefined;
31520
32374
  // Set of just-landed commit hashes for the "new commit" marker.
31521
32375
  // Populated for ~5s after a split-apply or other commit-creating
31522
32376
  // operation; auto-cleared by the runtime so it doesn't linger.
@@ -31571,6 +32425,25 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31571
32425
  paddingX: 1,
31572
32426
  width,
31573
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
+ })()),
31574
32447
  // Server-side filter indicator (#776). Only rendered when the user
31575
32448
  // has an active path:/author: prefix; clears when they Ctrl+U.
31576
32449
  ...(state.historyFetchArgs
@@ -31625,9 +32498,9 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
31625
32498
  }, truncateCells(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
31626
32499
  }
31627
32500
  if (rowMode === 'stacked') {
31628
- 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));
32501
+ 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);
31629
32502
  }
31630
- 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));
32503
+ 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);
31631
32504
  }));
31632
32505
  }
31633
32506
 
@@ -31783,20 +32656,38 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
31783
32656
  }
31784
32657
  function renderHelpPanel(h, components, state, width, theme, focused) {
31785
32658
  const { Box, Text } = components;
31786
- const children = [
31787
- h(Text, { bold: true, key: 'title' }, panelTitle('Help', focused)),
31788
- ];
32659
+ // Build the full list of body rows (everything below the title).
32660
+ // Splitting into title + body lets us window the body by
32661
+ // `state.helpScrollOffset` while keeping the title pinned.
32662
+ const body = [];
31789
32663
  const sections = getLogInkHelpSections({
31790
32664
  activeView: state.activeView,
31791
32665
  focus: state.focus,
31792
32666
  });
31793
32667
  for (const section of sections) {
31794
- children.push(h(Text, { key: `${section.title}-spacer` }, ''));
31795
- children.push(h(Text, { bold: true, key: section.title }, section.title));
32668
+ body.push(h(Text, { key: `${section.title}-spacer` }, ''));
32669
+ body.push(h(Text, { bold: true, key: section.title }, section.title));
31796
32670
  section.bindings.forEach((binding) => {
31797
- children.push(h(Text, { key: `${section.title}:${binding.id}` }, truncateCells(`${formatBindingKeys(binding).padEnd(14)} ${binding.description}`, width - 4)));
32671
+ body.push(h(Text, { key: `${section.title}:${binding.id}` }, truncateCells(`${formatBindingKeys(binding).padEnd(14)} ${binding.description}`, width - 4)));
31798
32672
  });
31799
32673
  }
32674
+ // Clamp the offset against actual content length. The reducer
32675
+ // only floor-clamps at 0; here we ceiling-clamp so j past EOF
32676
+ // sticks at the last row rather than scrolling into emptiness.
32677
+ // Reserve one row at the bottom so the user can always see the
32678
+ // tail of the last section.
32679
+ const maxOffset = Math.max(0, body.length - 1);
32680
+ const offset = Math.min(state.helpScrollOffset, maxOffset);
32681
+ const children = [
32682
+ h(Text, { bold: true, key: 'title' }, panelTitle('Help', focused)),
32683
+ ];
32684
+ // Visual hint that there's content scrolled above. The dim style
32685
+ // matches the rest of the chrome's "metadata" voice and avoids
32686
+ // stealing attention from the bindings themselves.
32687
+ if (offset > 0) {
32688
+ children.push(h(Text, { key: 'more-above', dimColor: true }, '↑ more above'));
32689
+ }
32690
+ children.push(...body.slice(offset));
31800
32691
  return h(Box, {
31801
32692
  borderColor: focusBorderColor(theme, focused),
31802
32693
  borderStyle: theme.borderStyle,
@@ -34532,7 +35423,7 @@ function enrichFilterActionWithRectification(action, state, context) {
34532
35423
  }
34533
35424
  }
34534
35425
  function LogInkApp(deps) {
34535
- const { appLabel, clipboardRunner, dateBucketingEnabled, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
35426
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
34536
35427
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
34537
35428
  const h = React.createElement;
34538
35429
  const { exit } = useApp();
@@ -34563,16 +35454,98 @@ function LogInkApp(deps) {
34563
35454
  // immediately while the chrome still flags the refresh.
34564
35455
  bootLoading: Boolean(loadRows),
34565
35456
  }));
34566
- const [context, setContext] = React.useState({});
34567
- const [contextStatus, setContextStatus] = React.useState(() => {
34568
- // Boot starts every fetched key in 'loading' so the surfaces show
34569
- // their loading hints immediately. `pullRequest` is the exception
34570
- // (#808) it isn't part of the boot fetch entries; it lazy-loads
34571
- // when the user enters the PR view. Marking it 'idle' avoids a
34572
- // permanent "loading" flag in the chrome and lets the dedicated
34573
- // PR view's own load effect drive its loading state.
34574
- return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
34575
- });
35457
+ // Nested-repo runtime stack (#931). Each frame holds the live
35458
+ // `SimpleGit`, the loaded `LogInkContext`, and the per-key load
35459
+ // status the chrome reads. The active (top-of-stack) entry drives
35460
+ // every loader and surface; popping a frame restores the parent's
35461
+ // cached entry so a drill-in / drill-out round trip doesn't re-pay
35462
+ // the context load cost. Seeded with a single root runtime against
35463
+ // the cwd `coco ui` was launched in.
35464
+ const [runtimes, setRuntimes] = React.useState(() => [{
35465
+ git: rootGit,
35466
+ context: {},
35467
+ contextStatus: createInitialContextStatus(),
35468
+ }]);
35469
+ // Sync `runtimes` against the view-model stack on every push / pop.
35470
+ // The sync is monotone — push appends a new runtime via the factory,
35471
+ // pop slices off the top runtime; the parent's cached state survives.
35472
+ // The factory is wrapped to capture `rootGit` so a defensively-pushed
35473
+ // frame without a workdir still has a working `SimpleGit` bound.
35474
+ React.useEffect(() => {
35475
+ setRuntimes((prev) => {
35476
+ const { runtimes: next } = syncRepoStackRuntimes(prev, state.repoStack, (frame) => createRepoFrameRuntime(frame, rootGit));
35477
+ return next;
35478
+ });
35479
+ }, [state.repoStack, rootGit]);
35480
+ // Active-frame projection (#931). `git`, `context`, `contextStatus`
35481
+ // — every existing closure / effect / surface reads these names; the
35482
+ // only thing this PR changes is where they come from. When the user
35483
+ // drills into a submodule, the top-of-stack runtime swaps, every
35484
+ // dep array that lists `git` re-fires, and the loaders refetch
35485
+ // against the submodule's working tree.
35486
+ const activeRuntime = getActiveRepoFrameRuntime(runtimes) ?? {
35487
+ git: rootGit,
35488
+ context: {},
35489
+ contextStatus: createInitialContextStatus(),
35490
+ };
35491
+ const git = activeRuntime.git;
35492
+ const context = activeRuntime.context;
35493
+ const contextStatus = activeRuntime.contextStatus;
35494
+ // Wrappers that delegate to the active frame's runtime entry so the
35495
+ // existing call sites stay byte-identical. Support both function-
35496
+ // updater and value-updater forms (the codebase uses both).
35497
+ const setContext = React.useCallback((arg) => {
35498
+ setRuntimes((prev) => {
35499
+ const depth = prev.length - 1;
35500
+ if (depth < 0)
35501
+ return prev;
35502
+ return updateRepoFrameRuntime(prev, depth, (frame) => ({
35503
+ ...frame,
35504
+ context: typeof arg === 'function'
35505
+ ? arg(frame.context)
35506
+ : arg,
35507
+ }));
35508
+ });
35509
+ }, []);
35510
+ const setContextStatus = React.useCallback((arg) => {
35511
+ setRuntimes((prev) => {
35512
+ const depth = prev.length - 1;
35513
+ if (depth < 0)
35514
+ return prev;
35515
+ return updateRepoFrameRuntime(prev, depth, (frame) => ({
35516
+ ...frame,
35517
+ contextStatus: typeof arg === 'function'
35518
+ ? arg(frame.contextStatus)
35519
+ : arg,
35520
+ }));
35521
+ });
35522
+ }, []);
35523
+ // #931 PR 3b — Absolute repo root for the active frame's `git`.
35524
+ // Resolved asynchronously after every `git` swap (push / pop /
35525
+ // boot) so the commit-diff drill-in helper can construct absolute
35526
+ // workdirs for submodule paths recorded in `.gitmodules` (which
35527
+ // are repo-relative). Undefined during the brief moment between
35528
+ // git swap and the revparse callback resolving.
35529
+ const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
35530
+ React.useEffect(() => {
35531
+ let cancelled = false;
35532
+ void (async () => {
35533
+ try {
35534
+ const root = (await git.revparse(['--show-toplevel'])).trim();
35535
+ if (!cancelled && root) {
35536
+ setActiveRepoRoot(root);
35537
+ }
35538
+ }
35539
+ catch {
35540
+ if (!cancelled) {
35541
+ setActiveRepoRoot(undefined);
35542
+ }
35543
+ }
35544
+ })();
35545
+ return () => {
35546
+ cancelled = true;
35547
+ };
35548
+ }, [git]);
34576
35549
  const [detail, setDetail] = React.useState(undefined);
34577
35550
  const [detailLoading, setDetailLoading] = React.useState(false);
34578
35551
  const [filePreview, setFilePreview] = React.useState(undefined);
@@ -35092,9 +36065,26 @@ function LogInkApp(deps) {
35092
36065
  selectedWorktreeFile?.worktreeStatus,
35093
36066
  state.activeView,
35094
36067
  ]);
36068
+ // #931 PR 5 — Cache-aware boot load. The frame's `git` instance is
36069
+ // the dep that drives this effect; on push, the new frame's runtime
36070
+ // starts every key in `'loading'` and we fetch fresh. On pop, the
36071
+ // parent's runtime carries cached context across the drill-out cycle
36072
+ // (`'ready'` for already-loaded keys), and the per-key gate below
36073
+ // skips the fetch so the user's drill-out is instant + flicker-free.
36074
+ //
36075
+ // `contextStatusRef` reads the latest status without putting
36076
+ // `contextStatus` in the effect deps — including it would re-fire
36077
+ // the effect on every per-key 'ready' write the effect itself
36078
+ // produces, causing duplicate in-flight fetches for not-yet-completed
36079
+ // keys. The ref pattern gives us "read latest" semantics with the
36080
+ // effect still gated on git swaps only.
36081
+ const contextStatusRef = React.useRef(contextStatus);
36082
+ contextStatusRef.current = contextStatus;
35095
36083
  React.useEffect(() => {
35096
36084
  let active = true;
35097
36085
  loadLogInkContextEntries(git).forEach(({ key, load }) => {
36086
+ if (contextStatusRef.current[key] === 'ready')
36087
+ return;
35098
36088
  void load().then((value) => {
35099
36089
  if (!active) {
35100
36090
  return;
@@ -36134,16 +37124,20 @@ function LogInkApp(deps) {
36134
37124
  return;
36135
37125
  }
36136
37126
  // Success — close the overlay, reset compose (the staged set is
36137
- // now empty since the plan committed everything), and pop the
36138
- // compose view so the user lands on whatever was beneath (usually
36139
- // 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.
36140
37138
  dispatch({ type: 'clearSplitPlan' });
36141
37139
  dispatch({ type: 'commitCompose', action: { type: 'reset' } });
36142
- // Only pop if compose is on top — the apply could have been
36143
- // invoked from a deeper stack and we don't want to over-pop.
36144
- if (state.activeView === 'compose' && state.viewStack.length > 1) {
36145
- dispatch({ type: 'popView' });
36146
- }
37140
+ dispatch({ type: 'navigateHome' });
36147
37141
  // Refresh BEFORE setting the final status so we can peek at the
36148
37142
  // post-apply worktree state and craft a directive next-step hint
36149
37143
  // ("X unstaged + Y untracked remaining — press gs to stage / I
@@ -36193,7 +37187,7 @@ function LogInkApp(deps) {
36193
37187
  }
36194
37188
  const successMessage = formatSplitApplySuccess(commitHashes.length, unstaged, untracked);
36195
37189
  dispatch({ type: 'setStatus', value: successMessage, kind: 'success' });
36196
- }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.activeView, state.splitPlan, state.viewStack.length]);
37190
+ }, [dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext, state.splitPlan]);
36197
37191
  // Esc inside the overlay — close without applying. Status line gets
36198
37192
  // a confirmation so the user knows the operation was abandoned.
36199
37193
  const cancelCommitSplit = React.useCallback(() => {
@@ -36700,6 +37694,41 @@ function LogInkApp(deps) {
36700
37694
  'fetch-remotes': async () => fetchRemotes(git),
36701
37695
  'pull-current-branch': async () => pullCurrentBranch(git),
36702
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
+ },
36703
37732
  'rename-branch': async () => {
36704
37733
  const newName = payload?.trim();
36705
37734
  if (!newName)
@@ -37509,6 +38538,37 @@ function LogInkApp(deps) {
37509
38538
  commitDiffSelectedSha: state.diffSource === 'commit'
37510
38539
  ? selected?.hash
37511
38540
  : undefined,
38541
+ // #931 PR 3b — Submodule drill-in target for the cursored file
38542
+ // in a commit diff. Resolved per-render so the Enter handler in
38543
+ // `inkInput.ts` doesn't have to re-walk the submodule overview;
38544
+ // undefined whenever the cursored file isn't a registered
38545
+ // submodule (or the overview / repo root haven't loaded yet).
38546
+ commitDiffSubmoduleDrillIn: state.diffSource === 'commit' && selectedDetailFile
38547
+ ? resolveCommitDiffDrillInTarget({
38548
+ selectedFile: {
38549
+ path: selectedDetailFile.path,
38550
+ submoduleChange: filePreview?.path === selectedDetailFile.path
38551
+ ? filePreview.submoduleChange
38552
+ : undefined,
38553
+ },
38554
+ submodules: context.submodules,
38555
+ activeRepoRoot,
38556
+ })
38557
+ : undefined,
38558
+ // #931 PR 4 / #932 — Submodule drill-in target for the cursored
38559
+ // row in the dedicated submodules view. Resolved per-render so
38560
+ // the Enter handler in `inkInput.ts` doesn't have to re-walk the
38561
+ // submodule overview. Gated on `activeView === 'submodules'` so
38562
+ // a stale resolution from a different view can't accidentally
38563
+ // fire — the runtime only ever populates it when the user is
38564
+ // actually on the view.
38565
+ submoduleViewDrillIn: state.activeView === 'submodules'
38566
+ ? resolveSubmoduleViewDrillInTarget({
38567
+ selectedIndex: state.selectedSubmoduleIndex,
38568
+ submodules: context.submodules,
38569
+ activeRepoRoot,
38570
+ })
38571
+ : undefined,
37512
38572
  worktreeDirty,
37513
38573
  conflictFileCount: context.operation?.conflictedFiles.length,
37514
38574
  conflictSelectedPath: (() => {
@@ -38257,6 +39317,27 @@ function formatCommitDetail(detail, format) {
38257
39317
  ].join('\n');
38258
39318
  }
38259
39319
 
39320
+ /**
39321
+ * Friendly empty-repo message for the non-interactive log path.
39322
+ *
39323
+ * In `--json` mode we emit an empty array so machine consumers see a
39324
+ * well-defined "no commits" result without a parse error. In table
39325
+ * mode we print a human one-liner that names the next-step commands
39326
+ * the user is likely after. Either way we exit 0 — "no commits" is
39327
+ * a valid repo state, not a failure.
39328
+ */
39329
+ function formatEmptyRepoResult(format) {
39330
+ if (format === 'json') {
39331
+ return '[]';
39332
+ }
39333
+ return [
39334
+ "No commits yet — this looks like a fresh `git init`'d repo.",
39335
+ '',
39336
+ 'Get started:',
39337
+ ' • `coco commit` to draft your first commit message with AI',
39338
+ ' • `git commit -m "chore: initial commit"` to commit by hand',
39339
+ ].join('\n');
39340
+ }
38260
39341
  const handler$3 = async (argv) => {
38261
39342
  // `--repo <dir>` (alias `--cwd`) — apply the global flag via the
38262
39343
  // shared helper. After this returns, `process.cwd()` and the git
@@ -38272,6 +39353,22 @@ const handler$3 = async (argv) => {
38272
39353
  });
38273
39354
  return;
38274
39355
  }
39356
+ // Empty-repo short-circuit. Without this, the underlying `git log`
39357
+ // crashes the command and the user sees a raw "fatal: your current
39358
+ // branch 'main' does not have any commits yet" + a generic "Failed
39359
+ // to execute command" banner. We catch the unborn-HEAD state and
39360
+ // emit a friendly next-step hint (or an empty array in JSON mode)
39361
+ // and exit 0 — "no commits" is a valid repo state, not an error.
39362
+ //
39363
+ // Only applies to the non-interactive path: the TUI runtime gets
39364
+ // its own empty-state rendering inside the workstation.
39365
+ if (!argv.interactive && (await isEmptyRepo(git))) {
39366
+ await handleResult({
39367
+ result: formatEmptyRepoResult(format),
39368
+ mode: 'stdout',
39369
+ });
39370
+ return;
39371
+ }
38275
39372
  // Interactive path defers the commit log fetch into the runtime
38276
39373
  // (#808) so the TUI mounts immediately with a "Loading commits…"
38277
39374
  // placeholder. The non-interactive (stdout) path still needs rows