From 905ef41a5b5e0b0a7c14cbc4ea35659a11d136e4 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:32:03 +0200 Subject: [PATCH] git: support custom working directories Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/git.test.ts | 66 +++++++++++++++++++++++++++++++++++++- src/git.ts | 73 ++++++++++++++++++++++--------------------- 2 files changed, 102 insertions(+), 37 deletions(-) diff --git a/__tests__/git.test.ts b/__tests__/git.test.ts index 5dc252b1..4c98dc09 100644 --- a/__tests__/git.test.ts +++ b/__tests__/git.test.ts @@ -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'; @@ -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'); diff --git a/src/git.ts b/src/git.ts index a9089383..940994d3 100644 --- a/src/git.ts +++ b/src/git.ts @@ -22,15 +22,15 @@ import {GitHub} from './github/github.js'; export type GitContext = typeof github.context; export class Git { - public static async context(): Promise { + public static async context(cwd?: string): Promise { 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 { - return await Git.exec(['rev-parse', '--is-inside-work-tree']) + public static async isInsideWorkTree(cwd?: string): Promise { + return await Git.exec(['rev-parse', '--is-inside-work-tree'], cwd) .then(out => { return out === 'true'; }) @@ -71,10 +71,10 @@ export class Git { }); } - public static async remoteURL(): Promise { - return await Git.exec(['remote', 'get-url', 'origin']).then(rurl => { + public static async remoteURL(cwd?: string): Promise { + 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`); } @@ -85,46 +85,46 @@ export class Git { }); } - public static async ref(): Promise { - const isHeadDetached = await Git.isHeadDetached(); + public static async ref(cwd?: string): Promise { + 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 { - return await Git.exec(['show', '--format=%H', 'HEAD', '--quiet', '--']); + public static async fullCommit(cwd?: string): Promise { + return await Git.exec(['show', '--format=%H', 'HEAD', '--quiet', '--'], cwd); } - public static async shortCommit(): Promise { - return await Git.exec(['show', '--format=%h', 'HEAD', '--quiet', '--']); + public static async shortCommit(cwd?: string): Promise { + return await Git.exec(['show', '--format=%h', 'HEAD', '--quiet', '--'], cwd); } - public static async tag(): Promise { - return await Git.exec(['tag', '--points-at', 'HEAD', '--sort', '-version:creatordate']).then(tags => { + public static async tag(cwd?: string): Promise { + 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 { - return await Git.exec(['branch', '--show-current']).then(res => { + private static async isHeadDetached(cwd?: string): Promise { + return await Git.exec(['branch', '--show-current'], cwd).then(res => { return res.length == 0; }); } - private static async getDetachedRef(): Promise { - const res = await Git.exec(['show', '-s', '--pretty=%D']); + private static async getDetachedRef(cwd?: string): Promise { + 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, " or "grafted, HEAD, " @@ -138,7 +138,7 @@ export class Git { // Tag refs are formatted as "tag: " 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//" @@ -162,8 +162,9 @@ export class Git { throw new Error(`Unsupported detached HEAD ref in "${res}"`); } - private static async exec(args: string[] = []): Promise { + private static async exec(args: string[] = [], cwd?: string): Promise { return await Exec.getExecOutput(`git`, args, { + cwd, ignoreReturnCode: true, silent: true }).then(res => { @@ -174,13 +175,13 @@ export class Git { }); } - private static async inferRefFromHead(): Promise { - const localRef = await Git.findContainingRef('refs/heads/'); + private static async inferRefFromHead(cwd?: string): Promise { + 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) { @@ -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()) @@ -202,8 +203,8 @@ export class Git { return ''; } - private static async findDetachedTagRef(tagDecoration: string, originalRef: string): Promise { - 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 { + 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()) @@ -224,8 +225,8 @@ export class Git { throw new Error(`Cannot find detached tag ref in "${originalRef}"`); } - private static async findContainingRef(scope: string): Promise { - const refs = await Git.exec(['for-each-ref', '--format=%(refname)', '--contains', 'HEAD', '--sort=-committerdate', scope]); + private static async findContainingRef(scope: string, cwd?: string): Promise { + const refs = await Git.exec(['for-each-ref', '--format=%(refname)', '--contains', 'HEAD', '--sort=-committerdate', scope], cwd); const [first] = refs .split('\n') @@ -234,7 +235,7 @@ export class Git { return first; } - public static async commitDate(ref: string): Promise { - return new Date(await Git.exec(['show', '-s', '--format="%ci"', ref])); + public static async commitDate(ref: string, cwd?: string): Promise { + return new Date(await Git.exec(['show', '-s', '--format="%ci"', ref], cwd)); } }