Merge pull request #856 from neilime/fix/git-ref-in-detached-head

fix(git): support getting ref in various detached HEAD contexts
This commit is contained in:
CrazyMax
2025-11-27 10:10:44 +01:00
committed by GitHub
2 changed files with 228 additions and 5 deletions

View File

@@ -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<ExecOutput> => {
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<ExecOutput> => {
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<ExecOutput> => {
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<ExecOutput> => {
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<ExecOutput> => {
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<ExecOutput> => {
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', () => {

View File

@@ -122,6 +122,11 @@ export class Git {
private static async getDetachedRef(): Promise<string> {
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, <tagname>" or "grafted, HEAD, <tagname>"
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 "<origin>/<branch-name>, <branch-name>"
// Pull request merge refs are formatted as "pull/<number>/<state>"
const prMatch = ref.match(/^pull\/\d+\/(head|merge)$/);
if (prMatch) {
return `refs/${ref}`;
}
// Branch refs can be formatted as "<origin>/<branch-name>, <branch-name>"
const branchMatch = ref.match(/^[^/]+\/[^/]+, (.+)$/);
if (branchMatch) {
return `refs/heads/${branchMatch[1].trim()}`;
}
// Pull request merge refs are formatted as "pull/<number>/<state>"
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 "<origin>/<branch-name>"
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<string> {
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<string | undefined> {
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<Date> {
return new Date(await Git.exec(['show', '-s', '--format="%ci"', ref]));
}