diff --git a/src/workspace/__tests__/cache.test.ts b/src/workspace/__tests__/cache.test.ts index f829d343..6bcc148d 100644 --- a/src/workspace/__tests__/cache.test.ts +++ b/src/workspace/__tests__/cache.test.ts @@ -163,11 +163,14 @@ describe('ensureBareCache', () => { expect(result.cachePath).toContain('/repos/cache/github.com/foo/bar.git'); - // Should call git clone --bare + // 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('--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'); @@ -176,16 +179,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', () => { diff --git a/src/workspace/cache.ts b/src/workspace/cache.ts index dfccc27f..3ec0c6a8 100644 --- a/src/workspace/cache.ts +++ b/src/workspace/cache.ts @@ -145,6 +145,12 @@ function cloneBareAtomic(repoUrl: string, cachePath: string): void { logger.info({ repoUrl: sanitizeRepoUrl(repoUrl), cachePath }, 'Creating bare clone cache'); try { + // 保持 --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', '--bare', ...GIT_REMOTE_CLONE_ARGS, @@ -165,7 +171,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 +187,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/* 不依赖仓库配置, + // 既让新建 --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'],