Skip to content

Commit

Permalink
Merge pull request #1039 from gperdomor/fix/fallback-detached
Browse files Browse the repository at this point in the history
fix(ci-context): improved fallback context detection for detached ref
  • Loading branch information
gperdomor committed Apr 25, 2024
2 parents cb976b4 + 1e18dc2 commit c3e73bd
Show file tree
Hide file tree
Showing 2 changed files with 243 additions and 11 deletions.
203 changes: 199 additions & 4 deletions packages/ci-context/src/lib/utils/git.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as core from '@nx-tools/core';
import mockedEnv, { RestoreFn } from 'mocked-env';
import * as local from './git';
import { Git } from './git';
Expand All @@ -8,10 +9,8 @@ describe('Git Context', () => {
beforeEach(() => {
restore = mockedEnv({ PATH: process.env['PATH'] }, { clear: true });

jest.spyOn(Git, 'getCommitUserEmail').mockResolvedValue('local-actor');
jest.spyOn(Git, 'ref').mockResolvedValue('local-ref');
jest.spyOn(Git, 'remoteURL').mockResolvedValue('https://local-git.com/gperdomor/nx-tools');
jest.spyOn(Git, 'fullCommit').mockResolvedValue('local-sha');
jest.clearAllMocks();
jest.restoreAllMocks();
});

afterEach(() => {
Expand All @@ -21,6 +20,11 @@ describe('Git Context', () => {

describe('context', () => {
it('Should be take proper cotext values', async () => {
jest.spyOn(Git, 'getCommitUserEmail').mockResolvedValue('local-actor');
jest.spyOn(Git, 'ref').mockResolvedValue('local-ref');
jest.spyOn(Git, 'remoteURL').mockResolvedValue('https://local-git.com/gperdomor/nx-tools');
jest.spyOn(Git, 'fullCommit').mockResolvedValue('local-sha');

const context = await Git.context();

expect(context).toEqual({
Expand All @@ -40,6 +44,8 @@ describe('Git Context', () => {

describe('repo', () => {
it('Should be take proper repo values', async () => {
jest.spyOn(Git, 'remoteURL').mockResolvedValue('https://local-git.com/gperdomor/nx-tools');

const repo = await local.repo();

expect(repo).toEqual({
Expand All @@ -51,4 +57,193 @@ describe('Git Context', () => {
});
});
});

describe('remoteURL', () => {
it('have been called', async () => {
const execSpy = jest.spyOn(core, 'getExecOutput');
try {
await Git.remoteURL();
} catch (err) {
// noop
}
expect(execSpy).toHaveBeenCalledWith(`git`, ['remote', 'get-url', 'origin'], {
silent: true,
ignoreReturnCode: true,
});
});
});

describe('ref', () => {
it('returns mocked ref', async () => {
jest.spyOn(core, 'getExecOutput').mockImplementation((cmd, args): Promise<core.ExecOutput> => {
const fullCmd = `${cmd} ${args?.join(' ')}`;
let result = '';
switch (fullCmd) {
case 'git branch --show-current':
result = 'test';
break;
case 'git symbolic-ref HEAD':
result = 'refs/heads/test';
break;
}
return Promise.resolve({
stdout: result,
stderr: '',
exitCode: 0,
});
});

const ref = await Git.ref();

expect(ref).toEqual('refs/heads/test');
});

it('returns mocked detached tag ref', async () => {
jest.spyOn(core, 'getExecOutput').mockImplementation((cmd, args): Promise<core.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, tag: 8.0.0';
break;
}
return Promise.resolve({
stdout: result,
stderr: '',
exitCode: 0,
});
});

const ref = await Git.ref();

expect(ref).toEqual('refs/tags/8.0.0');
});

it('returns mocked detached tag ref (shallow clone)', async () => {
jest.spyOn(core, 'getExecOutput').mockImplementation((cmd, args): Promise<core.ExecOutput> => {
const fullCmd = `${cmd} ${args?.join(' ')}`;
let result = '';
switch (fullCmd) {
case 'git branch --show-current':
result = '';
break;
case 'git show -s --pretty=%D':
result = 'grafted, HEAD, tag: 8.0.0';
break;
}
return Promise.resolve({
stdout: result,
stderr: '',
exitCode: 0,
});
});

const ref = await Git.ref();

expect(ref).toEqual('refs/tags/8.0.0');
});

it('returns mocked detached pull request merge ref (shallow clone)', async () => {
jest.spyOn(core, 'getExecOutput').mockImplementation((cmd, args): Promise<core.ExecOutput> => {
const fullCmd = `${cmd} ${args?.join(' ')}`;
let result = '';
switch (fullCmd) {
case 'git branch --show-current':
result = '';
break;
case 'git show -s --pretty=%D':
result = 'grafted, HEAD, pull/221/merge';
break;
}
return Promise.resolve({
stdout: result,
stderr: '',
exitCode: 0,
});
});

const ref = await Git.ref();

expect(ref).toEqual('refs/pull/221/merge');
});

it('should throws an error when detached HEAD ref is not supported', async () => {
jest.spyOn(core, 'getExecOutput').mockImplementation((cmd, args): Promise<core.ExecOutput> => {
const fullCmd = `${cmd} ${args?.join(' ')}`;
let result = '';
switch (fullCmd) {
case 'git branch --show-current':
result = '';
break;
case 'git show -s --pretty=%D':
result = 'wrong, HEAD, tag: 8.0.0';
break;
}
return Promise.resolve({
stdout: result,
stderr: '',
exitCode: 0,
});
});

await expect(Git.ref()).rejects.toThrow('Cannot find detached HEAD ref in "wrong, HEAD, tag: 8.0.0"');
});

it('returns mocked detached branch ref', async () => {
jest.spyOn(core, 'getExecOutput').mockImplementation((cmd, args): Promise<core.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/test, test';
break;
}
return Promise.resolve({
stdout: result,
stderr: '',
exitCode: 0,
});
});

const ref = await Git.ref();

expect(ref).toEqual('refs/heads/test');
});
});

describe('fullCommit', () => {
it('have been called', async () => {
const execSpy = jest.spyOn(core, 'getExecOutput');
try {
await Git.fullCommit();
} catch (err) {
// noop
}
expect(execSpy).toHaveBeenCalledWith(`git`, ['show', '--format=%H', 'HEAD', '--quiet', '--'], {
silent: true,
ignoreReturnCode: true,
});
});
});

describe('tag', () => {
it('have been called', async () => {
const execSpy = jest.spyOn(core, 'getExecOutput');
try {
await Git.tag();
} catch (err) {
// noop
}
expect(execSpy).toHaveBeenCalledWith(`git`, ['tag', '--points-at', 'HEAD', '--sort', '-version:creatordate'], {
silent: true,
ignoreReturnCode: true,
});
});
});
});
51 changes: 44 additions & 7 deletions packages/ci-context/src/lib/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,12 @@ export class Git {
}

public static async ref(): Promise<string> {
return await Git.exec(['symbolic-ref', 'HEAD']).catch(() => {
// if it fails (for example in a detached HEAD state), falls back to
// using git tag or describe to get the exact matching tag name.
return Git.tag().then((tag) => {
return `refs/tags/${tag}`;
});
});
const isHeadDetached = await Git.isHeadDetached();
if (isHeadDetached) {
return await Git.getDetachedRef();
}

return await Git.exec(['symbolic-ref', 'HEAD']);
}

public static async fullCommit(): Promise<string> {
Expand All @@ -58,6 +57,44 @@ export class Git {
return await Git.exec(['log', '-1', '--pretty=format:%ae']);
}

private static async isHeadDetached(): Promise<boolean> {
return await Git.exec(['branch', '--show-current']).then((res) => {
return res.length == 0;
});
}

private static async getDetachedRef(): Promise<string> {
const res = await Git.exec(['show', '-s', '--pretty=%D']);

// Can be "HEAD, <tagname>" or "grafted, HEAD, <tagname>"
const refMatch = res.match(/^(grafted, )?HEAD, (.*)$/);

if (!refMatch || !refMatch[2]) {
throw new Error(`Cannot find detached HEAD ref in "${res}"`);
}

const ref = refMatch[2].trim();

// Tag refs are formatted as "tag: <tagname>"
if (ref.startsWith('tag: ')) {
return `refs/tags/${ref.split(':')[1].trim()}`;
}

// Branch refs are 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}`;
}

throw new Error(`Unsupported detached HEAD ref in "${res}"`);
}

private static async exec(args: string[] = []): Promise<string> {
return await getExecOutput(`git`, args, {
ignoreReturnCode: true,
Expand Down

0 comments on commit c3e73bd

Please sign in to comment.