diff --git a/apps/desktop/electron/gitlock.test.ts b/apps/desktop/electron/gitlock.test.ts new file mode 100644 index 0000000000..bfaa067157 --- /dev/null +++ b/apps/desktop/electron/gitlock.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +import { clearStaleGitLocks, LOCK_NAMES, STALE_LOCK_MIN_AGE_MS } from './gitlock' + +function makeRepo(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitlock-test-')) + fs.mkdirSync(path.join(root, '.git')) + return root +} + +function writeLock(root: string, name: string, ageMs: number): string { + const p = path.join(root, '.git', name) + fs.writeFileSync(p, '') + const t = new Date(Date.now() - ageMs) + fs.utimesSync(p, t, t) + return p +} + +const noGit = async () => false +const gitRunning = async () => true + +test('stale shallow.lock older than min age is removed', async () => { + const root = makeRepo() + const lock = writeLock(root, 'shallow.lock', STALE_LOCK_MIN_AGE_MS + 60_000) + const removed = await clearStaleGitLocks(root, { isGitRunning: noGit }) + assert.deepEqual(removed, [lock]) + assert.equal(fs.existsSync(lock), false) +}) + +test('fresh lock is presumed live and never removed', async () => { + const root = makeRepo() + const lock = writeLock(root, 'shallow.lock', 1_000) + const removed = await clearStaleGitLocks(root, { isGitRunning: noGit }) + assert.deepEqual(removed, []) + assert.equal(fs.existsSync(lock), true) +}) + +test('running git process protects even ancient locks', async () => { + const root = makeRepo() + const lock = writeLock(root, 'shallow.lock', STALE_LOCK_MIN_AGE_MS * 10) + const removed = await clearStaleGitLocks(root, { isGitRunning: gitRunning }) + assert.deepEqual(removed, []) + assert.equal(fs.existsSync(lock), true) +}) + +test('all known lock names are cleared when stale', async () => { + const root = makeRepo() + const locks = LOCK_NAMES.map(name => writeLock(root, name, STALE_LOCK_MIN_AGE_MS + 60_000)) + const removed = await clearStaleGitLocks(root, { isGitRunning: noGit }) + assert.deepEqual(removed.sort(), locks.sort()) +}) + +test('unknown lock-like files are left alone', async () => { + const root = makeRepo() + const stray = writeLock(root, 'config.lock', STALE_LOCK_MIN_AGE_MS * 10) + await clearStaleGitLocks(root, { isGitRunning: noGit }) + assert.equal(fs.existsSync(stray), true) +}) + +test('missing .git dir is a silent no-op', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitlock-nogit-')) + const removed = await clearStaleGitLocks(root, { isGitRunning: noGit }) + assert.deepEqual(removed, []) +}) diff --git a/apps/desktop/electron/gitlock.ts b/apps/desktop/electron/gitlock.ts new file mode 100644 index 0000000000..09677e6f8b --- /dev/null +++ b/apps/desktop/electron/gitlock.ts @@ -0,0 +1,79 @@ +// Stale git lock-file recovery for the desktop update-check path. +// +// A crashed or killed `git fetch` on a shallow clone can leave +// `.git/shallow.lock` behind. Every later fetch then fails with +// "fatal: Unable to create '.git/shallow.lock': File exists", so the desktop +// update check reports 'fetch-failed' forever — git never self-heals these +// lock files. Mirrors hermes_cli/gitlock.py: a lock is removed only when it +// is older than the min age AND no git process is currently running. + +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +// Lock files younger than this are presumed live (a fetch is in flight) and +// are never removed. git lock files live for seconds under normal operation; +// anything older than 10 minutes is abandoned. +export const STALE_LOCK_MIN_AGE_MS = 10 * 60 * 1000 + +// Same self-healable lock set as hermes_cli/gitlock.py. +export const LOCK_NAMES = ['shallow.lock', 'index.lock', 'HEAD.lock', 'MERGE_HEAD.lock'] + +function gitProcessRunning(): Promise { + return new Promise(resolve => { + const [cmd, args] = + process.platform === 'win32' + ? ['tasklist', ['/FI', 'IMAGENAME eq git.exe', '/FO', 'CSV']] + : ['pgrep', ['-x', 'git']] + execFile(cmd, args, { timeout: 10_000 }, (error, stdout) => { + if (process.platform === 'win32') { + // tasklist exits 0 either way; presence is signaled in stdout. + resolve(Boolean(stdout && stdout.toLowerCase().includes('git.exe'))) + return + } + // pgrep: exit 0 = at least one match; 1 = none; other = probe failure. + // On probe failure stay conservative: report "running" so no lock is + // touched when we cannot tell. + if (error && (error as any).code === 1) { + resolve(false) + return + } + resolve(true) + }) + }) +} + +// Remove abandoned .git lock files under repoRoot. Returns removed paths. +// Never throws: a lock we cannot stat or unlink is skipped. +export async function clearStaleGitLocks( + repoRoot: string, + { minAgeMs = STALE_LOCK_MIN_AGE_MS, isGitRunning = gitProcessRunning }: { + minAgeMs?: number + isGitRunning?: () => Promise + } = {} +): Promise { + const gitDir = path.join(repoRoot, '.git') + const removed: string[] = [] + try { + if (!fs.statSync(gitDir).isDirectory()) return removed + } catch { + return removed + } + + if (await isGitRunning()) return removed + + const cutoff = Date.now() - minAgeMs + for (const name of LOCK_NAMES) { + const lockPath = path.join(gitDir, name) + try { + const st = fs.statSync(lockPath) + if (st.isFile() && st.mtimeMs < cutoff) { + fs.unlinkSync(lockPath) + removed.push(lockPath) + } + } catch { + // Missing or concurrently removed — skipping is always safe. + } + } + return removed +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c56f4112ac..3bcc28ad53 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -253,6 +253,7 @@ import { resolveCommitLogSelection, shouldCountCommits } from './update-count' +import { clearStaleGitLocks } from './gitlock' import { waitForUpdateClearance } from './update-gate' import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' @@ -2661,6 +2662,12 @@ async function checkUpdates() { } } + // 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) { diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 0b15493a39..a1cd4748f6 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -319,6 +319,15 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]: is_shallow = shallow == "true" try: + # Self-heal abandoned git lock files before fetching. A stale + # .git/shallow.lock from a crashed fetch makes the fetch fail, the + # exception below is swallowed, and stale refs get compared against + # HEAD — silently degrading the passive check until a human removes + # the lock (git never self-heals these). + from hermes_cli.gitlock import clear_stale_git_locks + + clear_stale_git_locks(repo_dir) + # Scope the fetch to the one branch the behind-count compares against. # An unscoped ``git fetch origin`` transfers every remote head (~1,400 # on this repo — measured 3.0 s vs 0.55 s scoped) and can burn the full