fix(desktop): passive update checks use the GitHub API once a day, never git fetch

Every desktop client ran `git fetch origin main` twice every 30 minutes
(client + backend check), plus on every window focus, with no cache in the
Electron main process and `force=true` hardcoded on the backend poll so the
backend's 6h cache was bypassed too. Across the install base GitHub measured
~33.8M fetch/clone requests in 24h against the repo and asked us to poll via
the API and releases instead.

Passive checks now:
- read the branch tip with GET /repos/{slug}/commits/{branch} using the
  application/vnd.github.sha media type (40-byte body), and only when the tips
  differ call the compare endpoint for the exact behind count and the commit
  list the overlay renders. No pack negotiation; `git fetch` runs only when
  the user applies an update.
- cache the answer on disk for 24h (1h on failure), keyed on local HEAD and
  branch so applying an update or switching branch invalidates immediately.
- run from the renderer every 24h instead of 30min; window focus re-checks
  only once the daily cadence has elapsed; the poller never passes `force`.
  Menu "Check for Updates", Settings "Check now", opening the overlay and the
  post-apply re-checks still force a fresh read.

Non-GitHub origins keep a single `ls-remote` (ref advertisement only).
runGit resolves on 'close' rather than 'exit' — the early-resolving
`remote get-url` returned "" often enough to route checks down the
non-GitHub path.

update-count.ts (shallow/full-clone counting heuristics for the fetch path)
is deleted; its compare-payload parsing moved to update-api-check.ts.
This commit is contained in:
Teknium
2026-09-10 12:11:26 -07:00
parent e253ea0233
commit 6dfcf15685
11 changed files with 414 additions and 624 deletions

View File

@@ -201,7 +201,6 @@ import {
import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { registerGitIpc } from './git-ipc'
import { clearStaleGitLocks } from './gitlock'
import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding'
import { readAndConsumeHandoffResult } from './handoff-result'
import {
@@ -385,13 +384,7 @@ import {
windowOpacityFor,
windowOpacityOptions
} from './translucency'
import {
compareApiUrl,
parseCompareBehindCount,
resolveBehindCount,
resolveCommitLogSelection,
shouldCountCommits
} from './update-count'
import { branchTipApiUrl, cacheIsFresh, compareApiUrl, githubRepoSlug, parseCompare } from './update-api-check'
import { waitForUpdateClearance } from './update-gate'
import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker'
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
@@ -856,6 +849,7 @@ const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'conne
const DESKTOP_CONNECTIONS_REGISTRY_PATH = path.join(app.getPath('userData'), 'connections.json')
const DESKTOP_INSTALLATION_PATH = path.join(app.getPath('userData'), 'desktop-installation.json')
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
const DESKTOP_UPDATE_CHECK_CACHE_PATH = path.join(app.getPath('userData'), 'update-check-cache.json')
const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json')
const DESKTOP_BACKEND_OWNERSHIP_PATH = path.join(app.getPath('userData'), 'backend-ownership.json')
const DESKTOP_MANAGED_SSH_RECOVERY_PATH = path.join(app.getPath('userData'), 'managed-ssh-update-recovery.json')
@@ -3093,7 +3087,10 @@ function runGit(args, options: any = {}): Promise<{ code: number; stdout: string
options.onLine?.('stderr', text)
})
child.once('error', reject)
child.once('exit', code => resolve({ code, stdout, stderr }))
// 'close', not 'exit': exit can fire before the stdio pipes drain, and a
// resolved-early `remote get-url` came back as "" often enough to route
// passive checks down the wrong remote path.
child.once('close', code => resolve({ code, stdout, stderr }))
})
}
@@ -3143,7 +3140,15 @@ async function resolveHealedBranch(updateRoot, branch) {
return 'main'
}
async function checkUpdates() {
// Passive checks never touch git's network side. Every client used to `git
// fetch` twice per half hour; across the install base that was tens of
// millions of pack negotiations a day against one repo (GitHub flagged it).
// The REST API answers the same question in one 40-byte response, so the
// check is API-first with a 24h on-disk cache keyed on local HEAD (applying an
// update changes HEAD, which busts the cache immediately). `git fetch` runs only
// inside applyUpdates. `force` (menu item, Settings "Check now") skips the
// cache; the renderer's background poller never passes it.
async function checkUpdates({ force = false }: { force?: boolean } = {}) {
const updateRoot = resolveUpdateRoot()
let { branch } = readDesktopUpdateConfig()
const gitDir = path.join(updateRoot, '.git')
@@ -3158,214 +3163,175 @@ async function checkUpdates() {
}
}
branch = await resolveHealedBranch(updateRoot, branch)
const originUrl = await getOriginUrl(updateRoot)
if (isOfficialSshRemote(originUrl)) {
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([
git(['rev-parse', 'HEAD']),
runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }),
git(['status', '--porcelain']),
git(['rev-parse', '--abbrev-ref', 'HEAD'])
])
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
if (target.code !== 0 || !targetSha) {
return {
supported: true,
branch,
error: 'fetch-failed',
message: firstLine(target.stderr) || 'git ls-remote failed.',
hermesRoot: updateRoot,
fetchedAt: Date.now()
}
}
// Passive SSH-official checks only know tip SHAs (ls-remote) — never
// fabricate a "1 commit behind". Recover the exact count via the GitHub
// compare API when possible; otherwise behind stays null ("update
// available, count unknown") and updateAvailable carries the signal.
// ahead_by === 0 with differing tips means the remote tip is reachable
// from our HEAD — a local carried commit sitting AHEAD, not behind:
// flagging that as an update nudges the user into wiping their work.
const tipsEqual = Boolean(currentSha && currentSha === targetSha)
const sshBehind = tipsEqual
? 0
: await fetchCompareBehindCount({ currentSha, originUrl: OFFICIAL_REPO_HTTPS_URL, targetSha })
const upToDate = tipsEqual || sshBehind === 0
return {
supported: true,
branch,
currentBranch,
behind: upToDate ? 0 : sshBehind,
updateAvailable: !upToDate,
currentSha,
targetSha,
commits: [],
dirty: dirtyStr.length > 0,
hermesRoot: updateRoot,
fetchedAt: Date.now()
}
}
// Self-heal abandoned git lock files before fetching. A stale
// .git/shallow.lock from a crashed/interrupted fetch otherwise fails every
// later fetch ("Unable to create '.git/shallow.lock': File exists") and this
// check reports 'fetch-failed' forever — git never removes these itself.
await clearStaleGitLocks(updateRoot)
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
if (fetched.code !== 0) {
return {
supported: true,
branch,
error: 'fetch-failed',
message: firstLine(fetched.stderr) || 'git fetch failed.',
hermesRoot: updateRoot,
fetchedAt: Date.now()
}
}
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr] = await Promise.all([
const [currentSha, dirtyStr, currentBranch, originUrl] = await Promise.all([
git(['rev-parse', 'HEAD']),
git(['rev-parse', `origin/${branch}`]),
git(['status', '--porcelain']),
git(['rev-parse', '--abbrev-ref', 'HEAD']),
git(['rev-parse', '--is-shallow-repository'])
getOriginUrl(updateRoot)
])
const isShallow = shallowStr === 'true'
const cached = readUpdateCheckCache()
const now = Date.now()
// A shallow graph cannot provide a trustworthy exact count, even when it has
// a visible merge-base. Skip the ancestry walk and use the SHA fallback.
const countStr = shouldCountCommits({ isShallow }) ? await git(['rev-list', `HEAD..origin/${branch}`, '--count']) : ''
// A positive directional ancestry result remains trustworthy in a shallow
// graph and prevents a local commit on top of origin from looking outdated.
const targetIsAncestorOfHead =
isShallow &&
currentSha !== targetSha &&
(await runGit(['merge-base', '--is-ancestor', `origin/${branch}`, 'HEAD'], { cwd: updateRoot })).code === 0
let behind = resolveBehindCount({
countStr,
currentSha,
targetSha,
isShallow,
targetIsAncestorOfHead
})
// Recover the exact count a shallow clone can't compute: the GitHub compare
// API knows the full graph regardless of local clone depth. Best-effort —
// offline, rate-limited, or non-GitHub origins keep the honest null
// ("update available", no fabricated number).
if (behind === null) {
behind = await fetchCompareBehindCount({ currentSha, originUrl, targetSha })
if (!force && cacheIsFresh(cached, { branch, currentSha, now })) {
return { ...cached.status, dirty: dirtyStr.length > 0, currentBranch }
}
// behind === null means "update available, exact count unknown" (shallow
// clone): still list what origin offers — resolveCommitLogSelection keeps
// the shallow log to the fetched tip so the range walk can't enumerate the
// contaminated ancestry — so "See what's new" stays useful and honest.
const commits = behind !== 0 ? await readCommitLog(updateRoot, branch, isShallow) : []
branch = await resolveHealedBranch(updateRoot, branch)
const slug = githubRepoSlug(originUrl)
return {
const status = slug
? await checkUpdatesViaApi({ slug, branch, currentSha })
: await checkUpdatesViaLsRemote({ updateRoot, branch, currentSha })
const result = {
supported: true,
branch,
currentBranch,
behind,
updateAvailable: behind === null || behind > 0,
currentSha,
targetSha,
commits,
dirty: dirtyStr.length > 0,
hermesRoot: updateRoot,
fetchedAt: Date.now()
fetchedAt: now,
...status
}
writeUpdateCheckCache({ fetchedAt: now, currentSha, branch, status: result })
return result
}
// Best-effort exact behind-count for graphs the local clone can't measure.
// Delegates URL building + response parsing to update-count.ts (pure, unit
// tested); this wrapper only does the bounded network call. Any failure —
// offline, 4xx/5xx, rate limit, shape surprise — returns null so callers keep
// the honest "update available, count unknown" state.
async function fetchCompareBehindCount({ currentSha, originUrl, targetSha }) {
const url = compareApiUrl({ currentSha, originUrl, targetSha })
if (!url) {
return null
}
function readUpdateCheckCache() {
try {
const payload = await new Promise((resolve, reject) => {
const req = https.get(
url,
{
headers: {
Accept: 'application/vnd.github+json',
// GitHub requires a UA on api.github.com; requests without one 403.
'User-Agent': 'hermes-desktop-update-check'
},
timeout: 10_000
},
res => {
const chunks = []
res.on('error', reject)
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
if ((res.statusCode || 500) >= 400) {
reject(new Error(`compare API ${res.statusCode}`))
const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CHECK_CACHE_PATH, 'utf8'))
return
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
} catch (error) {
reject(error)
}
})
}
)
req.on('timeout', () => req.destroy(new Error('compare API timeout')))
req.on('error', reject)
})
return parseCompareBehindCount(payload)
return parsed && typeof parsed === 'object' && parsed.status ? parsed : null
} catch {
return null
}
}
async function readCommitLog(cwd, branch, isShallow) {
const SEP = '\x1f'
const REC = '\x1e'
const { limit, revision } = resolveCommitLogSelection({ branch, isShallow })
function writeUpdateCheckCache(entry) {
try {
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CHECK_CACHE_PATH), { recursive: true })
writeFileAtomic(DESKTOP_UPDATE_CHECK_CACHE_PATH, JSON.stringify(entry))
} catch (error) {
rememberLog(`[updates] could not persist check cache: ${error?.message || error}`)
}
}
const { stdout } = await runGit(
['log', revision, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', String(limit)],
{ cwd }
)
// GitHub origins (official repo AND forks): tip SHA via the commits endpoint,
// then the compare endpoint only when the tips differ — it yields the exact
// behind count plus the commit list the overlay renders, replacing both
// `rev-list --count` and `git log HEAD..origin/<branch>`.
async function checkUpdatesViaApi({ slug, branch, currentSha }) {
let targetSha
return stdout
.split(REC)
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const [sha, summary, author, at] = line.split(SEP)
try {
targetSha = String(await fetchGitHubApi(branchTipApiUrl(slug, branch), 'application/vnd.github.sha')).trim()
} catch (error) {
return { error: 'fetch-failed', message: `GitHub API: ${error?.message || error}` }
}
return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 }
})
if (!/^[0-9a-f]{40}$/i.test(targetSha)) {
return { error: 'fetch-failed', message: 'GitHub API returned no tip SHA.' }
}
if (targetSha === currentSha) {
return { behind: 0, updateAvailable: false, targetSha, commits: [] }
}
// Compare failure (rate-limited, local-only HEAD 404) keeps the honest
// "update available, count unknown" — never a fabricated number.
const compared = await fetchGitHubApi(compareApiUrl(slug, currentSha, targetSha))
.then(parseCompare)
.catch(() => null)
// ahead_by === 0 with differing tips: the remote tip is reachable from our
// HEAD — a local commit sitting AHEAD, not behind. Flagging that as an update
// nudges the user into wiping their work.
if (compared?.behind === 0) {
return { behind: 0, updateAvailable: false, targetSha, commits: [] }
}
return {
behind: compared ? compared.behind : null,
updateAvailable: true,
targetSha,
commits: compared?.commits ?? []
}
}
// Non-GitHub origins: one ls-remote for the tip SHA (still no pack transfer),
// counting via the local graph only when the tip is already known locally.
async function checkUpdatesViaLsRemote({ updateRoot, branch, currentSha }) {
const target = await runGit(['ls-remote', 'origin', `refs/heads/${branch}`], { cwd: updateRoot })
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
if (target.code !== 0 || !targetSha) {
return { error: 'fetch-failed', message: firstLine(target.stderr) || 'git ls-remote failed.' }
}
if (targetSha === currentSha) {
return { behind: 0, updateAvailable: false, targetSha, commits: [] }
}
const known = (await runGit(['cat-file', '-e', `${targetSha}^{commit}`], { cwd: updateRoot })).code === 0
const isAncestor =
known && (await runGit(['merge-base', '--is-ancestor', targetSha, 'HEAD'], { cwd: updateRoot })).code === 0
if (isAncestor) {
return { behind: 0, updateAvailable: false, targetSha, commits: [] }
}
return { behind: null, updateAvailable: true, targetSha, commits: [] }
}
function fetchGitHubApi(url, accept = 'application/vnd.github+json') {
return new Promise((resolve, reject) => {
const req = https.get(
url,
{
headers: {
Accept: accept,
// GitHub requires a UA on api.github.com; requests without one 403.
'User-Agent': 'hermes-desktop-update-check'
},
timeout: 10_000
},
res => {
const chunks = []
res.on('error', reject)
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8')
if ((res.statusCode || 500) >= 400) {
reject(new Error(`HTTP ${res.statusCode}`))
return
}
if (accept === 'application/vnd.github.sha') {
resolve(body)
return
}
try {
resolve(JSON.parse(body))
} catch (error) {
reject(error)
}
})
}
)
req.on('timeout', () => req.destroy(new Error('timeout')))
req.on('error', reject)
})
}
let updateInFlight = false
@@ -17557,8 +17523,8 @@ const terminalIpc = registerTerminalIpc({
const disposeTerminalSession = terminalIpc.disposeTerminalSession
ipcMain.handle('hermes:updates:check', async () =>
checkUpdates().catch(error => ({
ipcMain.handle('hermes:updates:check', async (_event, opts) =>
checkUpdates({ force: Boolean(opts?.force) }).catch(error => ({
supported: true,
branch: readDesktopUpdateConfig().branch,
error: 'check-failed',

View File

@@ -504,7 +504,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
},
updates: {
check: () => ipcRenderer.invoke('hermes:updates:check'),
check: opts => ipcRenderer.invoke('hermes:updates:check', opts),
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
getBranch: () => ipcRenderer.invoke('hermes:updates:branch:get'),
setBranch: name => ipcRenderer.invoke('hermes:updates:branch:set', name),

View File

@@ -0,0 +1,81 @@
/**
* Tests for electron/update-api-check.ts — the API-first passive update check.
*
* Why this exists: every desktop client used to `git fetch` twice every 30
* minutes. GitHub measured tens of millions of fetch/clone requests per day
* from the install base and asked us to poll via the API instead. These pin
* the two load-bearing contracts: the cache answers passive checks for a full
* day but invalidates the moment HEAD moves, and the compare payload maps to
* an honest behind count (never a fabricated one).
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
branchTipApiUrl,
cacheIsFresh,
githubRepoSlug,
parseCompare,
UPDATE_CHECK_FAILURE_TTL_MS,
UPDATE_CHECK_TTL_MS
} from './update-api-check'
const SHA_A = 'a'.repeat(40)
const SHA_B = 'b'.repeat(40)
const HOUR = 60 * 60 * 1000
test('cache serves a passive check for 24h, but not once HEAD or the branch changes', () => {
const cached = { fetchedAt: 0, currentSha: SHA_A, branch: 'main', status: { behind: 0 } }
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_TTL_MS - 1 }), true)
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_TTL_MS }), false)
// Applying an update moves HEAD: a stale "update available" must never survive it.
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_B, now: 1 }), false)
assert.equal(cacheIsFresh(cached, { branch: 'bb/gui', currentSha: SHA_A, now: 1 }), false)
// Failures retry sooner than successes, but still not on every tick.
const failed = { ...cached, status: { error: 'fetch-failed' } }
assert.equal(cacheIsFresh(failed, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_FAILURE_TTL_MS - 1 }), true)
assert.equal(cacheIsFresh(failed, { branch: 'main', currentSha: SHA_A, now: 2 * HOUR }), false)
})
test('compare payload maps to the behind count and a newest-first commit list; malformed = null', () => {
const payload = {
ahead_by: 2,
status: 'ahead',
commits: [
{
sha: SHA_A,
commit: { message: 'fix: older\n\nbody', author: { name: 'A' }, committer: { date: '2026-09-10T00:00:00Z' } }
},
{
sha: SHA_B,
commit: { message: 'feat: newer', author: { name: 'B' }, committer: { date: '2026-09-10T01:00:00Z' } }
}
]
}
const parsed = parseCompare(payload)
assert.equal(parsed?.behind, 2)
assert.deepEqual(
parsed?.commits.map(c => [c.sha, c.summary, c.author]),
[
[SHA_B, 'feat: newer', 'B'],
[SHA_A, 'fix: older', 'A']
]
)
assert.equal(parseCompare({ ahead_by: -1 }), null)
assert.equal(parseCompare({ status: 'ahead' }), null)
assert.equal(parseCompare('nope'), null)
// Forks and SSH forms hit the API for their own repo; non-GitHub origins don't.
assert.equal(githubRepoSlug('git@github.com:Someone/hermes-agent.git'), 'someone/hermes-agent')
assert.equal(githubRepoSlug('https://gitlab.example/x/y.git'), null)
assert.equal(
branchTipApiUrl('nousresearch/hermes-agent', 'bb/gui'),
'https://api.github.com/repos/nousresearch/hermes-agent/commits/bb%2Fgui'
)
})

View File

@@ -0,0 +1,111 @@
/**
* Passive update checks against the GitHub REST API instead of git.
*
* Every desktop client used to run `git fetch origin <branch>` (or `ls-remote`)
* twice every 30 minutes, plus on each window focus. Multiplied across the
* install base that is tens of millions of pack negotiations a day against one
* repo — GitHub flagged it. A passive check only needs two facts the API gives
* for free: the remote tip SHA (`GET /repos/{repo}/commits/{branch}` with the
* `application/vnd.github.sha` media type — a 40-byte body) and, when the tips
* differ, the compare endpoint's `ahead_by` + `commits[]`. `git fetch` now
* runs only when the user actually applies an update.
*
* Pure helpers here (URL builders, cache policy, payload mapping) so they are
* unit-testable without booting Electron; the bounded network call is injected.
*/
import { canonicalGitHubRemote } from './update-remote'
export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000
// A failed check (offline, 403 rate-limit) is retried sooner than a good one,
// but never on every poller tick.
export const UPDATE_CHECK_FAILURE_TTL_MS = 60 * 60 * 1000
export interface CachedUpdateCheck {
fetchedAt: number
currentSha: string
branch: string
status: Record<string, unknown> & { error?: string }
}
/** `owner/repo` for any GitHub remote form; null for non-GitHub origins. */
export function githubRepoSlug(originUrl: string): string | null {
const canonical = canonicalGitHubRemote(originUrl)
const match = /^github\.com\/([^/]+\/[^/]+)$/.exec(canonical)
return match ? match[1] : null
}
export function branchTipApiUrl(slug: string, branch: string): string {
return `https://api.github.com/repos/${slug}/commits/${encodeURIComponent(branch)}`
}
export function compareApiUrl(slug: string, currentSha: string, targetSha: string): string {
return `https://api.github.com/repos/${slug}/compare/${currentSha}...${targetSha}`
}
/**
* Whether a cached result still answers a passive check. The cache is keyed on
* the local HEAD and branch: applying an update or switching branches changes
* HEAD and invalidates it immediately, so a 24h TTL never shows a stale
* "update available" after the user just updated.
*/
export function cacheIsFresh(
cached: CachedUpdateCheck | null | undefined,
{ branch, currentSha, now }: { branch: string; currentSha: string; now: number }
): boolean {
if (!cached || cached.branch !== branch || cached.currentSha !== currentSha) {
return false
}
const ttl = cached.status.error ? UPDATE_CHECK_FAILURE_TTL_MS : UPDATE_CHECK_TTL_MS
return now - cached.fetchedAt < ttl
}
export interface CompareCommit {
sha: string
summary: string
author: string
at: number
}
/**
* Map the compare payload to the shape the update overlay renders. `ahead_by`
* is how far the remote tip is ahead of local HEAD, i.e. the behind count; 0
* with differing tips means local carries commits on top of origin (not
* behind). Any shape surprise returns null so callers keep the honest
* "update available, count unknown" state instead of trusting a partial answer.
*/
export function parseCompare(payload: unknown): { behind: number; commits: CompareCommit[] } | null {
if (!payload || typeof payload !== 'object') {
return null
}
const ahead = (payload as { ahead_by?: unknown }).ahead_by
if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) {
return null
}
const raw = (payload as { commits?: unknown }).commits
const commits: CompareCommit[] = Array.isArray(raw)
? raw
.map(entry => {
const sha = typeof entry?.sha === 'string' ? entry.sha : ''
const message = typeof entry?.commit?.message === 'string' ? entry.commit.message : ''
const author = typeof entry?.commit?.author?.name === 'string' ? entry.commit.author.name : ''
const date =
typeof entry?.commit?.committer?.date === 'string' ? Date.parse(entry.commit.committer.date) : NaN
return { sha, summary: message.split('\n')[0], author, at: Number.isFinite(date) ? date : 0 }
})
.filter(commit => commit.sha)
// The overlay lists newest first; compare returns oldest first.
.reverse()
: []
return { behind: ahead, commits }
}

View File

@@ -1,302 +0,0 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
compareApiUrl,
parseCompareBehindCount,
resolveBehindCount,
resolveCommitLogSelection,
shouldCountCommits
} from './update-count'
function createTempGitRepo() {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-update-count-'))
const git = (...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }).trim()
try {
git('init', '--quiet')
git('config', 'commit.gpgSign', 'false')
git('config', 'core.hooksPath', '.git/no-hooks')
git('config', 'user.name', 'Hermes Test')
git('config', 'user.email', 'hermes@example.invalid')
return { cwd, git }
} catch (error) {
fs.rmSync(cwd, { recursive: true, force: true })
throw error
}
}
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
// rev-list count (e.g. 12104) — #51922. Later the branch returned the sentinel
// `1`, which the UI rendered as a literal "1 change included" even when the
// true count was far higher (e.g. 90, or the real-world 61 in #84591). An
// update IS available here, but its exact size is unknown — the only honest
// value is `null`.
test('shallow checkout with no merge-base reports null (unknown count), not a fake 1', () => {
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true
}),
null
)
})
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'abc',
targetSha: 'abc',
isShallow: true
}),
0
)
})
test('shallow local-ahead checkout reports up-to-date when origin is a known ancestor', () => {
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'local-child',
targetSha: 'origin-parent',
isShallow: true,
targetIsAncestorOfHead: true
}),
0
)
})
test('shallow Git graph proves the remote tip is an ancestor of a local commit', () => {
const { cwd, git } = createTempGitRepo()
try {
git('commit', '--allow-empty', '-m', 'origin tip')
const targetSha = git('rev-parse', 'HEAD')
git('update-ref', 'refs/remotes/origin/main', targetSha)
fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${targetSha}\n`)
git('commit', '--allow-empty', '-m', 'local child')
const currentSha = git('rev-parse', 'HEAD')
git('merge-base', '--is-ancestor', 'origin/main', 'HEAD')
assert.notEqual(currentSha, targetSha)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha,
targetSha,
isShallow: true,
targetIsAncestorOfHead: true
}),
0
)
} finally {
fs.rmSync(cwd, { recursive: true, force: true })
}
}, 30_000)
test('shallow checkout with a merge-base does not trust an inflated rev-list count', () => {
const { cwd, git } = createTempGitRepo()
try {
git('commit', '--allow-empty', '-m', 'root')
git('commit', '--allow-empty', '-m', 'ancestor')
const redundantParent = git('rev-parse', 'HEAD')
git('commit', '--allow-empty', '-m', 'installed head')
const currentSha = git('rev-parse', 'HEAD')
const tree = git('rev-parse', 'HEAD^{tree}')
const targetSha = execFileSync('git', ['commit-tree', tree, '-p', currentSha, '-p', redundantParent], {
cwd,
encoding: 'utf8',
input: 'remote merge\n',
timeout: 10_000
}).trim()
git('update-ref', 'refs/remotes/origin/main', targetSha)
const completeCount = git('rev-list', 'HEAD..origin/main', '--count')
assert.equal(completeCount, '1')
fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${currentSha}\n`)
assert.equal(git('rev-parse', '--is-shallow-repository'), 'true')
assert.equal(git('merge-base', 'HEAD', 'origin/main'), currentSha)
const shallowCount = git('rev-list', 'HEAD..origin/main', '--count')
assert.ok(Number.parseInt(shallowCount, 10) > Number.parseInt(completeCount, 10))
assert.equal(
resolveBehindCount({
countStr: shallowCount,
currentSha,
targetSha,
isShallow: true
}),
null
)
} finally {
fs.rmSync(cwd, { recursive: true, force: true })
}
}, 30_000)
test('shallow checkout with a merge-base still uses presence-only status', () => {
assert.equal(
resolveBehindCount({
countStr: '3',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true
}),
null
)
})
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
assert.equal(
resolveBehindCount({
countStr: '7',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false
}),
7
)
})
test('up-to-date full clone reports 0', () => {
assert.equal(
resolveBehindCount({
countStr: '0',
currentSha: 'x',
targetSha: 'x',
isShallow: false
}),
0
)
})
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false
}),
0
)
})
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
// Every shallow graph is incomplete, so a visible merge-base is not enough to
// prove that the count is exact.
test('shallow checkouts skip the rev-list count', () => {
assert.equal(shouldCountCommits({ isShallow: true }), false)
})
test('full (non-shallow) clones run the rev-list count', () => {
assert.equal(shouldCountCommits({ isShallow: false }), true)
})
test('shallow commit logs select only the fetched remote tip', () => {
assert.deepEqual(resolveCommitLogSelection({ branch: 'main', isShallow: true }), {
limit: 1,
revision: 'origin/main'
})
})
test('full-clone commit logs keep the complete behind range', () => {
assert.deepEqual(resolveCommitLogSelection({ branch: 'release', isShallow: false }), {
limit: 40,
revision: 'HEAD..origin/release'
})
})
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
// it and must fall through to the SHA compare (mirrors the live call site).
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true
}),
null
)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'same',
targetSha: 'same',
isShallow: true
}),
0
)
})
// --- compare-API recovery: the accuracy half of the class fix (#84591) ---
const SHA_A = 'a'.repeat(40)
const SHA_B = 'b'.repeat(40)
test('compareApiUrl builds the GitHub compare URL for HTTPS origins', () => {
assert.equal(
compareApiUrl({
currentSha: SHA_A,
originUrl: 'https://github.com/NousResearch/hermes-agent.git',
targetSha: SHA_B
}),
`https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}`
)
})
test('compareApiUrl handles SSH origin forms', () => {
for (const originUrl of [
'git@github.com:NousResearch/hermes-agent.git',
'ssh://git@github.com/NousResearch/hermes-agent.git',
'git@github.com:NousResearch/hermes-agent'
]) {
assert.equal(
compareApiUrl({ currentSha: SHA_A, originUrl, targetSha: SHA_B }),
`https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}`
)
}
})
test('compareApiUrl refuses non-GitHub remotes and partial SHAs', () => {
assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: 'https://gitlab.com/x/y.git', targetSha: SHA_B }), null)
assert.equal(compareApiUrl({ currentSha: 'abc123', originUrl: 'https://github.com/x/y.git', targetSha: SHA_B }), null)
assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: '', targetSha: SHA_B }), null)
})
test('parseCompareBehindCount returns ahead_by (the behind count)', () => {
assert.equal(parseCompareBehindCount({ ahead_by: 61, status: 'ahead' }), 61)
assert.equal(parseCompareBehindCount({ ahead_by: 0, status: 'behind' }), 0)
})
test('parseCompareBehindCount rejects malformed payloads', () => {
assert.equal(parseCompareBehindCount(null), null)
assert.equal(parseCompareBehindCount({}), null)
assert.equal(parseCompareBehindCount({ ahead_by: -2 }), null)
assert.equal(parseCompareBehindCount({ ahead_by: '61' }), null)
assert.equal(parseCompareBehindCount({ ahead_by: 1.5 }), null)
assert.equal(parseCompareBehindCount([]), null)
})

View File

@@ -1,92 +0,0 @@
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
// number worth computing. Installer checkouts are shallow (`--depth 1`), so
// their visible graph is incomplete even when `merge-base` happens to find a
// common commit. A merge can expose ancestry that the local shallow boundary
// hides from HEAD, inflating the count with old commits. Exact counts are only
// trustworthy in full clones; shallow checkouts use presence-only status plus
// any positively proven local-ahead ancestry.
function shouldCountCommits({ isShallow }) {
return !isShallow
}
// Resolve how many commits the local checkout is behind origin for the desktop
// update indicator. Shallow checkouts use SHA equality plus any positively
// proven local-ahead ancestry; exact counts remain exclusive to full clones.
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, targetIsAncestorOfHead = false }) {
if (!shouldCountCommits({ isShallow })) {
if (currentSha && targetSha && (currentSha === targetSha || targetIsAncestorOfHead)) {
return 0
}
// An update IS available, but its size is unknowable without a merge-base.
// Return null — never a numeric sentinel: the UI used to render the old
// `1` as a literal "1 change included" even when the true distance was
// far larger. null lets every surface say "update available" honestly.
return null
}
return Number.parseInt(countStr, 10) || 0
}
// Shallow history can also contaminate the changelog range. Trust the fetched
// remote tip itself, but do not walk its ancestry. Full clones retain the
// detailed range used by the existing update overlay.
function resolveCommitLogSelection({ branch, isShallow }) {
const remote = `origin/${branch}`
return isShallow ? { limit: 1, revision: remote } : { limit: 40, revision: `HEAD..${remote}` }
}
// When the local graph can't count (behind === null), the GitHub compare API
// still can: `GET /repos/<owner>/<repo>/compare/<current>...<target>` returns
// `ahead_by` — how many commits the remote tip is ahead of the local HEAD,
// i.e. exactly the behind count the shallow clone lost. Unauthenticated, no
// clone depth required. Pure URL builder + response parser here; the network
// call lives with the caller.
function compareApiUrl({ currentSha, originUrl, targetSha }) {
const sha = /^[0-9a-f]{40}$/i
if (!sha.test(currentSha || '') || !sha.test(targetSha || '')) {
return null
}
// Only GitHub remotes have a compare API. Reuse the canonical form the
// official-remote check produces: `github.com/<owner>/<repo>`.
const canonical = canonicalRemoteForCompare(originUrl)
if (!canonical) {
return null
}
return `https://api.github.com/repos/${canonical}/compare/${currentSha}...${targetSha}`
}
function canonicalRemoteForCompare(originUrl) {
const value = String(originUrl || '').trim()
const match =
/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value) ||
/^(?:ssh:\/\/git@|https:\/\/|http:\/\/)github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value)
return match ? match[1] : null
}
// `ahead_by` counts target commits not reachable from current — the behind
// count. `status` is "ahead" / "behind" / "diverged" / "identical" relative to
// current...target; any shape surprise returns null so the caller keeps the
// honest "update available" fallback instead of trusting a partial answer.
function parseCompareBehindCount(payload) {
if (!payload || typeof payload !== 'object') {
return null
}
const ahead = payload.ahead_by
if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) {
return null
}
return ahead
}
export { compareApiUrl, parseCompareBehindCount, resolveBehindCount, resolveCommitLogSelection, shouldCountCommits }

View File

@@ -72,7 +72,7 @@ export function AboutSettings() {
const handleCheck = async () => {
setJustChecked(false)
const next = await checkUpdates()
const next = await checkUpdates({ force: true })
setJustChecked(Boolean(next))
}

View File

@@ -138,7 +138,7 @@ export function UpdatesOverlay() {
commits={status?.commits ?? []}
onInstall={handleInstall}
onLater={() => handleClose(false)}
onRetryCheck={() => void check()}
onRetryCheck={() => void check({ force: true })}
status={status}
target={target}
updateAvailable={updateAvailable}

View File

@@ -526,7 +526,7 @@ declare global {
relaunchApp?: () => Promise<void>
getRemoteDisplayReason?: () => Promise<string | null>
updates: {
check: () => Promise<DesktopUpdateStatus>
check: (opts?: { force?: boolean }) => Promise<DesktopUpdateStatus>
apply: (opts?: DesktopUpdateApplyOptions) => Promise<DesktopUpdateApplyResult>
getBranch: () => Promise<{ branch: string }>
setBranch: (name: string) => Promise<{ branch: string }>

View File

@@ -99,7 +99,8 @@ const {
resetUpdateApplyState,
startUpdatePoller,
stopUpdatePoller,
$updateStatus
$updateStatus,
BACKGROUND_UPDATE_CHECK_MS
} = await import('./updates')
const { setConnection } = await import('./session')
@@ -1356,24 +1357,27 @@ describe('startUpdatePoller', () => {
it('calls checkUpdates() on startup so the version pill populates immediately', async () => {
startUpdatePoller()
// checkUpdates() is async — flush microtasks without advancing the 30-min interval.
// checkUpdates() is async — flush microtasks without advancing the daily interval.
await vi.advanceTimersByTimeAsync(0)
expect(checkMock).toHaveBeenCalled()
expect($updateStatus.get()?.behind).toBe(5)
})
it('calls checkUpdates() on each interval tick', async () => {
it('polls once per day and never forces past the caches', async () => {
startUpdatePoller()
await vi.advanceTimersByTimeAsync(0)
expect(checkMock).toHaveBeenCalledWith({ force: false })
checkMock.mockClear()
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
await vi.advanceTimersByTimeAsync(BACKGROUND_UPDATE_CHECK_MS - 1)
expect(checkMock).not.toHaveBeenCalled()
expect(checkMock).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(checkMock).toHaveBeenCalledTimes(1)
})
it('calls checkUpdates() when the window regains focus', async () => {
it('window focus only re-checks once the daily cadence has elapsed', async () => {
startUpdatePoller()
await vi.advanceTimersByTimeAsync(0)
checkMock.mockClear()
@@ -1381,9 +1385,12 @@ describe('startUpdatePoller', () => {
// Invoke the registered focus handler directly (the mock window doesn't
// propagate DOM events, so call the stored listener).
listeners['focus']?.()
await vi.advanceTimersByTimeAsync(0)
expect(checkMock).not.toHaveBeenCalled()
expect(checkMock).toHaveBeenCalled()
vi.setSystemTime(Date.now() + BACKGROUND_UPDATE_CHECK_MS)
listeners['focus']?.()
await vi.advanceTimersByTimeAsync(0)
expect(checkMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -66,7 +66,7 @@ export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(op
export const openUpdateOverlayFor = (target: UpdateTarget) => {
$updateOverlayTarget.set(target)
$updateOverlayOpen.set(true)
void (target === 'backend' ? checkBackendUpdates() : checkUpdates())
void (target === 'backend' ? checkBackendUpdates({ force: true }) : checkUpdates({ force: true }))
}
export const resetUpdateApplyState = () => {
@@ -386,7 +386,19 @@ function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
}
}
export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null> {
/**
* `force` bypasses every cache (Electron's 24h on-disk cache, the backend's
* `.update_check`). Only user-initiated checks pass it — the menu item,
* Settings "Check now", opening the overlay. The background poller never does:
* each forced check is a real GitHub round trip from every running client.
*/
export interface UpdateCheckOptions {
force?: boolean
}
export async function checkBackendUpdates({
force = false
}: UpdateCheckOptions = {}): Promise<DesktopUpdateStatus | null> {
if (!isRemoteMode() || $backendUpdateChecking.get()) {
return $backendUpdateStatus.get()
}
@@ -394,7 +406,7 @@ export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null>
$backendUpdateChecking.set(true)
try {
const status = mapBackendCheck(await checkHermesUpdate(true))
const status = mapBackendCheck(await checkHermesUpdate(force))
$backendUpdateStatus.set(status)
maybeNotifyUpdateAvailable(status, 'backend')
@@ -415,7 +427,7 @@ export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null>
}
}
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
export async function checkUpdates({ force = false }: UpdateCheckOptions = {}): Promise<DesktopUpdateStatus | null> {
const bridge = window.hermesDesktop?.updates
if (!bridge || $updateChecking.get()) {
@@ -425,7 +437,7 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
$updateChecking.set(true)
try {
const status = await bridge.check()
const status = await bridge.check({ force })
$updateStatus.set(status)
maybeNotifyUpdateAvailable(status, 'client')
void refreshDesktopVersion()
@@ -562,7 +574,7 @@ function finishBackendApply(returned: boolean): DesktopUpdateApplyResult {
if (returned) {
$backendUpdateApply.set(IDLE)
setUpdateOverlayOpen(false)
void checkBackendUpdates()
void checkBackendUpdates({ force: true })
// The update restarted the gateway process, which strands this window's
// WebSocket: over SSH/tailscale tunnels the old TCP connection often dies
// without a close event, so connectionState still reads 'open' while every
@@ -823,7 +835,7 @@ async function maybeNudgeClientAfterBackendUpdate(): Promise<void> {
return
}
const status = (await checkUpdates().catch(() => null)) ?? $updateStatus.get()
const status = (await checkUpdates({ force: true }).catch(() => null)) ?? $updateStatus.get()
if (!status || status.error || (!status.updateAvailable && (status.behind ?? 0) <= 0)) {
return
@@ -941,12 +953,12 @@ async function runEverythingUpdate(): Promise<void> {
// 3. The client last — its apply relaunches or hands off the app, so it
// must come after every dispatch above. Skipped when already current.
// Re-check rather than trusting `$updateStatus`: the cached value can be
// up to a poll interval (30 min) old and was captured BEFORE the backend
// up to a poll interval (24h) old and was captured BEFORE the backend
// update above, so a cached `behind: 0` would skip the client leg and
// leave the app stale — the exact failure this flow exists to prevent.
// `checkUpdates()` resolves with an error-status rather than rejecting,
// so fall back to the pre-flow snapshot when the live check can't answer.
const freshClientStatus = await checkUpdates().catch(() => null)
const freshClientStatus = await checkUpdates({ force: true }).catch(() => null)
const clientStatus = freshClientStatus?.error ? cachedClientStatus : (freshClientStatus ?? cachedClientStatus)
if ((clientStatus?.behind ?? 0) > 0 || clientStatus?.updateAvailable) {
@@ -985,10 +997,29 @@ function ingestProgress(payload: DesktopUpdateProgress): void {
let pollerStarted = false
let backgroundTimer: ReturnType<typeof setInterval> | null = null
let lastFocusAt = 0
let connectionUnsub: (() => void) | null = null
let lastConnectionMode: string | undefined
// Passive checks run at most once per day per client. The main process and the
// backend each keep a 24h cache, so a tick or focus that lands inside the window
// is answered locally — the interval just decides how often we ask.
export const BACKGROUND_UPDATE_CHECK_MS = 24 * 60 * 60 * 1000
// Focus re-checks are bounded by the same day-long cadence, tracked here so a
// user who alt-tabs every minute never turns focus into a poll.
const FOCUS_RECHECK_KEY = 'hermes.updates.last-passive-check'
function passiveCheckDue(now: number): boolean {
const last = Number(storedString(FOCUS_RECHECK_KEY) ?? 0)
return !Number.isFinite(last) || now - last >= BACKGROUND_UPDATE_CHECK_MS
}
function runPassiveChecks(): void {
persistString(FOCUS_RECHECK_KEY, String(Date.now()))
void checkUpdates()
void checkBackendUpdates()
}
/** Wire up background polling + progress streaming. Idempotent. */
export function startUpdatePoller(): void {
if (pollerStarted || typeof window === 'undefined') {
@@ -1002,8 +1033,7 @@ export function startUpdatePoller(): void {
}
pollerStarted = true
void checkUpdates()
void checkBackendUpdates()
runPassiveChecks()
void refreshDesktopVersion()
bridge.onProgress(ingestProgress)
@@ -1023,13 +1053,7 @@ export function startUpdatePoller(): void {
})
window.addEventListener('focus', onFocus)
backgroundTimer = setInterval(
() => {
void checkUpdates()
void checkBackendUpdates()
},
30 * 60 * 1000
)
backgroundTimer = setInterval(runPassiveChecks, BACKGROUND_UPDATE_CHECK_MS)
}
export function stopUpdatePoller(): void {
@@ -1046,14 +1070,9 @@ export function stopUpdatePoller(): void {
}
function onFocus() {
const now = Date.now()
if (now - lastFocusAt < 5 * 60 * 1000) {
return
}
lastFocusAt = now
void checkUpdates()
void checkBackendUpdates()
void refreshDesktopVersion()
if (passiveCheckDue(Date.now())) {
runPassiveChecks()
}
}