diff --git a/__tests__/git.test.ts b/__tests__/git.test.ts index b562802..9b16184 100644 --- a/__tests__/git.test.ts +++ b/__tests__/git.test.ts @@ -233,6 +233,181 @@ describe('ref', () => { expect(ref).toEqual('refs/heads/test'); }); + + it('returns mocked detached branch ref checked out by SHA', async () => { + jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise => { + const fullCmd = `${cmd} ${args?.join(' ')}`; + let result = ''; + switch (fullCmd) { + case 'git branch --show-current': + result = ''; + break; + case 'git show -s --pretty=%D': + result = 'HEAD, origin/feature-branch'; + break; + } + return Promise.resolve({ + stdout: result, + stderr: '', + exitCode: 0 + }); + }); + + const ref = await Git.ref(); + + expect(ref).toEqual('refs/heads/feature-branch'); + }); + + it('infers ref from local branch when detached HEAD returns only "HEAD"', async () => { + jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise => { + const fullCmd = `${cmd} ${args?.join(' ')}`; + let result = ''; + switch (fullCmd) { + case 'git branch --show-current': + result = ''; + break; + case 'git show -s --pretty=%D': + result = 'HEAD'; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/': + result = 'refs/heads/main\nrefs/heads/develop'; + break; + } + return Promise.resolve({ + stdout: result, + stderr: '', + exitCode: 0 + }); + }); + + const ref = await Git.ref(); + + expect(ref).toEqual('refs/heads/main'); + }); + + it('infers ref from remote branch when no local branch contains HEAD', async () => { + jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise => { + const fullCmd = `${cmd} ${args?.join(' ')}`; + let result = ''; + switch (fullCmd) { + case 'git branch --show-current': + result = ''; + break; + case 'git show -s --pretty=%D': + result = 'HEAD'; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/': + result = ''; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/remotes/': + result = 'refs/remotes/origin/feature'; + break; + } + return Promise.resolve({ + stdout: result, + stderr: '', + exitCode: 0 + }); + }); + + const ref = await Git.ref(); + + expect(ref).toEqual('refs/heads/feature'); + }); + + it('infers ref from tag when no branch contains HEAD', async () => { + jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise => { + const fullCmd = `${cmd} ${args?.join(' ')}`; + let result = ''; + switch (fullCmd) { + case 'git branch --show-current': + result = ''; + break; + case 'git show -s --pretty=%D': + result = 'HEAD'; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/': + result = ''; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/remotes/': + result = ''; + break; + case 'git tag --contains HEAD': + result = 'v1.0.0\nv0.9.0'; + break; + } + return Promise.resolve({ + stdout: result, + stderr: '', + exitCode: 0 + }); + }); + + const ref = await Git.ref(); + + expect(ref).toEqual('refs/tags/v1.0.0'); + }); + + it('throws error when cannot infer ref from detached HEAD', async () => { + jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise => { + const fullCmd = `${cmd} ${args?.join(' ')}`; + let result = ''; + switch (fullCmd) { + case 'git branch --show-current': + result = ''; + break; + case 'git show -s --pretty=%D': + result = 'HEAD'; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/': + result = ''; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/remotes/': + result = ''; + break; + case 'git tag --contains HEAD': + result = ''; + break; + } + return Promise.resolve({ + stdout: result, + stderr: '', + exitCode: 0 + }); + }); + + await expect(Git.ref()).rejects.toThrow('Cannot infer ref from detached HEAD'); + }); + + it('handles remote ref without branch pattern when inferring from remote', async () => { + jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise => { + const fullCmd = `${cmd} ${args?.join(' ')}`; + let result = ''; + switch (fullCmd) { + case 'git branch --show-current': + result = ''; + break; + case 'git show -s --pretty=%D': + result = 'HEAD'; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/': + result = ''; + break; + case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/remotes/': + result = 'refs/remotes/unusual-format'; + break; + } + return Promise.resolve({ + stdout: result, + stderr: '', + exitCode: 0 + }); + }); + + const ref = await Git.ref(); + + expect(ref).toEqual('refs/remotes/unusual-format'); + }); }); describe('fullCommit', () => { diff --git a/src/git.ts b/src/git.ts index b2c7595..0cf7109 100644 --- a/src/git.ts +++ b/src/git.ts @@ -122,6 +122,11 @@ export class Git { private static async getDetachedRef(): Promise { const res = await Git.exec(['show', '-s', '--pretty=%D']); + core.debug(`detached HEAD ref: ${res}`); + + if (res === 'HEAD') { + return await Git.inferRefFromHead(); + } // Can be "HEAD, " or "grafted, HEAD, " const refMatch = res.match(/^(grafted, )?HEAD, (.*)$/); @@ -137,16 +142,22 @@ export class Git { return `refs/tags/${ref.split(':')[1].trim()}`; } - // Branch refs are formatted as "/, " + // Pull request merge refs are formatted as "pull//" + const prMatch = ref.match(/^pull\/\d+\/(head|merge)$/); + if (prMatch) { + return `refs/${ref}`; + } + + // Branch refs can be formatted as "/, " const branchMatch = ref.match(/^[^/]+\/[^/]+, (.+)$/); if (branchMatch) { return `refs/heads/${branchMatch[1].trim()}`; } - // Pull request merge refs are formatted as "pull//" - const prMatch = ref.match(/^pull\/\d+\/(head|merge)$/); - if (prMatch) { - return `refs/${ref}`; + // Branch refs checked out by its latest SHA can be formatted as "/" + const shaBranchMatch = ref.match(/^[^/]+\/(.+)$/); + if (shaBranchMatch) { + return `refs/heads/${shaBranchMatch[1].trim()}`; } throw new Error(`Unsupported detached HEAD ref in "${res}"`); @@ -164,6 +175,43 @@ export class Git { }); } + private static async inferRefFromHead(): Promise { + const localRef = await Git.findContainingRef('refs/heads/'); + if (localRef) { + return localRef; + } + + const remoteRef = await Git.findContainingRef('refs/remotes/'); + if (remoteRef) { + const remoteMatch = remoteRef.match(/^refs\/remotes\/[^/]+\/(.+)$/); + if (remoteMatch) { + return `refs/heads/${remoteMatch[1]}`; + } + return remoteRef; + } + + const tagRef = await Git.exec(['tag', '--contains', 'HEAD']); + const [firstTag] = tagRef + .split('\n') + .map(tag => tag.trim()) + .filter(tag => tag.length > 0); + if (firstTag) { + return `refs/tags/${firstTag}`; + } + + throw new Error(`Cannot infer ref from detached HEAD`); + } + + private static async findContainingRef(scope: string): Promise { + const refs = await Git.exec(['for-each-ref', '--format=%(refname)', '--contains', 'HEAD', '--sort=-committerdate', scope]); + + const [first] = refs + .split('\n') + .map(r => r.trim()) + .filter(r => r.length > 0); + return first; + } + public static async commitDate(ref: string): Promise { return new Date(await Git.exec(['show', '-s', '--format="%ci"', ref])); }