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.
112 lines
4.2 KiB
TypeScript
112 lines
4.2 KiB
TypeScript
/**
|
|
* 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 }
|
|
}
|