fix(desktop): show 'update available' instead of fake '1 change included' on shallow clones

On an installer checkout (clone --depth 1) with no merge-base against the
freshly fetched origin tip, resolveBehindCount returned the sentinel 1 and
every surface rendered it as a literal count: 'A new update is ready (1
change included).' — even when the true distance was far larger (observed:
90 commits). The sentinel was meant to mean 'update available, exact count
unknown', but nothing downstream distinguished it from a real one.

- update-count.ts: return null (unknown) instead of the numeric sentinel
- main.ts: flag updateAvailable explicitly and still serve the (capped)
  commit log so 'See what's new' stays useful in the unknown case
- updates.ts: toast fires for behind:null + updateAvailable, with
  count-free copy instead of being swallowed by the <= 0 guard
- about-settings.tsx: status line and action buttons key off
  updateAvailable; unknown size renders the new count-free string
- i18n: updateReadyUnknown / updateReadyMessageUnknown in all 5 locales

Refs #51922 (the shallow-clone special case this UI now renders honestly).

Tests: vitest electron 10/10, ui 44/44 (3 FAIL-BEFORE reds turned green),
tsc typecheck clean, eslint clean on all touched files.
This commit is contained in:
Dolverin
2026-08-10 20:25:46 +02:00
committed by Teknium
parent 294071bf08
commit 54bd47b811
13 changed files with 57 additions and 16 deletions

View File

@@ -2633,13 +2633,17 @@ async function checkUpdates() {
hasMergeBase
})
const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : []
// behind === null means "update available, exact count unknown" (shallow
// clone without a merge-base): still list what origin offers (the log read
// is capped at 40 entries), so "See what's new" stays useful and honest.
const commits = behind !== 0 ? await readCommitLog(updateRoot, branch) : []
return {
supported: true,
branch,
currentBranch,
behind,
updateAvailable: behind === null || behind > 0,
currentSha,
targetSha,
commits,

View File

@@ -4,10 +4,11 @@ import { test } from 'vitest'
import { resolveBehindCount, shouldCountCommits } from './update-count'
// 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). This asserts the new shallow/no-merge-base branch.
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
// FAIL-BEFORE: the shallow/no-merge-base 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). 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',
@@ -16,7 +17,7 @@ test('shallow checkout with no merge-base does NOT trust the bogus rev-list coun
isShallow: true,
hasMergeBase: false
}),
1
null
)
})
@@ -113,7 +114,7 @@ test('skipped-count path resolves via SHA compare, never via empty countStr', ()
isShallow: true,
hasMergeBase: false
}),
1
null
)
assert.equal(
resolveBehindCount({

View File

@@ -21,7 +21,11 @@ function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMer
return 0
}
return 1 // behind by an unknown amount — show a generic "update available"
// 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

View File

@@ -63,6 +63,9 @@ export function AboutSettings() {
}, [])
const behind = status?.behind ?? 0
// behind is null when the exact count is unknowable (shallow clone): the
// backend flags that case via updateAvailable instead of a number.
const updateAvailable = behind > 0 || Boolean(status?.updateAvailable)
const supported = status?.supported !== false
const applying = apply.applying || apply.stage === 'restart'
@@ -84,8 +87,8 @@ export function AboutSettings() {
} else if (applying) {
statusLine = a.installing
statusTone = 'available'
} else if (behind > 0) {
statusLine = a.updateReady(behind)
} else if (updateAvailable) {
statusLine = behind > 0 ? a.updateReady(behind) : a.updateReadyUnknown
statusTone = 'available'
} else if (status) {
statusLine = a.onLatest
@@ -142,7 +145,7 @@ export function AboutSettings() {
{checking ? a.checking : a.checkNow}
</Button>
{behind > 0 && supported && !applying && (
{updateAvailable && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}

View File

@@ -439,7 +439,10 @@ export interface DesktopUpdateStatus {
reason?: string
message?: string
error?: string
behind?: number
/** Exact commits behind. null = update available, but the count is
* unknowable (shallow clone without a merge-base) — never render it as a
* literal number. */
behind?: number | null
currentSha?: string
/** Backend only: the version string the backend reports for itself. */
currentVersion?: string

View File

@@ -113,6 +113,7 @@ export const ar = defineLocale({
updateHermes: 'تحديث Hermes',
updateReadyTitle: 'التحديث جاهز',
updateReadyMessage: count => `${count} تغيير جديد متاح.`,
updateReadyMessageUnknown: 'يتوفر تحديث جديد.',
seeWhatsNew: 'عرض الجديد',
errors: {
elevenLabsNeedsKey: 'يتطلب ElevenLabs STT المفتاح ELEVENLABS_API_KEY.',
@@ -602,6 +603,7 @@ export const ar = defineLocale({
cantReach: 'تعذر الوصول لخدمة التحديث',
tapCheck: 'اضغط للتحقق من التحديثات.',
updateReady: count => `${count} تحديث متاح`,
updateReadyUnknown: 'تحديث جديد جاهز.',
lastChecked: age => `آخر تحقق ${age}`,
justNowSuffix: 'الآن',
automaticUpdates: 'التحديثات التلقائية',

View File

@@ -128,6 +128,7 @@ export const en: Translations = {
updateHermes: 'Update Hermes',
updateReadyTitle: 'Update ready',
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
updateReadyMessageUnknown: 'A new update is available.',
seeWhatsNew: "See what's new",
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
@@ -553,6 +554,7 @@ export const en: Translations = {
cantReach: "We couldn't reach the update server.",
tapCheck: 'Tap "Check now" to look for updates.',
updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`,
updateReadyUnknown: 'A new update is ready.',
lastChecked: age => `Last checked ${age}`,
justNowSuffix: ' · just now',
automaticUpdates: 'Automatic updates',

View File

@@ -129,6 +129,7 @@ export const ja = defineLocale({
updateHermes: 'Hermes を更新',
updateReadyTitle: '更新の準備ができました',
updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`,
updateReadyMessageUnknown: '新しい更新が利用可能です。',
seeWhatsNew: '新機能を見る',
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT には ELEVENLABS_API_KEY が必要です。',
@@ -637,6 +638,7 @@ export const ja = defineLocale({
cantReach: '更新サーバーに接続できませんでした。',
tapCheck: '更新を探すには「今すぐ確認」を押してください。',
updateReady: count => `新しい更新の準備ができました (${count} 件の変更を含みます)。`,
updateReadyUnknown: '新しい更新の準備ができました。',
lastChecked: age => `前回確認: ${age}`,
justNowSuffix: ' · たった今',
automaticUpdates: '自動更新',

View File

@@ -170,6 +170,7 @@ export interface Translations {
updateHermes: string
updateReadyTitle: string
updateReadyMessage: (count: number) => string
updateReadyMessageUnknown: string
seeWhatsNew: string
errors: {
elevenLabsNeedsKey: string
@@ -450,6 +451,7 @@ export interface Translations {
cantReach: string
tapCheck: string
updateReady: (count: number) => string
updateReadyUnknown: string
lastChecked: (age: string) => string
justNowSuffix: string
automaticUpdates: string

View File

@@ -125,6 +125,7 @@ export const zhHant = defineLocale({
updateHermes: '更新 Hermes',
updateReadyTitle: '有可用更新',
updateReadyMessage: count => `有 ${count} 項新變更可用。`,
updateReadyMessageUnknown: '有新更新可用。',
seeWhatsNew: '查看新增內容',
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
@@ -624,6 +625,7 @@ export const zhHant = defineLocale({
cantReach: '無法連線到更新伺服器。',
tapCheck: '點選「立即檢查」以尋找更新。',
updateReady: count => `新更新已就緒(包含 ${count} 項變更)。`,
updateReadyUnknown: '新更新已就緒。',
lastChecked: age => `上次檢查:${age}`,
justNowSuffix: ' · 剛剛',
automaticUpdates: '自動更新',

View File

@@ -125,6 +125,7 @@ export const zh: Translations = {
updateHermes: '更新 Hermes',
updateReadyTitle: '有可用更新',
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
updateReadyMessageUnknown: '有新更新可用。',
seeWhatsNew: '查看更新内容',
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
@@ -762,6 +763,7 @@ export const zh: Translations = {
cantReach: '无法连接更新服务器。',
tapCheck: '点击"立即检查"以查找更新。',
updateReady: count => `已准备好新更新 (包含 ${count} 项更改)。`,
updateReadyUnknown: '新更新已就绪。',
lastChecked: age => `上次检查:${age}`,
justNowSuffix: ' · 刚刚',
automaticUpdates: '自动更新',

View File

@@ -123,6 +123,15 @@ describe('maybeNotifyUpdateAvailable', () => {
maybeNotifyUpdateAvailable(status({ behind: 0 }))
expect(notifySpy).not.toHaveBeenCalled()
})
// FAIL-BEFORE: a shallow installer clone reports behind:null + updateAvailable
// (exact count unknowable without a merge-base). The guard treated null as 0
// and silently swallowed the notification entirely.
it('still notifies with generic copy when the exact behind count is unknown', () => {
maybeNotifyUpdateAvailable(status({ behind: null, updateAvailable: true }))
expect(notifySpy).toHaveBeenCalledTimes(1)
expect(notifySpy.mock.calls[0]?.[0]).toMatchObject({ message: 'A new update is available.' })
})
})
describe('reportBackendContract', () => {

View File

@@ -206,7 +206,11 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
return
}
if ((status.behind ?? 0) <= 0) {
const behind = typeof status.behind === 'number' ? status.behind : null
// behind === null means "update available, exact count unknown" (shallow
// clone). That still deserves the toast — just with count-free copy.
if ((behind ?? 0) <= 0 && !status.updateAvailable) {
return
}
@@ -218,8 +222,6 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
return
}
const behind = status.behind ?? 0
notify({
action: {
label: translateNow('notifications.seeWhatsNew'),
@@ -232,7 +234,10 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
icon: 'gift',
id: UPDATE_TOAST_ID,
kind: 'info',
message: translateNow('notifications.updateReadyMessage', behind),
message:
behind !== null && behind > 0
? translateNow('notifications.updateReadyMessage', behind)
: translateNow('notifications.updateReadyMessageUnknown'),
onDismiss: () => snoozeUpdateToast(),
title: translateNow('notifications.updateReadyTitle')
})