diff --git a/apps/desktop/electron/updater/checkout-current-sha.test.ts b/apps/desktop/electron/updater/checkout-current-sha.test.ts new file mode 100644 index 0000000000..9c5eee4f14 --- /dev/null +++ b/apps/desktop/electron/updater/checkout-current-sha.test.ts @@ -0,0 +1,101 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { expect, it, vi } from 'vitest' + +import { type CheckoutStrategyDeps, createCheckoutStrategy, readStampedCommit } from './checkout' +import type { SourceUpdate } from './checkout-source' + +const STAMPED: string = 'a'.repeat(40) +const PROBED: string = 'b'.repeat(40) + +function writeStamp(root: string, commit: string): void { + fs.writeFileSync(path.join(root, 'install-stamp.json'), JSON.stringify({ schemaVersion: 2, commit, source: 'git' })) +} + +function deps(root: string, status: SourceUpdate | null, isWindows: boolean): CheckoutStrategyDeps { + return { + readSourceUpdate: vi.fn(async (): Promise => status), + hermesHome: 'home', + isWindows, + isMac: process.platform === 'darwin', + defaultUpdateBranch: 'main', + updateHandoffDwellMs: 0, + resolveUpdateRoot: (): string => root, + resolveUpdaterBinary: vi.fn((): null => null), + remoteGatewayActive: (): boolean => false, + emitUpdateProgress: vi.fn(), + rememberLog: vi.fn(), + startHermes: vi.fn(async (): Promise => {}), + stopBackendsForUpdate: vi.fn(async (): Promise => {}), + repairMacUpdaterHelper: vi.fn(), + preflightStateDb: vi.fn(), + runningAppBundle: (): null => null, + markQuittingForHandoff: vi.fn(), + quit: vi.fn() + } +} + +it.each([ + ['windows-handoff', true], + ['posix-handoff', false] +])( + '%s reports the checkout commit even when the source probe never supplied one', + async (_mechanism: string, isWindows: boolean): Promise => { + const root: string = fs.mkdtempSync(path.join(os.tmpdir(), 'checkout-sha-')) + + try { + // A probe-less checkout (the manual path: no hermes_cli/source_check.py) + // previously reported no currentSha at all — the commit showed nowhere. + writeStamp(root, STAMPED) + const strategy: ReturnType = createCheckoutStrategy(deps(root, null, isWindows)) + + expect(await strategy.check()).toMatchObject({ + supported: true, + mechanism: isWindows ? 'windows-handoff' : 'posix-handoff', + currentSha: STAMPED + }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + } +) + +it('fills currentSha from the install stamp when the probe omitted it, but never overrides the probe', async (): Promise => { + const root: string = fs.mkdtempSync(path.join(os.tmpdir(), 'checkout-sha-')) + + try { + writeStamp(root, STAMPED) + + // A probe status that carries no currentSha (stale cache shapes, error + // statuses) still leaves the badge with a commit to show. + const bare: SourceUpdate = { supported: true, error: 'fetch-failed', behind: null } + expect(await createCheckoutStrategy(deps(root, bare, false)).check()).toMatchObject({ currentSha: STAMPED }) + + // The probe's own answer — including its currentSha — always wins. + const probed: SourceUpdate = { supported: true, currentSha: PROBED, behind: 0, updateAvailable: false } + expect(await createCheckoutStrategy(deps(root, probed, false)).check()).toMatchObject({ currentSha: PROBED }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +it('readStampedCommit returns the stamped commit, or null for absent and malformed stamps', (): void => { + const root: string = fs.mkdtempSync(path.join(os.tmpdir(), 'checkout-stamp-')) + + try { + expect(readStampedCommit(root)).toBeNull() + + writeStamp(root, STAMPED) + expect(readStampedCommit(root)).toBe(STAMPED) + + fs.writeFileSync(path.join(root, 'install-stamp.json'), JSON.stringify({ commit: null, source: 'git' })) + expect(readStampedCommit(root)).toBeNull() + + fs.writeFileSync(path.join(root, 'install-stamp.json'), '{not json') + expect(readStampedCommit(root)).toBeNull() + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/updater/checkout.ts b/apps/desktop/electron/updater/checkout.ts index 637d1974bc..32d07e6030 100644 --- a/apps/desktop/electron/updater/checkout.ts +++ b/apps/desktop/electron/updater/checkout.ts @@ -1,6 +1,6 @@ // Checkout update policy and handoff execution. The shell supplies process and UI dependencies. -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import * as path from 'node:path' import { updateHandoffConflict, writeUpdateMarker } from '../update-marker' @@ -66,6 +66,22 @@ export function buildManualUpdateCommand(currentBranch: string | null | undefine : 'hermes update' } +/** + * The commit this checkout was installed at, from the install stamp the + * updater already trusts (written by the CLI's completion tail next to the + * checkout it attests). Read-only display data for `currentSha`; never a + * git spawn and never an update input. Null when absent or malformed. + */ +export function readStampedCommit(root: string): string | null { + try { + const stamp = JSON.parse(readFileSync(path.join(root, 'install-stamp.json'), 'utf8')) as { commit?: unknown } + + return typeof stamp.commit === 'string' && stamp.commit ? stamp.commit : null + } catch { + return null + } +} + /** * The checkout strategy: windows-handoff on win32, posix-handoff elsewhere. * The bodies are the production update flow; the mechanism stamp rides on @@ -89,6 +105,18 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat status.mechanism = mechanism + // Display data only (#122727): the statusbar and command palette read + // currentSha, but a probe-less checkout never supplied it. The install + // stamp names the checkout's commit without a git spawn; never let it + // override the probe and never let it influence the update decision. + if (!status.currentSha) { + const stamped: string | null = readStampedCommit(root) + + if (stamped) { + status.currentSha = stamped + } + } + return status }