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..46e0265 --- /dev/null +++ b/__test__/github-api-helper.test.ts @@ -0,0 +1,164 @@ +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({ + defaultBranch: 'main', + 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({defaultBranch: 'main', format: 'sha1', succeeded: true}) + }) + + it('detects object format from an existing commit without API calls', async () => { + const commitSha = + '9422233ca7ee1b17f1e905d0e141faf0c401556c41cdc6acd71c6bd685da2e92' + getOctokitSpy = jest.spyOn(github, 'getOctokit') + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat( + 'token', + 'owner', + 'repo', + undefined, + undefined, + commitSha + ) + ).resolves.toEqual({format: 'sha256', succeeded: true}) + + expect(getOctokitSpy).not.toHaveBeenCalled() + }) + + it('uses a branch ref directly without looking up the default branch', async () => { + const commitSha = 'c988866043f035e6a46509872215f91d879044c9' + repoGet = jest.fn() + branchGet = jest.fn(async () => ({ + data: { + commit: { + sha: commitSha + } + } + })) + getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({ + rest: { + repos: { + get: repoGet, + getBranch: branchGet + } + } + } as any) + + await expect( + githubApiHelper.tryGetRepositoryObjectFormat( + 'token', + 'owner', + 'repo', + undefined, + 'refs/heads/feature' + ) + ).resolves.toEqual({format: 'sha1', succeeded: true}) + + expect(repoGet).not.toHaveBeenCalled() + expect(branchGet).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + branch: 'feature' + }) + }) + + 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 commit SHA' + ) + }) + + 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..2bfb9a1 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']; @@ -1484,10 +1528,24 @@ function getSource(settings) { } // Save state for POST action stateHelper.setRepositoryPath(settings.repositoryPath); + let defaultBranch = ''; // 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, settings.ref, settings.commit); + if (!objectFormatResult.succeeded) { + objectFormatResult = yield git.tryGetObjectFormat(repositoryUrl); + } + const objectFormat = objectFormatResult.succeeded + ? objectFormatResult.format + : ''; + defaultBranch = objectFormatResult.defaultBranch || ''; + 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(); } @@ -1511,6 +1569,10 @@ function getSource(settings) { if (settings.sshKey) { settings.ref = yield git.getDefaultBranch(repositoryUrl); } + else if (defaultBranch) { + core.info(`Default branch '${defaultBranch}'`); + settings.ref = `refs/heads/${defaultBranch}`; + } else { settings.ref = yield githubApiHelper.getDefaultBranch(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.githubServerUrl); } @@ -1810,6 +1872,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 +1974,69 @@ function getDefaultBranch(authToken, owner, repo, baseUrl) { })); }); } +function tryGetRepositoryObjectFormat(authToken, owner, repo, baseUrl, ref, commit) { + return __awaiter(this, void 0, void 0, function* () { + var _a; + try { + const commitFormat = getObjectFormat(commit); + if (commitFormat) { + return { format: commitFormat, succeeded: true }; + } + const octokit = github.getOctokit(authToken, { + baseUrl: (0, url_helper_1.getServerApiUrl)(baseUrl) + }); + let branchName = getBranchName(ref); + let defaultBranch = ''; + if (!branchName) { + const repository = yield octokit.rest.repos.get({ owner, repo }); + defaultBranch = repository.data.default_branch; + assert.ok(defaultBranch, 'default_branch cannot be empty'); + branchName = defaultBranch; + } + const branch = yield octokit.rest.repos.getBranch({ + owner, + repo, + branch: branchName + }); + const branchFormat = getObjectFormat(branch.data.commit.sha); + if (branchFormat) { + return { + defaultBranch: defaultBranch || undefined, + format: branchFormat, + succeeded: true + }; + } + core.debug('Unable to determine repository object format from commit SHA'); + 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 getBranchName(ref) { + if (!ref) { + return ''; + } + const headsPrefix = 'refs/heads/'; + if (ref.startsWith(headsPrefix)) { + return ref.substring(headsPrefix.length); + } + if (!ref.startsWith('refs/') && !getObjectFormat(ref)) { + return ref; + } + return ''; +} +function getObjectFormat(sha) { + if (/^[0-9a-fA-F]{64}$/.test(sha || '')) { + return 'sha256'; + } + if (/^[0-9a-fA-F]{40}$/.test(sha || '')) { + return 'sha1'; + } + return ''; +} 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..f924f3b 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -105,12 +105,36 @@ export async function getSource(settings: IGitSourceSettings): Promise { // Save state for POST action stateHelper.setRepositoryPath(settings.repositoryPath) + let defaultBranch = '' + // Initialize the repository 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, + settings.ref, + settings.commit + ) + if (!objectFormatResult.succeeded) { + objectFormatResult = await git.tryGetObjectFormat(repositoryUrl) + } + const objectFormat = objectFormatResult.succeeded + ? objectFormatResult.format + : '' + defaultBranch = objectFormatResult.defaultBranch || '' + 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() } @@ -138,6 +162,9 @@ export async function getSource(settings: IGitSourceSettings): Promise { core.startGroup('Determining the default branch') if (settings.sshKey) { settings.ref = await git.getDefaultBranch(repositoryUrl) + } else if (defaultBranch) { + core.info(`Default branch '${defaultBranch}'`) + settings.ref = `refs/heads/${defaultBranch}` } else { settings.ref = await githubApiHelper.getDefaultBranch( settings.authToken, diff --git a/src/github-api-helper.ts b/src/github-api-helper.ts index 1ff27c2..1fffc97 100644 --- a/src/github-api-helper.ts +++ b/src/github-api-helper.ts @@ -11,6 +11,12 @@ import {getServerApiUrl} from './url-helper' const IS_WINDOWS = process.platform === 'win32' +export interface RepositoryObjectFormatResult { + defaultBranch?: string + format: string + succeeded: boolean +} + export async function downloadRepository( authToken: string, owner: string, @@ -122,6 +128,84 @@ export async function getDefaultBranch( }) } +export async function tryGetRepositoryObjectFormat( + authToken: string, + owner: string, + repo: string, + baseUrl?: string, + ref?: string, + commit?: string +): Promise { + try { + const commitFormat = getObjectFormat(commit) + if (commitFormat) { + return {format: commitFormat, succeeded: true} + } + + const octokit = github.getOctokit(authToken, { + baseUrl: getServerApiUrl(baseUrl) + }) + + let branchName = getBranchName(ref) + let defaultBranch = '' + if (!branchName) { + const repository = await octokit.rest.repos.get({owner, repo}) + defaultBranch = repository.data.default_branch + assert.ok(defaultBranch, 'default_branch cannot be empty') + branchName = defaultBranch + } + + const branch = await octokit.rest.repos.getBranch({ + owner, + repo, + branch: branchName + }) + const branchFormat = getObjectFormat(branch.data.commit.sha) + if (branchFormat) { + return { + defaultBranch: defaultBranch || undefined, + format: branchFormat, + succeeded: true + } + } + + core.debug('Unable to determine repository object format from commit SHA') + return {format: '', succeeded: false} + } catch (err) { + core.debug( + `Unable to determine repository object format: ${(err as any)?.message ?? err}` + ) + return {format: '', succeeded: false} + } +} + +function getBranchName(ref?: string): string { + if (!ref) { + return '' + } + + const headsPrefix = 'refs/heads/' + if (ref.startsWith(headsPrefix)) { + return ref.substring(headsPrefix.length) + } + + if (!ref.startsWith('refs/') && !getObjectFormat(ref)) { + return ref + } + + return '' +} + +function getObjectFormat(sha?: string): string { + if (/^[0-9a-fA-F]{64}$/.test(sha || '')) { + return 'sha256' + } + if (/^[0-9a-fA-F]{40}$/.test(sha || '')) { + return 'sha1' + } + return '' +} + async function downloadArchive( authToken: string, owner: string,