From 96f1bc8df92656226e8c7b3fc6144eff07568f14 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Fri, 21 Aug 2026 22:03:48 +0800 Subject: [PATCH 1/3] feat(desktop): show the running build on the sidebar footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PR-BUILD-HYGIENE-0` put a build stamp on the About page so a dev build could be told apart from a release. It works, and it is two clicks and a scroll away — so the question it answers gets asked where it cannot answer it. This session hit exactly that: a locally built app was mistaken for the installed release, and a merged change looked like it had not landed. The rail footer already had the shape for it. `.maka-sidebar-footer-row` is a flex row whose settings item is `flex: 1`, so a sibling lands flush right — the slot the update button uses today. Order is settings, stamp, update button. The button is an action and keeps the edge, where a control is reached for; the stamp is a label, and putting it outside the button would move it every time an update arrived or was installed. A dev build never renders that button at all — `app-update-service` short-circuits to `not-available` when `!isPackaged` — so on a dev build the stamp sits at the edge on its own, and on a release it yields without changing its relation to Settings. `v0.1.11` on a release, `v0.1.11 · 4d223d0` on a dev build. The commit is present only when unpackaged, since `build-info.ts` reads `.git/HEAD` and returns null once there is no repository to read — which is exactly the case where a version number alone cannot separate two builds. Seven hex characters is what `git log --oneline` prints and how a commit is quoted in review. Collapsed, the rail is 48px and the stamp is hidden rather than clipped or allowed to widen it. The About page still carries it, and a collapsed rail is a deliberate request for less. Read once at mount through `window.maka.app.info()`: neither value can change without the process restarting. A failed read renders nothing — an orientation label that shows an error where a version belongs is worse than one that is absent, and the About page reports that failure where a user went looking for it. Tests cover both build shapes, the pre-resolution empty state, and the order against the update button — verified to fail when the stamp is moved past it. Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J Generated-by: Claude Code (Claude Opus 5) --- .../src/renderer/app-shell-build-stamp.ts | 34 +++++++++ apps/desktop/src/renderer/app-shell.tsx | 3 + apps/desktop/src/renderer/styles/sidebar.css | 22 ++++++ .../__tests__/sidebar-build-stamp.test.tsx | 69 +++++++++++++++++++ packages/ui/src/components.tsx | 2 +- packages/ui/src/session-list-panel.tsx | 9 ++- packages/ui/src/session-sidebar-nav.tsx | 35 ++++++++++ packages/ui/src/shell-controls-copy.ts | 4 ++ 8 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/renderer/app-shell-build-stamp.ts create mode 100644 packages/ui/src/__tests__/sidebar-build-stamp.test.tsx diff --git a/apps/desktop/src/renderer/app-shell-build-stamp.ts b/apps/desktop/src/renderer/app-shell-build-stamp.ts new file mode 100644 index 0000000000..f41779db88 --- /dev/null +++ b/apps/desktop/src/renderer/app-shell-build-stamp.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import type { SidebarBuildStamp } from '@maka/ui'; + +/** + * The running build, for the rail's footer. + * + * Read once at mount and never again: neither the version nor the commit can + * change without the process restarting, so a subscription would re-render for + * a value that cannot move. + * + * Failure is silent and the stamp is simply absent. This is an orientation + * label — a rail that shows nothing is strictly better than one that shows an + * error where a version belongs, and the About page reports the same failure + * where a user went looking for it. + */ +export function useBuildStamp(): SidebarBuildStamp | undefined { + const [stamp, setStamp] = useState(undefined); + + useEffect(() => { + let cancelled = false; + window.maka.app + .info() + .then((info) => { + if (cancelled) return; + setStamp({ version: info.appVersion, commit: info.buildCommit }); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + return stamp; +} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 4e854995c8..0479e61bea 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -146,6 +146,7 @@ import { modelSetupToastCopy } from './model-connection-errors'; import type { AppShellCommandListOptions } from './app-shell-command-actions'; import { AppShellTopbarActions, AppShellWorkspaceTopActions } from './app-shell-chrome-actions'; import { updateReminderFromStatus } from './app-shell-app-update'; +import { useBuildStamp } from './app-shell-build-stamp'; import { AppShellDetailPanel } from './app-shell-detail-panel'; import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; @@ -602,6 +603,7 @@ function AppShellContent({ }, []); const updateReminder = updateReminderFromStatus(appUpdateStatus); + const buildStamp = useBuildStamp(); // Dispatches on the task, not on the raw status: the footer is this // callback's only caller and it only renders for the two states above, so // reading the status again here would be the same "who needs the user" list @@ -3031,6 +3033,7 @@ function AppShellContent({ onSelect={setNavSelection} onSelectSession={sessionListSelectSession} onOpenSettings={openSettings} + buildStamp={buildStamp} updateReminder={updateReminder} onOpenUpdate={openUpdateDownload} onNew={createSession} diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 83a8d94a86..15eb570cb9 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -177,6 +177,28 @@ align-self: center; } +/* A label, not a control: it takes no pointer events and no focus, so tabbing + through the rail still goes Settings -> update button as it did before. */ +.maka-sidebar-build-stamp { + flex: none; + color: var(--text-tertiary); + font-size: var(--font-size-xs); + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; + white-space: nowrap; + pointer-events: none; + user-select: none; + -webkit-app-region: no-drag; +} + +/* Collapsed, the rail is 48px and the footer stacks. `v0.1.11 · a1b2c3d` does + not fit and would either clip or force the rail wider, so it drops — the + About page still carries it, and a collapsed rail is a deliberate ask for + less. */ +.appFrame[data-sidebar-state="collapsed"] .maka-sidebar-build-stamp { + display: none; +} + .maka-sidebar-update-button { flex: none; /* Astryx has no shape prop — `isIconOnly` is documented as rendering a diff --git a/packages/ui/src/__tests__/sidebar-build-stamp.test.tsx b/packages/ui/src/__tests__/sidebar-build-stamp.test.tsx new file mode 100644 index 0000000000..cc52c37681 --- /dev/null +++ b/packages/ui/src/__tests__/sidebar-build-stamp.test.tsx @@ -0,0 +1,69 @@ +/** + * The rail footer answers "which build is this?" — the question the About + * page already answers two clicks away, which is why it was worth putting + * where the user already looks. + * + * Both halves are asserted. A dev build and a release build render different + * strings, and the stamp keeps its place when the update button appears: the + * button is an action and takes the edge, the stamp is a label and must not + * move under it. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { LocaleProvider } from '../locale-context.js'; +import { SessionSidebarFooter, type SidebarBuildStamp } from '../session-sidebar-nav.js'; + +function renderFooter( + buildStamp?: SidebarBuildStamp, + updateReminder?: { state: 'downloaded' | 'error'; latestVersion: string }, +): string { + return renderToStaticMarkup( + + undefined} + onOpenUpdate={() => undefined} + /> + , + ); +} + +function stampText(markup: string): string | null { + const match = /class="maka-sidebar-build-stamp"[^>]*>([^<]*) { + // `build-info.ts` returns `commit: null` once packaged — there is no `.git` + // to read — so the version is the whole identity a release can offer. + assert.equal(stampText(renderFooter({ version: '0.1.11', commit: null })), 'v0.1.11'); +}); + +test('a dev build appends the short commit, which is what distinguishes two of them', () => { + assert.equal( + stampText(renderFooter({ version: '0.1.11', commit: '4d223d05beea1bfa' })), + 'v0.1.11 · 4d223d0', + ); +}); + +test('no stamp renders before the build info resolves', () => { + // The value arrives over async IPC. Rendering a placeholder would put a + // wrong version on screen for the time it takes to be replaced. + assert.equal(stampText(renderFooter(undefined)), null); +}); + +test('the update button takes the edge and the stamp keeps its place', () => { + const markup = renderFooter({ version: '0.1.11', commit: null }, { + state: 'downloaded', + latestVersion: '0.1.12', + }); + const stampAt = markup.indexOf('maka-sidebar-build-stamp'); + const buttonAt = markup.indexOf('maka-sidebar-update-button'); + assert.ok(stampAt > 0, 'the stamp renders alongside an update reminder'); + assert.ok(buttonAt > 0, 'the update button still renders'); + // Order in the flex row is order in the markup: settings, stamp, button. + assert.ok(stampAt < buttonAt, 'the stamp precedes the update button'); +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 20e0d7c83e..b5e7ad73dd 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -10,7 +10,7 @@ export type { ModuleHubHeader } from './module-hub-selector.js'; export { SearchModal } from './search-modal.js'; export { SessionListPanel } from './session-list-panel.js'; export type { SessionViewMode } from './session-list-panel.js'; -export type { SidebarUpdateReminder } from './session-sidebar-nav.js'; +export type { SidebarBuildStamp, SidebarUpdateReminder } from './session-sidebar-nav.js'; export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js'; export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolDisplayName } from './tool-format.js'; export { formatBytes, ToolCallDetail, ToolTrow } from './tool-activity.js'; diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx index 6edde2ba01..c14f077cda 100644 --- a/packages/ui/src/session-list-panel.tsx +++ b/packages/ui/src/session-list-panel.tsx @@ -12,7 +12,12 @@ import { type SessionHistoryGroup, type SessionRowActions, } from './session-history-list.js'; -import { SessionSidebarFooter, SessionSidebarNav, type SidebarUpdateReminder } from './session-sidebar-nav.js'; +import { + SessionSidebarFooter, + SessionSidebarNav, + type SidebarBuildStamp, + type SidebarUpdateReminder, +} from './session-sidebar-nav.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import type { Ref } from 'react'; @@ -49,6 +54,7 @@ export function SessionListPanel(props: { moduleMemory?: NavModuleMemory; onSelect(selection: NavSelection): void; onOpenSettings(): void; + buildStamp?: SidebarBuildStamp; updateReminder?: SidebarUpdateReminder; onOpenUpdate?(): void; onNew(): void; @@ -151,6 +157,7 @@ export function SessionListPanel(props: { } footer={ + {stamp && ( + // Between Settings and the update button, not after it: the update + // button is an action and keeps the edge, where a control is + // reached. The stamp is a label — it reads on the way to that edge + // and never moves when the button appears or goes away. + + {stamp} + + )} {updateAction && ( string; updateDownloaded(version: string): string; updateFailed(version: string): string; pendingTasks(count: number): string; @@ -43,6 +45,7 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { automations: '定时任务', extensions: '扩展', settings: '设置', + buildStamp: (stamp: string) => `当前版本 ${stamp}`, updateDownloaded: (version: string) => `新版本 ${version} 已下载,重启后安装`, updateFailed: (version: string) => `新版本 ${version} 更新失败,点击重试或手动下载`, pendingTasks: (count: number) => `定时任务,${count} 条进行中`, @@ -74,6 +77,7 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { automations: 'Scheduled tasks', extensions: 'Extensions', settings: 'Settings', + buildStamp: (stamp: string) => `Current build ${stamp}`, updateDownloaded: (version: string) => `Update ${version} downloaded. Restart to install.`, updateFailed: (version: string) => `Update ${version} failed. Click to retry or download manually.`, pendingTasks: (count: number) => `Scheduled tasks, ${count} active`, From 45aba559d219e62a0733c6b28eded5ded1314d0a Mon Sep 17 00:00:00 2001 From: Joob1n Date: Sat, 22 Aug 2026 00:56:15 +0800 Subject: [PATCH 2/3] fix(desktop): resolve the build commit inside a linked worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from @Astro-Han's review of the sidebar build stamp. **P2 — a worktree's `.git` is a file, and the resolver only read directories.** `resolveCommit` opened `/.git/HEAD`. In a linked worktree `.git` is a file holding `gitdir: ` into `
/.git/worktrees/`, so the read found nothing and the stamp fell back to the version alone — in exactly the checkout where it matters most, since a worktree's HEAD is the one that differs from the tree a developer thinks they are running. Fixed in the main-process `build-info` authority as asked; the renderer stays a pure consumer of `app:info`. A relative `gitdir:` resolves against the checkout, which is how Git writes it for a worktree created inside the repo. Verified against a real worktree in this checkout rather than fixtures alone: the repository root reports `96f1bc8` and the worktree reports `5d9a5b0`, each matching `git rev-parse` — two different commits, which is the distinction the stamp exists to make. The resolver had no test file at all. It has one now: ordinary clone, worktree by absolute and by relative pointer, detached HEAD, packed refs, and the two unreadable cases, where absence stays a supported answer rather than throwing out of `app:info`. **P3 — the Settings label could clip at the 180px minimum.** The stamp was `flex: none` and the settings row `flex: 1`, so with an update button present the label lost the argument. The stamp is now the shrinkable item — it ellipsises and keeps its full value in `title`, while the label a user reads and the row they aim at stay intact. Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J Generated-by: Claude Code (Claude Opus 5) --- .../src/main/__tests__/build-info.test.ts | 100 ++++++++++++++++++ apps/desktop/src/main/build-info.ts | 43 +++++++- apps/desktop/src/renderer/styles/sidebar.css | 15 ++- 3 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/build-info.test.ts diff --git a/apps/desktop/src/main/__tests__/build-info.test.ts b/apps/desktop/src/main/__tests__/build-info.test.ts new file mode 100644 index 0000000000..2d5312030a --- /dev/null +++ b/apps/desktop/src/main/__tests__/build-info.test.ts @@ -0,0 +1,100 @@ +/** + * The build stamp exists to tell a locally built tree apart from a release. + * A linked worktree is the checkout where that matters most — it is the one + * whose HEAD differs from the tree a developer thinks they are running — and + * it is also the one whose `.git` is a file rather than a directory, so a + * resolver that only reads `/.git/HEAD` finds nothing there and the + * stamp falls back to a bare version number. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { resolveBuildInfo } from '../build-info.js'; + +const roots: string[] = []; + +after(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +}); + +const SHA = '4d223d05beea1bfa8af3b4bb4fe1211a9e93acfe'; + +test('a packaged build reports no commit and does not read the filesystem for one', () => { + // `app.isPackaged` is the whole answer: a packaged tree has no repository, + // and a commit resolved from one next to it would describe the wrong thing. + assert.deepEqual(resolveBuildInfo(true, process.cwd()), { mode: 'packaged', commit: null }); +}); + +test('an ordinary clone resolves the commit through a ref', async () => { + const root = await makeRoot(); + await seedGitDir(join(root, '.git'), { head: 'ref: refs/heads/main', refs: { 'refs/heads/main': SHA } }); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a linked worktree resolves through its gitdir pointer', async () => { + // `.git` is a FILE here, holding `gitdir: `. This is the case the + // resolver used to miss entirely. + const root = await makeRoot(); + const real = join(root, 'real-git-dir'); + await seedGitDir(real, { head: 'ref: refs/heads/feature', refs: { 'refs/heads/feature': SHA } }); + await writeFile(join(root, '.git'), `gitdir: ${real}\n`); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a worktree pointer relative to the checkout resolves', async () => { + // Git writes a relative pointer for a worktree created inside the repo. + const root = await makeRoot(); + await seedGitDir(join(root, 'nested', 'git-dir'), { + head: 'ref: refs/heads/main', + refs: { 'refs/heads/main': SHA }, + }); + await writeFile(join(root, '.git'), 'gitdir: nested/git-dir\n'); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a detached HEAD is already the sha', async () => { + const root = await makeRoot(); + await seedGitDir(join(root, '.git'), { head: SHA, refs: {} }); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a packed ref resolves when no loose ref file exists', async () => { + const root = await makeRoot(); + const gitDir = join(root, '.git'); + await seedGitDir(gitDir, { head: 'ref: refs/heads/main', refs: {} }); + await writeFile(join(gitDir, 'packed-refs'), `# pack-refs with: peeled\n${SHA} refs/heads/main\n`); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('an unreadable checkout reports dev with no commit rather than throwing', async () => { + // Absence is a supported answer: the stamp shows the version alone. A throw + // here would fail `app:info`, which carries far more than the commit. + const root = await makeRoot(); + assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: null }); + + const dangling = await makeRoot(); + await writeFile(join(dangling, '.git'), 'gitdir: /nowhere/that/exists\n'); + assert.deepEqual(resolveBuildInfo(false, dangling), { mode: 'dev', commit: null }); +}); + +async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-build-info-')); + roots.push(root); + return root; +} + +async function seedGitDir( + gitDir: string, + input: { head: string; refs: Readonly> }, +): Promise { + await mkdir(gitDir, { recursive: true }); + await writeFile(join(gitDir, 'HEAD'), `${input.head}\n`); + for (const [ref, sha] of Object.entries(input.refs)) { + const refPath = join(gitDir, ...ref.split('/')); + await mkdir(join(refPath, '..'), { recursive: true }); + await writeFile(refPath, `${sha}\n`); + } +} diff --git a/apps/desktop/src/main/build-info.ts b/apps/desktop/src/main/build-info.ts index f7c21da692..27da641b84 100644 --- a/apps/desktop/src/main/build-info.ts +++ b/apps/desktop/src/main/build-info.ts @@ -16,8 +16,8 @@ * One-shot — captured at module load and cached. */ -import { readFileSync, existsSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; export interface BuildInfo { readonly mode: 'dev' | 'packaged'; @@ -38,16 +38,49 @@ function findRepoRoot(start: string): string | null { return null; } +/** + * The directory holding this checkout's Git metadata. + * + * `.git` is a directory in an ordinary clone and a **file** in a linked + * worktree, where it holds `gitdir: ` pointing at + * `
/.git/worktrees/`. Reading `/.git/HEAD` therefore finds + * nothing in a worktree — and a worktree is exactly the checkout whose commit + * a developer most needs to see, since it is the one that differs from the + * tree they think they are running. + * + * A relative `gitdir:` is resolved against the checkout, which is how Git + * writes it for a worktree created inside the repository. + */ +function resolveGitDir(repoRoot: string): string | null { + const dotGit = join(repoRoot, '.git'); + try { + if (statSync(dotGit).isDirectory()) return dotGit; + } catch { + return null; + } + try { + const pointer = readFileSync(dotGit, 'utf8').trim(); + if (!pointer.startsWith('gitdir:')) return null; + const target = pointer.slice('gitdir:'.length).trim(); + if (!target) return null; + return isAbsolute(target) ? target : resolve(repoRoot, target); + } catch { + return null; + } +} + function resolveCommit(repoRoot: string): string | null { + const gitDir = resolveGitDir(repoRoot); + if (!gitDir) return null; try { - const headPath = join(repoRoot, '.git', 'HEAD'); + const headPath = join(gitDir, 'HEAD'); if (!existsSync(headPath)) return null; const head = readFileSync(headPath, 'utf8').trim(); if (head.startsWith('ref: ')) { - const refPath = join(repoRoot, '.git', head.slice(5).trim()); + const refPath = join(gitDir, head.slice(5).trim()); if (!existsSync(refPath)) { // Packed refs path — read packed-refs and match the ref name. - const packed = join(repoRoot, '.git', 'packed-refs'); + const packed = join(gitDir, 'packed-refs'); if (!existsSync(packed)) return null; const target = head.slice(5).trim(); const lines = readFileSync(packed, 'utf8').split('\n'); diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 15eb570cb9..fd1c1df98b 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -166,7 +166,10 @@ } .maka-sidebar-footer-row-primary { - flex: 1; + /* `auto` rather than `1 1 0`: the settings row asks for the width its label + needs and gives back the rest, so the stamp shrinks into what is left + instead of the label being clipped first. */ + flex: 1 1 auto; min-width: 0; } @@ -180,7 +183,15 @@ /* A label, not a control: it takes no pointer events and no focus, so tabbing through the rail still goes Settings -> update button as it did before. */ .maka-sidebar-build-stamp { - flex: none; + /* The stamp yields first. At the 180px minimum width with an update button + present, something has to give, and it should be the label a user is + reading rather than the row they are aiming at — Settings keeps its text + and its hit target, the stamp ellipsises, and its `title` still carries + the full build. */ + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; color: var(--text-tertiary); font-size: var(--font-size-xs); font-variant-numeric: tabular-nums; From e147a1e6a6588eb0fac6d1cd32e25285cc831cac Mon Sep 17 00:00:00 2001 From: Joob1n Date: Sat, 22 Aug 2026 13:40:54 +0800 Subject: [PATCH 3/3] fix(desktop): read a worktree's branch ref from its common Git directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the resolver still misses a normal branch-linked worktree, and was right about why my fixture did not catch it. A linked worktree splits its Git directory in two. The private admin directory named by `.git` holds that worktree's own `HEAD`, but loose refs and `packed-refs` stay in the common directory shared with the main checkout; `commondir` inside the admin directory names it. The resolver read `HEAD` from the admin directory — correct — and then looked for the branch ref beside it, where Git never puts one. Real branch worktrees returned `commit: null`. Reproduced against `git worktree add` before fixing: the admin directory contains `commondir`, `gitdir`, `HEAD`, `index`, `logs`, `ORIG_HEAD`, and a `refs` tree with no `heads` directory in it. The fix keeps `HEAD` per worktree and resolves refs — loose and packed — through `commondir`. An ordinary clone has no `commondir` and falls back to its own directory. The old fixture passed by placing `refs/heads/feature` inside the private admin directory, which Git does not do, so it certified the behaviour it was written against rather than the behaviour Git produces. It now mirrors the real layout, and a second case covers a packed ref in the common directory. Both fail when the `commondir` hop is removed. Also floors the Settings row at `min-content` instead of `0`. With both items free to shrink, the label could still ellipsise while the stamp had room left to give. Measured at the 180px minimum with an update reminder present: before, `Settings` rendered 30px wide against 53px of content; after, nothing in the row truncates and the stamp is the only thing that yields. Generated-by: Claude Opus 5 --- .../src/main/__tests__/build-info.test.ts | 42 +++++++++++++++---- apps/desktop/src/main/build-info.ts | 29 ++++++++++++- apps/desktop/src/renderer/styles/sidebar.css | 11 ++++- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/__tests__/build-info.test.ts b/apps/desktop/src/main/__tests__/build-info.test.ts index 2d5312030a..8be020f826 100644 --- a/apps/desktop/src/main/__tests__/build-info.test.ts +++ b/apps/desktop/src/main/__tests__/build-info.test.ts @@ -34,14 +34,42 @@ test('an ordinary clone resolves the commit through a ref', async () => { assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); }); -test('a linked worktree resolves through its gitdir pointer', async () => { - // `.git` is a FILE here, holding `gitdir: `. This is the case the - // resolver used to miss entirely. +test('a linked worktree reads HEAD privately and the branch ref from commondir', async () => { + // The real layout, reproduced from `git worktree add`: `.git` is a FILE + // holding `gitdir: `; the admin directory holds this worktree's + // own HEAD plus a `commondir` pointer, and NOTHING under `refs/heads`. The + // branch ref lives in the common Git directory shared with the main + // checkout. A resolver that looks for the ref beside HEAD finds nothing. const root = await makeRoot(); - const real = join(root, 'real-git-dir'); - await seedGitDir(real, { head: 'ref: refs/heads/feature', refs: { 'refs/heads/feature': SHA } }); - await writeFile(join(root, '.git'), `gitdir: ${real}\n`); - assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) }); + const commonDir = join(root, 'main-repo', '.git'); + const adminDir = join(commonDir, 'worktrees', 'linked'); + await seedGitDir(commonDir, { head: 'ref: refs/heads/main', refs: { 'refs/heads/main': SHA } }); + await mkdir(adminDir, { recursive: true }); + await writeFile(join(adminDir, 'HEAD'), 'ref: refs/heads/feature\n'); + await writeFile(join(adminDir, 'commondir'), '../..\n'); + // The branch ref exists only in the common directory, as Git writes it. + await mkdir(join(commonDir, 'refs', 'heads'), { recursive: true }); + await writeFile(join(commonDir, 'refs', 'heads', 'feature'), `${SHA}\n`); + const checkout = join(root, 'linked'); + await mkdir(checkout, { recursive: true }); + await writeFile(join(checkout, '.git'), `gitdir: ${adminDir}\n`); + assert.deepEqual(resolveBuildInfo(false, checkout), { mode: 'dev', commit: SHA.slice(0, 7) }); +}); + +test('a worktree finds a packed branch ref in the common directory', async () => { + // Same split, but the ref is packed. `packed-refs` is shared too. + const root = await makeRoot(); + const commonDir = join(root, 'main-repo', '.git'); + const adminDir = join(commonDir, 'worktrees', 'linked'); + await mkdir(commonDir, { recursive: true }); + await writeFile(join(commonDir, 'packed-refs'), `# pack-refs with: peeled\n${SHA} refs/heads/feature\n`); + await mkdir(adminDir, { recursive: true }); + await writeFile(join(adminDir, 'HEAD'), 'ref: refs/heads/feature\n'); + await writeFile(join(adminDir, 'commondir'), '../..\n'); + const checkout = join(root, 'linked'); + await mkdir(checkout, { recursive: true }); + await writeFile(join(checkout, '.git'), `gitdir: ${adminDir}\n`); + assert.deepEqual(resolveBuildInfo(false, checkout), { mode: 'dev', commit: SHA.slice(0, 7) }); }); test('a worktree pointer relative to the checkout resolves', async () => { diff --git a/apps/desktop/src/main/build-info.ts b/apps/desktop/src/main/build-info.ts index 27da641b84..4d8a2704b6 100644 --- a/apps/desktop/src/main/build-info.ts +++ b/apps/desktop/src/main/build-info.ts @@ -69,18 +69,43 @@ function resolveGitDir(repoRoot: string): string | null { } } +/** + * The Git directory holding refs for this checkout. + * + * A linked worktree splits its Git directory in two: the private admin + * directory named by `.git` holds that worktree's own `HEAD` and index, while + * loose refs and `packed-refs` stay in the *common* directory shared with the + * main checkout. `commondir` inside the admin directory names it, relative to + * the admin directory itself. + * + * Reading a branch ref from the admin directory therefore finds nothing on a + * real branch-linked worktree, and the build stamp loses its commit. + */ +function resolveCommonDir(gitDir: string): string { + try { + const pointer = readFileSync(join(gitDir, 'commondir'), 'utf8').trim(); + if (!pointer) return gitDir; + return isAbsolute(pointer) ? pointer : resolve(gitDir, pointer); + } catch { + // An ordinary clone has no `commondir`; its own directory is the common one. + return gitDir; + } +} + function resolveCommit(repoRoot: string): string | null { const gitDir = resolveGitDir(repoRoot); if (!gitDir) return null; + // HEAD is per-worktree; refs are shared. + const commonDir = resolveCommonDir(gitDir); try { const headPath = join(gitDir, 'HEAD'); if (!existsSync(headPath)) return null; const head = readFileSync(headPath, 'utf8').trim(); if (head.startsWith('ref: ')) { - const refPath = join(gitDir, head.slice(5).trim()); + const refPath = join(commonDir, head.slice(5).trim()); if (!existsSync(refPath)) { // Packed refs path — read packed-refs and match the ref name. - const packed = join(gitDir, 'packed-refs'); + const packed = join(commonDir, 'packed-refs'); if (!existsSync(packed)) return null; const target = head.slice(5).trim(); const lines = readFileSync(packed, 'utf8').split('\n'); diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index fd1c1df98b..842d62b5a6 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -168,9 +168,16 @@ .maka-sidebar-footer-row-primary { /* `auto` rather than `1 1 0`: the settings row asks for the width its label needs and gives back the rest, so the stamp shrinks into what is left - instead of the label being clipped first. */ + instead of the label being clipped first. + + `min-content`, not `0`: with `min-width: 0` BOTH items are free to shrink, + so at the 180px minimum with an update reminder present the Settings label + could still pick up an ellipsis even though the stamp had room left to + give. Flooring this row at its content width makes the priority + deterministic — the stamp is the only thing that truncates. The stamp + keeps `min-width: 0`, so it can still yield all the way down. */ flex: 1 1 auto; - min-width: 0; + min-width: min-content; } /* Stacked, the row stretches its children to the rail's width so the settings