From 44c2bc1922b103805732b83abe0aac1658d22d96 Mon Sep 17 00:00:00 2001 From: xunguangzlj-cloud <293371022+xunguangzlj-cloud@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:35:37 +0800 Subject: [PATCH] Restore full history after a filtered checkout --- __test__/partial-clone.test.ts | 63 ++++++++++++++++++++++++++++++++++ dist/index.js | 49 ++++++++++++++++++++++++++ src/git-command-manager.ts | 62 +++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 __test__/partial-clone.test.ts diff --git a/__test__/partial-clone.test.ts b/__test__/partial-clone.test.ts new file mode 100644 index 00000000..816ab16c --- /dev/null +++ b/__test__/partial-clone.test.ts @@ -0,0 +1,63 @@ +import {execFileSync} from 'node:child_process' +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {pathToFileURL} from 'node:url' +import {expect, it} from '@jest/globals' +import {createCommandManager} from '../src/git-command-manager.js' + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, {cwd, encoding: 'utf8'}).trim() +} + +it.each([false, true])( + 'recovers full history after a filtered fetch (already unshallowed: %s)', + async alreadyUnshallowed => { + const directory = await mkdtemp(join(tmpdir(), 'checkout-partial-')) + try { + git(directory, 'init', '-q', '-b', 'main', 'remote') + git(directory, 'init', '-q', 'local') + const remote = join(directory, 'remote') + const local = join(directory, 'local') + git(remote, 'config', 'uploadpack.allowFilter', 'true') + git(remote, 'config', 'user.name', 'Checkout Test') + git(remote, 'config', 'user.email', 'checkout@example.invalid') + await writeFile(join(remote, 'historical.txt'), 'historical blob\n') + git(remote, 'add', 'historical.txt') + git(remote, 'commit', '-qm', 'Add historical blob') + git(remote, 'rm', '-q', 'historical.txt') + git(remote, 'commit', '-qm', 'Remove historical blob') + + git(local, 'remote', 'add', 'origin', pathToFileURL(remote).href) + const refSpec = ['+refs/heads/main:refs/remotes/origin/main'] + const manager = await createCommandManager(local, false, false) + await manager.fetch(refSpec, {filter: 'blob:none', fetchDepth: 1}) + if (alreadyUnshallowed) { + await manager.fetch(refSpec, {filter: 'blob:none', fetchDepth: 0}) + } + await writeFile(join(local, 'keep.txt'), 'worktree file\n') + + await manager.fetch(refSpec, {fetchDepth: 0}) + + expect(git(local, 'rev-parse', '--is-shallow-repository')).toBe('false') + expect( + git( + local, + 'rev-list', + '--objects', + '--all', + '--quiet', + '--missing=print' + ) + ).toBe('') + expect( + await manager.configExists('remote.origin.partialclonefilter') + ).toBe(false) + expect(await manager.configExists('remote.origin.promisor')).toBe(false) + expect(git(local, 'status', '--short')).toBe('?? keep.txt') + } finally { + await rm(directory, {recursive: true, force: true}) + } + }, + 20000 +) diff --git a/dist/index.js b/dist/index.js index 06ae5d22..36e0c481 100644 --- a/dist/index.js +++ b/dist/index.js @@ -35597,6 +35597,8 @@ class GitVersion { // sparse-checkout not [well-]supported before 2.28 (see https://github.com/actions/checkout/issues/1386) const MinimumGitVersion = new GitVersion('2.18'); const MinimumGitSparseCheckoutVersion = new GitVersion('2.28'); +const MinimumGitRefetchVersion = new GitVersion('2.36'); +const MinimumGitFetchWithoutWritingHeadVersion = new GitVersion('2.29'); async function createCommandManager(workingDirectory, lfs, doSparseCheckout) { return await GitCommandManager.createCommandManager(workingDirectory, lfs, doSparseCheckout); } @@ -35749,9 +35751,19 @@ class GitCommandManager { if (options.showProgress) { args.push('--progress'); } + const inheritedFilter = !options.filter && + (await this.configExists('remote.origin.partialclonefilter')); + const restoreFullHistory = inheritedFilter && !(options.fetchDepth && options.fetchDepth > 0); if (options.filter) { args.push(`--filter=${options.filter}`); } + else if (inheritedFilter) { + args.push('--no-filter'); + if (restoreFullHistory && + this.gitVersion.checkMinimum(MinimumGitRefetchVersion)) { + args.push('--refetch'); + } + } if (options.fetchDepth && options.fetchDepth > 0) { args.push(`--depth=${options.fetchDepth}`); } @@ -35766,6 +35778,43 @@ class GitCommandManager { await execute(async () => { await that.execGit(args); }); + if (restoreFullHistory) { + const canSkipFetchHead = this.gitVersion.checkMinimum(MinimumGitFetchWithoutWritingHeadVersion); + let fetchedMissingObjects = false; + let previousMissing = new Set(); + while (true) { + const output = await this.execGit(['rev-list', '--objects', '--all', '--quiet', '--missing=print'], false, true); + const missing = [ + ...output.stdout.matchAll(/^\?([0-9a-f]{40}|[0-9a-f]{64})\r?$/gm) + ].map(match => match[1]); + if (!missing.length) { + break; + } + if (missing.some(oid => previousMissing.has(oid))) { + throw new Error('Unable to fetch all objects from the previous partial checkout.'); + } + previousMissing = new Set(missing); + for (let i = 0; i < missing.length; i += 100) { + await this.execGit([ + '-c', + 'protocol.version=2', + 'fetch', + '--no-tags', + '--no-recurse-submodules', + '--no-filter', + canSkipFetchHead ? '--no-write-fetch-head' : '--append', + 'origin', + ...missing.slice(i, i + 100) + ]); + } + fetchedMissingObjects = true; + } + if (fetchedMissingObjects && !canSkipFetchHead) { + await this.execGit(args.filter(arg => arg !== '--unshallow')); + } + await this.tryConfigUnset('remote.origin.partialclonefilter'); + await this.tryConfigUnset('remote.origin.promisor'); + } } async getDefaultBranch(repositoryUrl) { let output; diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 84316589..9eb61463 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -14,6 +14,8 @@ import {GitVersion} from './git-version.js' // sparse-checkout not [well-]supported before 2.28 (see https://github.com/actions/checkout/issues/1386) export const MinimumGitVersion = new GitVersion('2.18') export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28') +const MinimumGitRefetchVersion = new GitVersion('2.36') +const MinimumGitFetchWithoutWritingHeadVersion = new GitVersion('2.29') export interface IGitCommandManager { branchDelete(remote: boolean, branch: string): Promise @@ -292,8 +294,22 @@ class GitCommandManager { args.push('--progress') } + const inheritedFilter = + !options.filter && + (await this.configExists('remote.origin.partialclonefilter')) + const restoreFullHistory = + inheritedFilter && !(options.fetchDepth && options.fetchDepth > 0) + if (options.filter) { args.push(`--filter=${options.filter}`) + } else if (inheritedFilter) { + args.push('--no-filter') + if ( + restoreFullHistory && + this.gitVersion.checkMinimum(MinimumGitRefetchVersion) + ) { + args.push('--refetch') + } } if (options.fetchDepth && options.fetchDepth > 0) { @@ -315,6 +331,52 @@ class GitCommandManager { await retryHelper.execute(async () => { await that.execGit(args) }) + + if (restoreFullHistory) { + const canSkipFetchHead = this.gitVersion.checkMinimum( + MinimumGitFetchWithoutWritingHeadVersion + ) + let fetchedMissingObjects = false + let previousMissing = new Set() + while (true) { + const output = await this.execGit( + ['rev-list', '--objects', '--all', '--quiet', '--missing=print'], + false, + true + ) + const missing = [ + ...output.stdout.matchAll(/^\?([0-9a-f]{40}|[0-9a-f]{64})\r?$/gm) + ].map(match => match[1]) + if (!missing.length) { + break + } + if (missing.some(oid => previousMissing.has(oid))) { + throw new Error( + 'Unable to fetch all objects from the previous partial checkout.' + ) + } + previousMissing = new Set(missing) + for (let i = 0; i < missing.length; i += 100) { + await this.execGit([ + '-c', + 'protocol.version=2', + 'fetch', + '--no-tags', + '--no-recurse-submodules', + '--no-filter', + canSkipFetchHead ? '--no-write-fetch-head' : '--append', + 'origin', + ...missing.slice(i, i + 100) + ]) + } + fetchedMissingObjects = true + } + if (fetchedMissingObjects && !canSkipFetchHead) { + await this.execGit(args.filter(arg => arg !== '--unshallow')) + } + await this.tryConfigUnset('remote.origin.partialclonefilter') + await this.tryConfigUnset('remote.origin.promisor') + } } async getDefaultBranch(repositoryUrl: string): Promise {