diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index ad3566a..590bd24 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -1103,6 +1103,7 @@ async function setup(testName: string): Promise { ), tryDisableAutomaticGarbageCollection: jest.fn(), tryGetFetchUrl: jest.fn(), + tryGetObjectFormat: jest.fn(async () => ({format: '', succeeded: true})), tryGetConfigValues: jest.fn( async ( key: string, diff --git a/__test__/git-command-manager.test.ts b/__test__/git-command-manager.test.ts index 8a97d82..8c4f503 100644 --- a/__test__/git-command-manager.test.ts +++ b/__test__/git-command-manager.test.ts @@ -378,6 +378,169 @@ describe('Test fetchDepth and fetchTags options', () => { }) }) +describe('repository object format', () => { + beforeEach(async () => { + jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn()) + jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn()) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('detects SHA-256 from a 64-character HEAD oid', async () => { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('git version 2.50.1')) + } + + if (args.includes('ls-remote')) { + options.listeners.stdout( + Buffer.from( + 'ref: refs/heads/main\tHEAD\n' + + '9422233ca7ee1b17f1e905d0e141faf0c401556c41cdc6acd71c6bd685da2e92\tHEAD\n' + ) + ) + } + + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + git = await commandManager.createCommandManager('test', false, false) + + const objectFormat = await git.tryGetObjectFormat( + 'https://github.com/example/repo' + ) + + expect(objectFormat).toEqual({format: 'sha256', succeeded: true}) + expect(mockExec).toHaveBeenCalledWith( + expect.any(String), + [ + '-c', + 'protocol.version=2', + 'ls-remote', + '--quiet', + '--exit-code', + '--symref', + 'https://github.com/example/repo', + 'HEAD' + ], + expect.objectContaining({ + ignoreReturnCode: true, + silent: true + }) + ) + }) + + it('detects SHA-1 from a 40-character HEAD oid', async () => { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('git version 2.50.1')) + } + + if (args.includes('ls-remote')) { + options.listeners.stdout( + Buffer.from( + 'ref: refs/heads/main\tHEAD\n' + + 'c988866043f035e6a46509872215f91d879044c9\tHEAD\n' + ) + ) + } + + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + git = await commandManager.createCommandManager('test', false, false) + + await expect( + git.tryGetObjectFormat('https://github.com/example/repo') + ).resolves.toEqual({format: 'sha1', succeeded: true}) + }) + + it('returns unsuccessful when HEAD does not resolve to a recognized object id', async () => { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('git version 2.50.1')) + } + + if (args.includes('ls-remote')) { + options.listeners.stdout(Buffer.from('ref: refs/heads/main\tHEAD\n')) + } + + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + git = await commandManager.createCommandManager('test', false, false) + + await expect( + git.tryGetObjectFormat('https://github.com/example/repo') + ).resolves.toEqual({format: '', succeeded: false}) + }) + + it('returns unsuccessful when object format detection cannot reach the remote', async () => { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('git version 2.50.1')) + return 0 + } + + return 128 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + git = await commandManager.createCommandManager('test', false, false) + + await expect( + git.tryGetObjectFormat('https://github.com/example/repo') + ).resolves.toEqual({format: '', succeeded: false}) + }) + + it('initializes SHA-256 repositories with the matching object format', async () => { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('git version 2.50.1')) + } + + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + git = await commandManager.createCommandManager('test', false, false) + + await git.init('sha256') + + expect(mockExec).toHaveBeenCalledWith( + expect.any(String), + ['init', '--object-format=sha256', 'test'], + expect.any(Object) + ) + }) + + it('initializes SHA-1 repositories with existing default arguments', async () => { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('git version 2.50.1')) + } + + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + + git = await commandManager.createCommandManager('test', false, false) + + await git.init('sha1') + + expect(mockExec).toHaveBeenCalledWith( + expect.any(String), + ['init', 'test'], + expect.any(Object) + ) + }) +}) + describe('git user-agent with orchestration ID', () => { beforeEach(async () => { jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn()) diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index de79dc8..5cab554 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -501,6 +501,7 @@ async function setup(testName: string): Promise { await fs.promises.stat(path.join(repositoryPath, '.git')) return repositoryUrl }), + tryGetObjectFormat: jest.fn(async () => ({format: '', succeeded: true})), tryGetConfigValues: jest.fn(), tryGetConfigKeys: jest.fn(), tryReset: jest.fn(async () => { diff --git a/__test__/github-api-helper.test.ts b/__test__/github-api-helper.test.ts new file mode 100644 index 0000000..07d673e --- /dev/null +++ b/__test__/github-api-helper.test.ts @@ -0,0 +1,104 @@ +import * as core from '@actions/core' +import * as github from '@actions/github' +import * as githubApiHelper from '../lib/github-api-helper' + +describe('github-api-helper object format', () => { + let getOctokitSpy: jest.SpyInstance + let debugSpy: jest.SpyInstance + let repoGet: jest.Mock + let branchGet: jest.Mock + + function mockObjectFormatApi(defaultBranch: string, commitSha: string): void { + repoGet = jest.fn(async () => ({ + data: { + default_branch: defaultBranch + } + })) + branchGet = jest.fn(async () => ({ + data: { + commit: { + sha: commitSha + } + } + })) + getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({ + rest: { + repos: { + get: repoGet, + getBranch: branchGet + } + } + } as any) + } + + beforeEach(() => { + debugSpy = jest.spyOn(core, 'debug').mockImplementation(jest.fn()) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('detects SHA-256 from the default branch commit SHA', async () => { + const commitSha = + '9422233ca7ee1b17f1e905d0e141faf0c401556c41cdc6acd71c6bd685da2e92' + mockObjectFormatApi('main', commitSha) + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo') + ).resolves.toEqual({format: 'sha256', succeeded: true}) + + expect(getOctokitSpy).toHaveBeenCalledWith( + 'token', + expect.objectContaining({baseUrl: 'https://api.github.com'}) + ) + expect(repoGet).toHaveBeenCalledWith({owner: 'owner', repo: 'repo'}) + expect(branchGet).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + branch: 'main' + }) + }) + + it('detects SHA-1 from the default branch commit SHA', async () => { + mockObjectFormatApi('main', 'c988866043f035e6a46509872215f91d879044c9') + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo') + ).resolves.toEqual({format: 'sha1', succeeded: true}) + }) + + it('returns unsuccessful when the default branch commit SHA is not recognized', async () => { + mockObjectFormatApi('main', 'not-a-sha') + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo') + ).resolves.toEqual({format: '', succeeded: false}) + expect(debugSpy).toHaveBeenCalledWith( + 'Unable to determine repository object format from default branch' + ) + }) + + it('returns unsuccessful when the repository API lookup fails', async () => { + repoGet = jest.fn(async () => { + throw new Error('not found') + }) + branchGet = jest.fn() + jest.spyOn(github, 'getOctokit').mockReturnValue({ + rest: { + repos: { + get: repoGet, + getBranch: branchGet + } + } + } as any) + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo') + ).resolves.toEqual({format: '', succeeded: false}) + expect(branchGet).not.toHaveBeenCalled() + expect(debugSpy).toHaveBeenCalledWith( + 'Unable to determine repository object format: not found' + ) + }) +}) diff --git a/dist/index.js b/dist/index.js index 57729b2..fd27133 100644 --- a/dist/index.js +++ b/dist/index.js @@ -896,9 +896,14 @@ class GitCommandManager { getWorkingDirectory() { return this.workingDirectory; } - init() { + init(objectFormat) { return __awaiter(this, void 0, void 0, function* () { - yield this.execGit(['init', this.workingDirectory]); + const args = ['init']; + if (objectFormat === 'sha256') { + args.push('--object-format=sha256'); + } + args.push(this.workingDirectory); + yield this.execGit(args); }); } isDetached() { @@ -1056,6 +1061,45 @@ class GitCommandManager { return stdout; }); } + tryGetObjectFormat(repositoryUrl) { + return __awaiter(this, void 0, void 0, function* () { + var _a; + try { + const output = yield this.execGit([ + '-c', + 'protocol.version=2', + 'ls-remote', + '--quiet', + '--exit-code', + '--symref', + repositoryUrl, + 'HEAD' + ], true, true); + if (output.exitCode !== 0) { + core.debug(`Unable to determine repository object format: git ls-remote exited with ${output.exitCode}`); + return { format: '', succeeded: false }; + } + for (const line of output.stdout.trim().split('\n')) { + const [oid, ref] = line.split('\t'); + if (ref !== 'HEAD') { + continue; + } + if (/^[0-9a-fA-F]{64}$/.test(oid)) { + return { format: 'sha256', succeeded: true }; + } + if (/^[0-9a-fA-F]{40}$/.test(oid)) { + return { format: 'sha1', succeeded: true }; + } + } + core.debug('Unable to determine repository object format from HEAD'); + return { format: '', succeeded: false }; + } + catch (err) { + core.debug(`Unable to determine repository object format: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); + return { format: '', succeeded: false }; + } + }); + } tryGetConfigValues(configKey, globalConfig, configFile) { return __awaiter(this, void 0, void 0, function* () { const args = ['config']; @@ -1486,8 +1530,20 @@ function getSource(settings) { stateHelper.setRepositoryPath(settings.repositoryPath); // Initialize the repository if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) { + core.startGroup('Determining repository object format'); + let objectFormatResult = yield githubApiHelper.tryGetRepositoryObjectFormat(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.githubServerUrl); + if (!objectFormatResult.succeeded) { + objectFormatResult = yield git.tryGetObjectFormat(repositoryUrl); + } + const objectFormat = objectFormatResult.succeeded + ? objectFormatResult.format + : ''; + if (objectFormat === 'sha256') { + core.info('Detected SHA-256 repository object format'); + } + core.endGroup(); core.startGroup('Initializing the repository'); - yield git.init(); + yield git.init(objectFormat); yield git.remoteAdd('origin', repositoryUrl); core.endGroup(); } @@ -1810,6 +1866,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge Object.defineProperty(exports, "__esModule", ({ value: true })); exports.downloadRepository = downloadRepository; exports.getDefaultBranch = getDefaultBranch; +exports.tryGetRepositoryObjectFormat = tryGetRepositoryObjectFormat; const assert = __importStar(__nccwpck_require__(9491)); const core = __importStar(__nccwpck_require__(2186)); const fs = __importStar(__nccwpck_require__(7147)); @@ -1911,6 +1968,37 @@ function getDefaultBranch(authToken, owner, repo, baseUrl) { })); }); } +function tryGetRepositoryObjectFormat(authToken, owner, repo, baseUrl) { + return __awaiter(this, void 0, void 0, function* () { + var _a; + try { + const octokit = github.getOctokit(authToken, { + baseUrl: (0, url_helper_1.getServerApiUrl)(baseUrl) + }); + const repository = yield octokit.rest.repos.get({ owner, repo }); + const defaultBranch = repository.data.default_branch; + assert.ok(defaultBranch, 'default_branch cannot be empty'); + const branch = yield octokit.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch + }); + const commitSha = branch.data.commit.sha; + if (/^[0-9a-fA-F]{64}$/.test(commitSha)) { + return { format: 'sha256', succeeded: true }; + } + if (/^[0-9a-fA-F]{40}$/.test(commitSha)) { + return { format: 'sha1', succeeded: true }; + } + core.debug('Unable to determine repository object format from default branch'); + return { format: '', succeeded: false }; + } + catch (err) { + core.debug(`Unable to determine repository object format: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); + return { format: '', succeeded: false }; + } + }); +} function downloadArchive(authToken, owner, repo, ref, commit, baseUrl) { return __awaiter(this, void 0, void 0, function* () { const octokit = github.getOctokit(authToken, { diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index f5ba40e..9fde8b2 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -15,6 +15,11 @@ import {GitVersion} from './git-version' export const MinimumGitVersion = new GitVersion('2.18') export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28') +export interface GitObjectFormatResult { + format: string + succeeded: boolean +} + export interface IGitCommandManager { branchDelete(remote: boolean, branch: string): Promise branchExists(remote: boolean, pattern: string): Promise @@ -43,7 +48,7 @@ export interface IGitCommandManager { getDefaultBranch(repositoryUrl: string): Promise getSubmoduleConfigPaths(recursive: boolean): Promise getWorkingDirectory(): string - init(): Promise + init(objectFormat?: string): Promise isDetached(): Promise lfsFetch(ref: string): Promise lfsInstall(): Promise @@ -68,6 +73,7 @@ export interface IGitCommandManager { ): Promise tryDisableAutomaticGarbageCollection(): Promise tryGetFetchUrl(): Promise + tryGetObjectFormat(repositoryUrl: string): Promise tryGetConfigValues( configKey: string, globalConfig?: boolean, @@ -364,8 +370,14 @@ class GitCommandManager { return this.workingDirectory } - async init(): Promise { - await this.execGit(['init', this.workingDirectory]) + async init(objectFormat?: string): Promise { + const args = ['init'] + if (objectFormat === 'sha256') { + args.push('--object-format=sha256') + } + args.push(this.workingDirectory) + + await this.execGit(args) } async isDetached(): Promise { @@ -536,6 +548,55 @@ class GitCommandManager { return stdout } + async tryGetObjectFormat( + repositoryUrl: string + ): Promise { + try { + const output = await this.execGit( + [ + '-c', + 'protocol.version=2', + 'ls-remote', + '--quiet', + '--exit-code', + '--symref', + repositoryUrl, + 'HEAD' + ], + true, + true + ) + + if (output.exitCode !== 0) { + core.debug( + `Unable to determine repository object format: git ls-remote exited with ${output.exitCode}` + ) + return {format: '', succeeded: false} + } + + for (const line of output.stdout.trim().split('\n')) { + const [oid, ref] = line.split('\t') + if (ref !== 'HEAD') { + continue + } + if (/^[0-9a-fA-F]{64}$/.test(oid)) { + return {format: 'sha256', succeeded: true} + } + if (/^[0-9a-fA-F]{40}$/.test(oid)) { + return {format: 'sha1', succeeded: true} + } + } + + core.debug('Unable to determine repository object format from HEAD') + return {format: '', succeeded: false} + } catch (err) { + core.debug( + `Unable to determine repository object format: ${(err as any)?.message ?? err}` + ) + return {format: '', succeeded: false} + } + } + async tryGetConfigValues( configKey: string, globalConfig?: boolean, diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index ec87178..ffd9c19 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -109,8 +109,27 @@ export async function getSource(settings: IGitSourceSettings): Promise { if ( !fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) ) { + core.startGroup('Determining repository object format') + let objectFormatResult = + await githubApiHelper.tryGetRepositoryObjectFormat( + settings.authToken, + settings.repositoryOwner, + settings.repositoryName, + settings.githubServerUrl + ) + if (!objectFormatResult.succeeded) { + objectFormatResult = await git.tryGetObjectFormat(repositoryUrl) + } + const objectFormat = objectFormatResult.succeeded + ? objectFormatResult.format + : '' + if (objectFormat === 'sha256') { + core.info('Detected SHA-256 repository object format') + } + core.endGroup() + core.startGroup('Initializing the repository') - await git.init() + await git.init(objectFormat) await git.remoteAdd('origin', repositoryUrl) core.endGroup() } diff --git a/src/github-api-helper.ts b/src/github-api-helper.ts index 1ff27c2..f120bc2 100644 --- a/src/github-api-helper.ts +++ b/src/github-api-helper.ts @@ -11,6 +11,11 @@ import {getServerApiUrl} from './url-helper' const IS_WINDOWS = process.platform === 'win32' +export interface RepositoryObjectFormatResult { + format: string + succeeded: boolean +} + export async function downloadRepository( authToken: string, owner: string, @@ -122,6 +127,46 @@ export async function getDefaultBranch( }) } +export async function tryGetRepositoryObjectFormat( + authToken: string, + owner: string, + repo: string, + baseUrl?: string +): Promise { + try { + const octokit = github.getOctokit(authToken, { + baseUrl: getServerApiUrl(baseUrl) + }) + const repository = await octokit.rest.repos.get({owner, repo}) + const defaultBranch = repository.data.default_branch + assert.ok(defaultBranch, 'default_branch cannot be empty') + + const branch = await octokit.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch + }) + const commitSha = branch.data.commit.sha + + if (/^[0-9a-fA-F]{64}$/.test(commitSha)) { + return {format: 'sha256', succeeded: true} + } + if (/^[0-9a-fA-F]{40}$/.test(commitSha)) { + return {format: 'sha1', succeeded: true} + } + + core.debug( + 'Unable to determine repository object format from default branch' + ) + return {format: '', succeeded: false} + } catch (err) { + core.debug( + `Unable to determine repository object format: ${(err as any)?.message ?? err}` + ) + return {format: '', succeeded: false} + } +} + async function downloadArchive( authToken: string, owner: string,