From 236593bb52cb4fe4125fcfdfd36559626d76d9b7 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 22 Jun 2026 11:43:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20bare=20cache=20=E7=94=A8=20mirror=20?= =?UTF-8?q?+=20=E6=98=BE=E5=BC=8F=20refspec,=E4=BF=AE=E5=A4=8D=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=88=86=E6=94=AF=E6=B0=B8=E4=B9=85=E5=86=BB=E7=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git clone --bare 不创建 remote.origin.fetch refspec,导致 fetchIfStale 里的 git fetch --all 只下载对象、刷新 FETCH_HEAD,却不会移动 refs/heads/*。 缓存分支因此冻结在首次 clone 的 commit,之后远程的所有 push 都丢失, 直到缓存 30 天过期重建为止 —— 从该缓存 local-clone 出来的每个工作区都是旧码。 两处修复: - cloneBareAtomic: clone --bare → clone --mirror(隐含 bare 且持久化 +refs/*:refs/* refspec,新缓存 fetch 即可正确前进) - fetchIfStale: fetch --all → fetch --prune origin 显式 +refs/heads/* 与 +refs/tags/* refspec。显式 refspec 不依赖仓库配置,既保证新缓存正确, 也能原地修复存量旧 --bare 缓存(无需等 30 天重建) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/workspace/cache.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/workspace/cache.ts b/src/workspace/cache.ts index dfccc27f..00b1a2d3 100644 --- a/src/workspace/cache.ts +++ b/src/workspace/cache.ts @@ -145,8 +145,12 @@ function cloneBareAtomic(repoUrl: string, cachePath: string): void { logger.info({ repoUrl: sanitizeRepoUrl(repoUrl), cachePath }, 'Creating bare clone cache'); try { + // 用 --mirror 而非 --bare:--mirror 隐含 --bare,并写入 + // remote.origin.fetch = +refs/*:refs/* refspec,使后续 git fetch 能真正 + // 更新 refs/heads/*。普通 --bare 不创建 fetch refspec,fetch 只会下载对象、 + // 刷新 FETCH_HEAD 而永不前进分支,导致缓存分支冻结在创建那一刻。 execFileSync('git', [ - 'clone', '--bare', + 'clone', '--mirror', ...GIT_REMOTE_CLONE_ARGS, repoUrl, tmpPath, ], { @@ -165,7 +169,7 @@ function cloneBareAtomic(repoUrl: string, cachePath: string): void { } /** - * 如果上次 fetch 超过 fetchIntervalMin 分钟,执行 git fetch --all + * 如果上次 fetch 超过 fetchIntervalMin 分钟,用显式 refspec 更新 bare 缓存 * @returns true 表示 fetch 失败(缓存可能过期),false 表示成功或跳过 */ function fetchIfStale(cachePath: string): boolean { @@ -181,11 +185,20 @@ function fetchIfStale(cachePath: string): boolean { logger.info({ cachePath }, 'Fetching updates for bare cache'); try { + // 必须用显式 refspec,不能用 git fetch --all: + // 历史上用 git clone --bare 创建的缓存没有 remote.origin.fetch refspec, + // fetch --all 只会下载对象、刷新 FETCH_HEAD,却不会移动 refs/heads/*, + // 缓存分支因此永久冻结。显式 +refs/heads/*:refs/heads/* 不依赖仓库配置, + // 既保证新 --mirror 缓存正确,也能原地修复存量旧 --bare 缓存。 + // --prune 清理远端已删除的分支/标签,保持缓存与远程一致。 execFileSync('git', [ '-C', cachePath, ...GIT_REMOTE_FETCH_TOP_ARGS, - 'fetch', '--all', + 'fetch', '--prune', ...GIT_REMOTE_FETCH_SUB_ARGS, + 'origin', + '+refs/heads/*:refs/heads/*', + '+refs/tags/*:refs/tags/*', ], { timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'], From 4e662daa54ea5ac88932ea85031aade11dc31b63 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 22 Jun 2026 11:43:44 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=20bare=20cache?= =?UTF-8?q?=20mirror=20clone=20=E4=B8=8E=E6=98=BE=E5=BC=8F=20refspec=20fet?= =?UTF-8?q?ch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 断言 clone 使用 --mirror 且不再用裸 --bare - 断言 stale fetch 使用 --prune origin +refs/heads/*:refs/heads/* - 新增冻结分支回归守卫:禁止退回 fetch --all(无 refspec 时分支不前进) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/workspace/__tests__/cache.test.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/workspace/__tests__/cache.test.ts b/src/workspace/__tests__/cache.test.ts index f829d343..beef8363 100644 --- a/src/workspace/__tests__/cache.test.ts +++ b/src/workspace/__tests__/cache.test.ts @@ -163,11 +163,13 @@ describe('ensureBareCache', () => { expect(result.cachePath).toContain('/repos/cache/github.com/foo/bar.git'); - // Should call git clone --bare + // Should call git clone --mirror (implies --bare + persists fetch refspec) expect(mockExecFileSync).toHaveBeenCalledTimes(1); const args = mockExecFileSync.mock.calls[0][1]; expect(args).toContain('clone'); - expect(args).toContain('--bare'); + expect(args).toContain('--mirror'); + // 不应再用裸 --bare:它不会创建 remote.origin.fetch refspec,导致后续 fetch 冻结分支 + expect(args).not.toContain('--bare'); expect(args).toContain('--config'); expect(args).toContain('core.hooksPath=/dev/null'); expect(args).toContain('--no-recurse-submodules'); @@ -176,16 +178,30 @@ describe('ensureBareCache', () => { expect(mockRenameSync).toHaveBeenCalledTimes(1); }); - it('should fetch when cache exists and is stale', () => { + it('should fetch with explicit heads refspec when cache exists and is stale', () => { mockExistsSync.mockReturnValue(true); ensureBareCache('https://github.com/foo/bar.git'); - // Should call git fetch --all expect(mockExecFileSync).toHaveBeenCalledTimes(1); const args = mockExecFileSync.mock.calls[0][1]; expect(args).toContain('fetch'); - expect(args).toContain('--all'); + expect(args).toContain('--prune'); + expect(args).toContain('origin'); + expect(args).toContain('+refs/heads/*:refs/heads/*'); + }); + + it('should NOT use bare `fetch --all` (regression: frozen heads on refspec-less --bare caches)', () => { + // 根因回归守卫:早期 git clone --bare 创建的缓存无 remote.origin.fetch refspec, + // fetch --all 只刷新 FETCH_HEAD 而不移动 refs/heads/*,缓存分支永久冻结。 + // 必须用显式 refspec,且不得退回 --all。 + mockExistsSync.mockReturnValue(true); + + ensureBareCache('https://github.com/foo/frozen-heads-regression.git'); + + const args = mockExecFileSync.mock.calls[0][1] as string[]; + expect(args).toContain('+refs/heads/*:refs/heads/*'); + expect(args).not.toContain('--all'); }); it('should skip fetch when recently fetched', () => { From 5364b196da9f6e9409bb196e908cc0390c59207b Mon Sep 17 00:00:00 2001 From: lishuceo Date: Tue, 23 Jun 2026 10:49:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E7=BC=93=E5=AD=98=E6=94=B9=E5=9B=9E?= =?UTF-8?q?=20--bare,=E9=81=BF=E5=85=8D=20--mirror=20=E6=8B=89=E5=8F=96=20?= =?UTF-8?q?refs/pull/*=20=E8=86=A8=E8=83=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 采纳 review 观察:git clone --mirror 会拉取 GitHub 通告的全部 refs/pull/* (本仓库 259 个 vs heads 56 个),而 fetchIfStale 只更新 heads/tags, 这些 pull ref 会永久滞留、永不 prune,徒增缓存体积。 实际修复是 fetchIfStale 的显式 refspec(本就不依赖仓库 config), --bare + 显式 refspec 已覆盖新建与存量缓存的所有场景,--mirror 纯属冗余。 故 clone 改回 --bare,显式 refspec fetch 保持不变。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/workspace/__tests__/cache.test.ts | 9 +++++---- src/workspace/cache.ts | 16 +++++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/workspace/__tests__/cache.test.ts b/src/workspace/__tests__/cache.test.ts index beef8363..6bcc148d 100644 --- a/src/workspace/__tests__/cache.test.ts +++ b/src/workspace/__tests__/cache.test.ts @@ -163,13 +163,14 @@ describe('ensureBareCache', () => { expect(result.cachePath).toContain('/repos/cache/github.com/foo/bar.git'); - // Should call git clone --mirror (implies --bare + persists fetch refspec) + // Should call git clone --bare (后续 fetch 的新鲜度由显式 refspec 保证) expect(mockExecFileSync).toHaveBeenCalledTimes(1); const args = mockExecFileSync.mock.calls[0][1]; expect(args).toContain('clone'); - expect(args).toContain('--mirror'); - // 不应再用裸 --bare:它不会创建 remote.origin.fetch refspec,导致后续 fetch 冻结分支 - expect(args).not.toContain('--bare'); + expect(args).toContain('--bare'); + // 不用 --mirror:避免把 GitHub 通告的 refs/pull/* 全部拉进缓存 + // (fetch 只更新 heads/tags,永不 prune 这些 pull ref,徒增体积) + expect(args).not.toContain('--mirror'); expect(args).toContain('--config'); expect(args).toContain('core.hooksPath=/dev/null'); expect(args).toContain('--no-recurse-submodules'); diff --git a/src/workspace/cache.ts b/src/workspace/cache.ts index 00b1a2d3..3ec0c6a8 100644 --- a/src/workspace/cache.ts +++ b/src/workspace/cache.ts @@ -145,12 +145,14 @@ function cloneBareAtomic(repoUrl: string, cachePath: string): void { logger.info({ repoUrl: sanitizeRepoUrl(repoUrl), cachePath }, 'Creating bare clone cache'); try { - // 用 --mirror 而非 --bare:--mirror 隐含 --bare,并写入 - // remote.origin.fetch = +refs/*:refs/* refspec,使后续 git fetch 能真正 - // 更新 refs/heads/*。普通 --bare 不创建 fetch refspec,fetch 只会下载对象、 - // 刷新 FETCH_HEAD 而永不前进分支,导致缓存分支冻结在创建那一刻。 + // 保持 --bare(不用 --mirror):新建缓存时 refs/heads/* 已是远程最新, + // 之后的陈旧更新统一由 fetchIfStale 的显式 refspec 负责(见下), + // 不依赖 --mirror 持久化的 +refs/*:refs/* config。 + // 之所以不用 --mirror:GitHub 会通告 refs/pull/*(数量常远超 heads), + // --mirror 会把它们全部拉进缓存,而本模块的 fetch 只更新 heads/tags, + // 这些 pull ref 会永久滞留、永不被 prune,徒增镜像体积。 execFileSync('git', [ - 'clone', '--mirror', + 'clone', '--bare', ...GIT_REMOTE_CLONE_ARGS, repoUrl, tmpPath, ], { @@ -186,10 +188,10 @@ function fetchIfStale(cachePath: string): boolean { try { // 必须用显式 refspec,不能用 git fetch --all: - // 历史上用 git clone --bare 创建的缓存没有 remote.origin.fetch refspec, + // git clone --bare 不创建 remote.origin.fetch refspec, // fetch --all 只会下载对象、刷新 FETCH_HEAD,却不会移动 refs/heads/*, // 缓存分支因此永久冻结。显式 +refs/heads/*:refs/heads/* 不依赖仓库配置, - // 既保证新 --mirror 缓存正确,也能原地修复存量旧 --bare 缓存。 + // 既让新建 --bare 缓存能持续跟随远程前进,也能原地修复存量旧缓存。 // --prune 清理远端已删除的分支/标签,保持缓存与远程一致。 execFileSync('git', [ '-C', cachePath,