{"repo":"NousResearch/hermes-agent","public_id":"51922","display_label":"Fix bogus update count in shallow checkouts","summary":"A bug causes the desktop client to report an incorrect update count when working in a shallow checkout. These members address identifying and fixing that specific issue.","subsystem":"apps/desktop","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52201,"merged_at":"2026-06-25T00:34:02+00:00"},"resolution":{"state":"resolved","by":"pr-52201"},"freshness":{"state":"verified","last_verified_at":"2026-06-25T00:34:02+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":51922,"pr_number":52201,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-25T00:34:02+00:00"}],"members":[{"kind":"issue","number":51922,"title":"Desktop shows bogus client update count in dirty shallow checkout","url":null,"state":"closed"},{"kind":"pr","number":51979,"title":"fix(desktop): don't report a bogus update count for a shallow checkout","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":52201,"title":"fix(desktop): don't report a bogus update count for a shallow checkout","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.cjs","apps/desktop/electron/update-count.cjs","apps/desktop/electron/update-count.test.cjs","apps/desktop/package.json"],"fix_hunks":[{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -57,6 +57,7 @@ const {\n const { gitRootForIpc } = require('./git-root.cjs')\n const { worktreesForIpc } = require('./git-worktrees.cjs')\n const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')\n+const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')\n const { runRebuildWithRetry } = require('./update-rebuild.cjs')\n const {\n   buildPosixCleanupScript,","@@ -1706,15 +1707,34 @@ async function checkUpdates() {\n   }\n \n   const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())\n-  const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([\n+  const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([\n     git(['rev-parse', 'HEAD']),\n     git(['rev-parse', `origin/${branch}`]),\n-    git(['rev-list', `HEAD..origin/${branch}`, '--count']),\n     git(['status', '--porcelain']),\n-    git(['rev-parse', '--abbrev-ref', 'HEAD'])\n+    git(['rev-parse', '--abbrev-ref', 'HEAD']),\n+    git(['rev-parse', '--is-shallow-repository']),\n+    // merge-base exits non-zero with empty stdout when HEAD shares no common\n+    // ancestor with the freshly fetched tip — exactly the shallow-clone case.\n+    git(['merge-base', 'HEAD', `origin/${branch}`])\n   ])\n \n-  const behind = Number.parseInt(countStr, 10) || 0\n+  const isShallow = shallowStr === 'true'\n+  const hasMergeBase = Boolean(mergeBaseStr)\n+  // Only enumerate the commit count when it is meaningful. On a shallow checkout\n+  // with no merge-base, `rev-list --count` walks the entire remote ancestry\n+  // (thousands of commits, see #51922) and resolveBehindCount discards the\n+  // result anyway in favour of a SHA compare — so skip the expensive query.\n+  const countStr = shouldCountCommits({ isShallow, hasMergeBase })\n+    ? await git(['rev-list', `HEAD..origin/${branch}`, '--count'])\n+    : ''\n+\n+  const behind = resolveBehindCount({\n+    countStr,\n+    currentSha,\n+    targetSha,\n+    isShallow,\n+    hasMergeBase\n+  })\n   const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : []\n \n   return {"]},{"file":"apps/desktop/electron/update-count.cjs","hunks":["@@ -0,0 +1,28 @@\n+'use strict'\n+\n+// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful\n+// number worth computing. On a SHALLOW checkout (installer clones with\n+// --depth 1) the local history often shares no merge-base with the freshly\n+// fetched origin tip, so the count enumerates the entire remote ancestry and\n+// returns a bogus huge number (e.g. 12104) — see #51922. resolveBehindCount\n+// discards that bogus count in favour of a SHA compare, so the caller should\n+// SKIP the expensive rev-list entirely in that case rather than run it and\n+// throw the result away.\n+function shouldCountCommits({ isShallow, hasMergeBase }) {\n+  return !(isShallow && !hasMergeBase)\n+}\n+\n+// Resolve how many commits the local checkout is behind origin for the desktop\n+// update indicator. When the count isn't meaningful (shallow + no merge-base)\n+// fall back to a binary up-to-date check by SHA, exactly like the official-SSH\n+// path in checkUpdates() and the CLI guard in hermes_cli/banner.py. Full clones\n+// (developers / Docker dev images) keep the exact count path unchanged.\n+function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {\n+  if (!shouldCountCommits({ isShallow, hasMergeBase })) {\n+    if (currentSha && targetSha && currentSha === targetSha) return 0\n+    return 1 // behind by an unknown amount — show a generic \"update available\"\n+  }\n+  return Number.parseInt(countStr, 10) || 0\n+}\n+\n+module.exports = { resolveBehindCount, shouldCountCommits }"]},{"file":"apps/desktop/electron/update-count.test.cjs","hunks":["@@ -0,0 +1,79 @@\n+'use strict'\n+const test = require('node:test')\n+const assert = require('node:assert/strict')\n+const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')\n+\n+// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`\n+// unconditionally, so a shallow checkout with no merge-base surfaced the bogus\n+// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.\n+test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {\n+  assert.equal(resolveBehindCount({\n+    countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',\n+    isShallow: true, hasMergeBase: false,\n+  }), 1)\n+})\n+\n+test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {\n+  assert.equal(resolveBehindCount({\n+    countStr: '12104', currentSha: 'abc', targetSha: 'abc',\n+    isShallow: true, hasMergeBase: false,\n+  }), 0)\n+})\n+\n+test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {\n+  assert.equal(resolveBehindCount({\n+    countStr: '3', currentSha: 'aaa', targetSha: 'bbb',\n+    isShallow: true, hasMergeBase: true,\n+  }), 3)\n+})\n+\n+test('full (non-shallow) clone keeps the exact count path unchanged', () => {\n+  assert.equal(resolveBehindCount({\n+    countStr: '7', currentSha: 'aaa', targetSha: 'bbb',\n+    isShallow: false, hasMergeBase: true,\n+  }), 7)\n+})\n+\n+test('up-to-date full clone reports 0', () => {\n+  assert.equal(resolveBehindCount({\n+    countStr: '0', currentSha: 'x', targetSha: 'x',\n+    isShallow: false, hasMergeBase: true,\n+  }), 0)\n+})\n+\n+test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {\n+  assert.equal(resolveBehindCount({\n+    countStr: '', currentSha: 'aaa', targetSha: 'bbb',\n+    isShallow: false, hasMergeBase: true,\n+  }), 0)\n+})\n+\n+// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().\n+// FAIL-BEFORE: in the shallow + no-merge-base case the caller ran rev-list\n+// unconditionally and discarded the bogus result; this predicate lets the\n+// caller SKIP the whole-ancestry enumeration in exactly that case (#51922).\n+test('shallow checkout with no merge-base SKIPS the rev-list count', () => {\n+  assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: false }), false)\n+})\n+\n+test('shallow checkout WITH a merge-base still runs the count', () => {\n+  assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: true }), true)\n+})\n+\n+test('full (non-shallow) clone always runs the count', () => {\n+  assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: true }), true)\n+  assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: false }), true)\n+})\n+\n+// The skip path produces an empty countStr; resolveBehindCount must NOT trust\n+// it and must fall through to the SHA compare (mirrors the live call site).\n+test('skipped-count path resolves via SHA compare, never via empty countStr', () => {\n+  assert.equal(resolveBehindCount({\n+    countStr: '', currentSha: 'aaa', targetSha: 'bbb',\n+    isShallow: true, hasMergeBase: false,\n+  }), 1)\n+  assert.equal(resolveBehindCount({\n+    countStr: '', currentSha: 'same', targetSha: 'same',\n+    isShallow: true, hasMergeBase: false,\n+  }), 0)\n+})"]},{"file":"apps/desktop/package.json","hunks":["@@ -37,7 +37,7 @@\n     \"test:desktop:nsis\": \"node scripts/test-desktop.mjs nsis\",\n     \"test:desktop:existing\": \"node scripts/test-desktop.mjs existing\",\n     \"test:desktop:fresh\": \"node scripts/test-desktop.mjs fresh\",\n-    \"test:desktop:platforms\": \"node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs\",\n+    \"test:desktop:platforms\": \"node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs\",\n     \"typecheck\": \"tsc -p . --noEmit\",\n     \"lint\": \"eslint src/ electron/\",\n     \"lint:fix\": \"eslint src/ electron/ --fix\",\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}