Skip to content

Commit 8d01bf1

Browse files
authored
Merge pull request #856 from neilime/fix/git-ref-in-detached-head
fix(git): support getting ref in various detached HEAD contexts
2 parents ccdd59f + ad7ffde commit 8d01bf1

File tree

2 files changed

+228
-5
lines changed

2 files changed

+228
-5
lines changed

__tests__/git.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,181 @@ describe('ref', () => {
233233

234234
expect(ref).toEqual('refs/heads/test');
235235
});
236+
237+
it('returns mocked detached branch ref checked out by SHA', async () => {
238+
jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise<ExecOutput> => {
239+
const fullCmd = `${cmd} ${args?.join(' ')}`;
240+
let result = '';
241+
switch (fullCmd) {
242+
case 'git branch --show-current':
243+
result = '';
244+
break;
245+
case 'git show -s --pretty=%D':
246+
result = 'HEAD, origin/feature-branch';
247+
break;
248+
}
249+
return Promise.resolve({
250+
stdout: result,
251+
stderr: '',
252+
exitCode: 0
253+
});
254+
});
255+
256+
const ref = await Git.ref();
257+
258+
expect(ref).toEqual('refs/heads/feature-branch');
259+
});
260+
261+
it('infers ref from local branch when detached HEAD returns only "HEAD"', async () => {
262+
jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise<ExecOutput> => {
263+
const fullCmd = `${cmd} ${args?.join(' ')}`;
264+
let result = '';
265+
switch (fullCmd) {
266+
case 'git branch --show-current':
267+
result = '';
268+
break;
269+
case 'git show -s --pretty=%D':
270+
result = 'HEAD';
271+
break;
272+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/':
273+
result = 'refs/heads/main\nrefs/heads/develop';
274+
break;
275+
}
276+
return Promise.resolve({
277+
stdout: result,
278+
stderr: '',
279+
exitCode: 0
280+
});
281+
});
282+
283+
const ref = await Git.ref();
284+
285+
expect(ref).toEqual('refs/heads/main');
286+
});
287+
288+
it('infers ref from remote branch when no local branch contains HEAD', async () => {
289+
jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise<ExecOutput> => {
290+
const fullCmd = `${cmd} ${args?.join(' ')}`;
291+
let result = '';
292+
switch (fullCmd) {
293+
case 'git branch --show-current':
294+
result = '';
295+
break;
296+
case 'git show -s --pretty=%D':
297+
result = 'HEAD';
298+
break;
299+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/':
300+
result = '';
301+
break;
302+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/remotes/':
303+
result = 'refs/remotes/origin/feature';
304+
break;
305+
}
306+
return Promise.resolve({
307+
stdout: result,
308+
stderr: '',
309+
exitCode: 0
310+
});
311+
});
312+
313+
const ref = await Git.ref();
314+
315+
expect(ref).toEqual('refs/heads/feature');
316+
});
317+
318+
it('infers ref from tag when no branch contains HEAD', async () => {
319+
jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise<ExecOutput> => {
320+
const fullCmd = `${cmd} ${args?.join(' ')}`;
321+
let result = '';
322+
switch (fullCmd) {
323+
case 'git branch --show-current':
324+
result = '';
325+
break;
326+
case 'git show -s --pretty=%D':
327+
result = 'HEAD';
328+
break;
329+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/':
330+
result = '';
331+
break;
332+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/remotes/':
333+
result = '';
334+
break;
335+
case 'git tag --contains HEAD':
336+
result = 'v1.0.0\nv0.9.0';
337+
break;
338+
}
339+
return Promise.resolve({
340+
stdout: result,
341+
stderr: '',
342+
exitCode: 0
343+
});
344+
});
345+
346+
const ref = await Git.ref();
347+
348+
expect(ref).toEqual('refs/tags/v1.0.0');
349+
});
350+
351+
it('throws error when cannot infer ref from detached HEAD', async () => {
352+
jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise<ExecOutput> => {
353+
const fullCmd = `${cmd} ${args?.join(' ')}`;
354+
let result = '';
355+
switch (fullCmd) {
356+
case 'git branch --show-current':
357+
result = '';
358+
break;
359+
case 'git show -s --pretty=%D':
360+
result = 'HEAD';
361+
break;
362+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/':
363+
result = '';
364+
break;
365+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/remotes/':
366+
result = '';
367+
break;
368+
case 'git tag --contains HEAD':
369+
result = '';
370+
break;
371+
}
372+
return Promise.resolve({
373+
stdout: result,
374+
stderr: '',
375+
exitCode: 0
376+
});
377+
});
378+
379+
await expect(Git.ref()).rejects.toThrow('Cannot infer ref from detached HEAD');
380+
});
381+
382+
it('handles remote ref without branch pattern when inferring from remote', async () => {
383+
jest.spyOn(Exec, 'getExecOutput').mockImplementation((cmd, args): Promise<ExecOutput> => {
384+
const fullCmd = `${cmd} ${args?.join(' ')}`;
385+
let result = '';
386+
switch (fullCmd) {
387+
case 'git branch --show-current':
388+
result = '';
389+
break;
390+
case 'git show -s --pretty=%D':
391+
result = 'HEAD';
392+
break;
393+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/heads/':
394+
result = '';
395+
break;
396+
case 'git for-each-ref --format=%(refname) --contains HEAD --sort=-committerdate refs/remotes/':
397+
result = 'refs/remotes/unusual-format';
398+
break;
399+
}
400+
return Promise.resolve({
401+
stdout: result,
402+
stderr: '',
403+
exitCode: 0
404+
});
405+
});
406+
407+
const ref = await Git.ref();
408+
409+
expect(ref).toEqual('refs/remotes/unusual-format');
410+
});
236411
});
237412

238413
describe('fullCommit', () => {

src/git.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ export class Git {
122122

123123
private static async getDetachedRef(): Promise<string> {
124124
const res = await Git.exec(['show', '-s', '--pretty=%D']);
125+
core.debug(`detached HEAD ref: ${res}`);
126+
127+
if (res === 'HEAD') {
128+
return await Git.inferRefFromHead();
129+
}
125130

126131
// Can be "HEAD, <tagname>" or "grafted, HEAD, <tagname>"
127132
const refMatch = res.match(/^(grafted, )?HEAD, (.*)$/);
@@ -137,16 +142,22 @@ export class Git {
137142
return `refs/tags/${ref.split(':')[1].trim()}`;
138143
}
139144

140-
// Branch refs are formatted as "<origin>/<branch-name>, <branch-name>"
145+
// Pull request merge refs are formatted as "pull/<number>/<state>"
146+
const prMatch = ref.match(/^pull\/\d+\/(head|merge)$/);
147+
if (prMatch) {
148+
return `refs/${ref}`;
149+
}
150+
151+
// Branch refs can be formatted as "<origin>/<branch-name>, <branch-name>"
141152
const branchMatch = ref.match(/^[^/]+\/[^/]+, (.+)$/);
142153
if (branchMatch) {
143154
return `refs/heads/${branchMatch[1].trim()}`;
144155
}
145156

146-
// Pull request merge refs are formatted as "pull/<number>/<state>"
147-
const prMatch = ref.match(/^pull\/\d+\/(head|merge)$/);
148-
if (prMatch) {
149-
return `refs/${ref}`;
157+
// Branch refs checked out by its latest SHA can be formatted as "<origin>/<branch-name>"
158+
const shaBranchMatch = ref.match(/^[^/]+\/(.+)$/);
159+
if (shaBranchMatch) {
160+
return `refs/heads/${shaBranchMatch[1].trim()}`;
150161
}
151162

152163
throw new Error(`Unsupported detached HEAD ref in "${res}"`);
@@ -164,6 +175,43 @@ export class Git {
164175
});
165176
}
166177

178+
private static async inferRefFromHead(): Promise<string> {
179+
const localRef = await Git.findContainingRef('refs/heads/');
180+
if (localRef) {
181+
return localRef;
182+
}
183+
184+
const remoteRef = await Git.findContainingRef('refs/remotes/');
185+
if (remoteRef) {
186+
const remoteMatch = remoteRef.match(/^refs\/remotes\/[^/]+\/(.+)$/);
187+
if (remoteMatch) {
188+
return `refs/heads/${remoteMatch[1]}`;
189+
}
190+
return remoteRef;
191+
}
192+
193+
const tagRef = await Git.exec(['tag', '--contains', 'HEAD']);
194+
const [firstTag] = tagRef
195+
.split('\n')
196+
.map(tag => tag.trim())
197+
.filter(tag => tag.length > 0);
198+
if (firstTag) {
199+
return `refs/tags/${firstTag}`;
200+
}
201+
202+
throw new Error(`Cannot infer ref from detached HEAD`);
203+
}
204+
205+
private static async findContainingRef(scope: string): Promise<string | undefined> {
206+
const refs = await Git.exec(['for-each-ref', '--format=%(refname)', '--contains', 'HEAD', '--sort=-committerdate', scope]);
207+
208+
const [first] = refs
209+
.split('\n')
210+
.map(r => r.trim())
211+
.filter(r => r.length > 0);
212+
return first;
213+
}
214+
167215
public static async commitDate(ref: string): Promise<Date> {
168216
return new Date(await Git.exec(['show', '-s', '--format="%ci"', ref]));
169217
}

0 commit comments

Comments
 (0)