Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion __tests__/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {execFileSync} from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';

import {Git} from '../src/git.js';
import {Exec} from '../src/exec.js';
Expand Down Expand Up @@ -91,6 +91,70 @@ describe('context', () => {
});
});

describe('working directory', () => {
let tmpDir: string;

beforeEach(() => {
// Keep the fixture on the same drive as cwd so relative paths work on Windows.
tmpDir = fs.mkdtempSync(path.join(process.cwd(), 'git-directory-'));
});

afterEach(() => {
fs.rmSync(tmpDir, {recursive: true, force: true});
});

it.each(['absolute', 'relative'])(
'reads the selected checkout (%s path)',
async pathType => {
const checkoutDir = path.join(tmpDir, 'nested checkout');
const cwd = pathType === 'relative' ? path.relative(process.cwd(), checkoutDir) : checkoutDir;
const commitDate = '2024-01-02T03:04:05Z';
const git = (args: string[]) =>
execFileSync('git', ['-C', checkoutDir, ...args], {
encoding: 'utf8',
stdio: 'pipe',
env: {...process.env, GIT_AUTHOR_DATE: commitDate, GIT_COMMITTER_DATE: commitDate}
}).trim();
const commit = (message: string) => git(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', message]);

fs.mkdirSync(checkoutDir);
git(['init', '--initial-branch=test']);
commit('initial');
git(['tag', 'v1.0.0']);
git(['remote', 'add', 'origin', 'https://example.com/selected-repo.git']);
const sha = git(['rev-parse', 'HEAD']);

expect(await Git.isInsideWorkTree(cwd)).toEqual(true);
expect(await Git.remoteURL(cwd)).toEqual('https://example.com/selected-repo.git');
expect(await Git.context(cwd)).toMatchObject({ref: 'refs/heads/test', sha});
expect(await Git.fullCommit(cwd)).toEqual(sha);
expect(await Git.shortCommit(cwd)).toEqual(git(['rev-parse', '--short', 'HEAD']));
expect(await Git.commitDate(sha, cwd)).toEqual(new Date(commitDate));
expect(await Git.tag(cwd)).toEqual('v1.0.0');

git(['checkout', '--detach', 'HEAD']);
expect(await Git.context(cwd)).toMatchObject({ref: 'refs/tags/v1.0.0', sha});

git(['checkout', 'test']);
commit('second');
expect(await Git.tag(cwd)).toEqual('v1.0.0');
const detachedSha = git(['rev-parse', 'HEAD']);
commit('third');
git(['tag', 'v2.0.0']);
git(['checkout', '--detach', detachedSha]);
expect(await Git.context(cwd)).toMatchObject({ref: 'refs/heads/test', sha: detachedSha});

git(['update-ref', 'refs/remotes/origin/test', 'refs/heads/test']);
git(['branch', '-D', 'test']);
expect(await Git.context(cwd)).toMatchObject({ref: 'refs/heads/test', sha: detachedSha});

git(['update-ref', '-d', 'refs/remotes/origin/test']);
expect(await Git.context(cwd)).toMatchObject({ref: 'refs/tags/v2.0.0', sha: detachedSha});
},
30000
);
});

describe('isInsideWorkTree', () => {
it('have been called', async () => {
const execSpy = vi.spyOn(Exec, 'getExecOutput');
Expand Down
73 changes: 37 additions & 36 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ import {GitHub} from './github/github.js';
export type GitContext = typeof github.context;

export class Git {
public static async context(): Promise<GitContext> {
public static async context(cwd?: string): Promise<GitContext> {
const ctx = {...github.context} as GitContext;
ctx.ref = await Git.ref();
ctx.sha = await Git.fullCommit();
ctx.ref = await Git.ref(cwd);
ctx.sha = await Git.fullCommit(cwd);
return ctx;
}

public static async isInsideWorkTree(): Promise<boolean> {
return await Git.exec(['rev-parse', '--is-inside-work-tree'])
public static async isInsideWorkTree(cwd?: string): Promise<boolean> {
return await Git.exec(['rev-parse', '--is-inside-work-tree'], cwd)
.then(out => {
return out === 'true';
})
Expand Down Expand Up @@ -71,10 +71,10 @@ export class Git {
});
}

public static async remoteURL(): Promise<string> {
return await Git.exec(['remote', 'get-url', 'origin']).then(rurl => {
public static async remoteURL(cwd?: string): Promise<string> {
return await Git.exec(['remote', 'get-url', 'origin'], cwd).then(rurl => {
if (rurl.length == 0) {
return Git.exec(['remote', 'get-url', 'upstream']).then(rurl => {
return Git.exec(['remote', 'get-url', 'upstream'], cwd).then(rurl => {
if (rurl.length == 0) {
throw new Error(`Cannot find remote URL for origin or upstream`);
}
Expand All @@ -85,46 +85,46 @@ export class Git {
});
}

public static async ref(): Promise<string> {
const isHeadDetached = await Git.isHeadDetached();
public static async ref(cwd?: string): Promise<string> {
const isHeadDetached = await Git.isHeadDetached(cwd);
if (isHeadDetached) {
return await Git.getDetachedRef();
return await Git.getDetachedRef(cwd);
}

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

public static async fullCommit(): Promise<string> {
return await Git.exec(['show', '--format=%H', 'HEAD', '--quiet', '--']);
public static async fullCommit(cwd?: string): Promise<string> {
return await Git.exec(['show', '--format=%H', 'HEAD', '--quiet', '--'], cwd);
}

public static async shortCommit(): Promise<string> {
return await Git.exec(['show', '--format=%h', 'HEAD', '--quiet', '--']);
public static async shortCommit(cwd?: string): Promise<string> {
return await Git.exec(['show', '--format=%h', 'HEAD', '--quiet', '--'], cwd);
}

public static async tag(): Promise<string> {
return await Git.exec(['tag', '--points-at', 'HEAD', '--sort', '-version:creatordate']).then(tags => {
public static async tag(cwd?: string): Promise<string> {
return await Git.exec(['tag', '--points-at', 'HEAD', '--sort', '-version:creatordate'], cwd).then(tags => {
if (tags.length == 0) {
return Git.exec(['describe', '--tags', '--abbrev=0']);
return Git.exec(['describe', '--tags', '--abbrev=0'], cwd);
}
return tags.split('\n')[0];
});
}

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

private static async getDetachedRef(): Promise<string> {
const res = await Git.exec(['show', '-s', '--pretty=%D']);
private static async getDetachedRef(cwd?: string): Promise<string> {
const res = await Git.exec(['show', '-s', '--pretty=%D'], cwd);
core.debug(`detached HEAD ref: ${res}`);

const normalizedRef = res.replace(/^grafted, /, '').trim();

if (normalizedRef === 'HEAD') {
return await Git.inferRefFromHead();
return await Git.inferRefFromHead(cwd);
}

// Can be "HEAD, <tagname>" or "grafted, HEAD, <tagname>"
Expand All @@ -138,7 +138,7 @@ export class Git {

// Tag refs are formatted as "tag: <tagname>"
if (ref.startsWith('tag: ')) {
return await Git.findDetachedTagRef(ref, res);
return await Git.findDetachedTagRef(ref, res, cwd);
}

// Pull request merge refs are formatted as "pull/<number>/<state>"
Expand All @@ -162,8 +162,9 @@ export class Git {
throw new Error(`Unsupported detached HEAD ref in "${res}"`);
}

private static async exec(args: string[] = []): Promise<string> {
private static async exec(args: string[] = [], cwd?: string): Promise<string> {
return await Exec.getExecOutput(`git`, args, {
cwd,
ignoreReturnCode: true,
silent: true
}).then(res => {
Expand All @@ -174,13 +175,13 @@ export class Git {
});
}

private static async inferRefFromHead(): Promise<string> {
const localRef = await Git.findContainingRef('refs/heads/');
private static async inferRefFromHead(cwd?: string): Promise<string> {
const localRef = await Git.findContainingRef('refs/heads/', cwd);
if (localRef) {
return localRef;
}

const remoteRef = await Git.findContainingRef('refs/remotes/');
const remoteRef = await Git.findContainingRef('refs/remotes/', cwd);
if (remoteRef) {
const remoteMatch = remoteRef.match(/^refs\/remotes\/[^/]+\/(.+)$/);
if (remoteMatch) {
Expand All @@ -189,7 +190,7 @@ export class Git {
return remoteRef;
}

const tagRef = await Git.exec(['tag', '--contains', 'HEAD']);
const tagRef = await Git.exec(['tag', '--contains', 'HEAD'], cwd);
const [firstTag] = tagRef
.split('\n')
.map(tag => tag.trim())
Expand All @@ -202,8 +203,8 @@ export class Git {
return '';
}

private static async findDetachedTagRef(tagDecoration: string, originalRef: string): Promise<string> {
const tagRefs = await Git.exec(['for-each-ref', '--format=%(refname)', '--points-at', 'HEAD', 'refs/tags/']);
private static async findDetachedTagRef(tagDecoration: string, originalRef: string, cwd?: string): Promise<string> {
const tagRefs = await Git.exec(['for-each-ref', '--format=%(refname)', '--points-at', 'HEAD', 'refs/tags/'], cwd);
const refs = tagRefs
.split('\n')
.map(tagRef => tagRef.trim())
Expand All @@ -224,8 +225,8 @@ export class Git {
throw new Error(`Cannot find detached tag ref in "${originalRef}"`);
}

private static async findContainingRef(scope: string): Promise<string | undefined> {
const refs = await Git.exec(['for-each-ref', '--format=%(refname)', '--contains', 'HEAD', '--sort=-committerdate', scope]);
private static async findContainingRef(scope: string, cwd?: string): Promise<string | undefined> {
const refs = await Git.exec(['for-each-ref', '--format=%(refname)', '--contains', 'HEAD', '--sort=-committerdate', scope], cwd);

const [first] = refs
.split('\n')
Expand All @@ -234,7 +235,7 @@ export class Git {
return first;
}

public static async commitDate(ref: string): Promise<Date> {
return new Date(await Git.exec(['show', '-s', '--format="%ci"', ref]));
public static async commitDate(ref: string, cwd?: string): Promise<Date> {
return new Date(await Git.exec(['show', '-s', '--format="%ci"', ref], cwd));
}
}