6dfcf15685
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.
82 lines
3.1 KiB
TypeScript
82 lines
3.1 KiB
TypeScript
/**
|
|
* 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'
|
|
)
|
|
})
|