diff --git a/.gitignore b/.gitignore index 7189a44..5bb7b01 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ Thumbs.db .nx/workspace-data .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md + +# Code coverage +coverage \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 98fadd3..66a603e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ -**/output +**/output/** /.nx/workspace-data \ No newline at end of file diff --git a/package.json b/package.json index 4fc0aff..c2c7198 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "lint-staged": { "*.{ts,js,json,md}": [ - "prettier --write" + "npx prettier --write" ] } } diff --git a/workflow-steps/checkout/jest.config.ts b/workflow-steps/checkout/jest.config.ts new file mode 100644 index 0000000..cab5ae2 --- /dev/null +++ b/workflow-steps/checkout/jest.config.ts @@ -0,0 +1,21 @@ +import { Config } from 'jest'; + +export default { + displayName: 'checkout-step', + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverageFrom: ['main.ts', '!main.test.ts', '!jest.config.ts'], + testMatch: ['**/*.test.ts'], + moduleFileExtensions: ['ts', 'js'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, +} as Config; diff --git a/workflow-steps/checkout/main.test.ts b/workflow-steps/checkout/main.test.ts new file mode 100644 index 0000000..c3d6771 --- /dev/null +++ b/workflow-steps/checkout/main.test.ts @@ -0,0 +1,1625 @@ +import { + afterEach, + beforeEach, + describe, + expect, + jest, + test, +} from '@jest/globals'; +import { spawn } from 'node:child_process'; +import * as fsPromises from 'node:fs/promises'; +import { + buildFetchCommand, + classifyError, + detectPlatform, + executeGitCommand, + executeWithRetry, + GitCheckoutConfig, + GitCheckoutError, + isMergeQueueRef, + isPullRequestRef, + validateEnvironment, + writeToNxCloudEnv, +} from './main'; + +jest.mock('node:child_process'); +jest.mock('node:fs/promises', () => ({ + appendFile: jest.fn(), +})); + +const mockSpawn = spawn as unknown as jest.MockedFunction; + +describe('Git Checkout Utility', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + process.env = originalEnv; + jest.useRealTimers(); + }); + + describe('Environment validation', () => { + test('throws when GIT_REPOSITORY_URL is missing', () => { + delete process.env.GIT_REPOSITORY_URL; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).toThrow( + new GitCheckoutError( + 'GIT_REPOSITORY_URL environment variable is required', + false, + ), + ); + }); + + test('throws when NX_COMMIT_SHA is missing', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + delete process.env.NX_COMMIT_SHA; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).toThrow( + new GitCheckoutError( + 'NX_COMMIT_SHA environment variable is required', + false, + ), + ); + }); + + test('throws when NX_BRANCH is missing', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + delete process.env.NX_BRANCH; + + expect(() => validateEnvironment()).toThrow( + new GitCheckoutError( + 'NX_BRANCH environment variable is required', + false, + ), + ); + }); + + test('validates URL format', () => { + process.env.GIT_REPOSITORY_URL = 'not-a-valid-url'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).toThrow( + new GitCheckoutError( + 'Invalid GIT_REPOSITORY_URL: not-a-valid-url', + false, + ), + ); + }); + + test('validates git SSH URL format', () => { + process.env.GIT_REPOSITORY_URL = 'git@github.com:user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('validates commit SHA format - regular SHA', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123def456'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('validates commit SHA format - origin branch', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'origin/feature-branch'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('validates commit SHA format - PR head reference', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'pull/123/head'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('validates commit SHA format - PR merge reference', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'pull/123/merge'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('validates commit SHA format - refs/pull/ head reference', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'refs/pull/456/head'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('validates commit SHA format - refs/pull/ merge reference', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'refs/pull/456/merge'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('validates commit SHA format - refs/heads/ reference', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'refs/heads/main'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('validates commit SHA format - refs/heads/ with feature branch', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'refs/heads/feature/my-feature'; + process.env.NX_BRANCH = 'feature/my-feature'; + + expect(() => validateEnvironment()).not.toThrow(); + }); + + test('rejects invalid commit SHA format', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'invalid-sha!!!'; + process.env.NX_BRANCH = 'main'; + + expect(() => validateEnvironment()).toThrow( + new GitCheckoutError( + 'Invalid NX_COMMIT_SHA format: invalid-sha!!!', + false, + ), + ); + }); + + test('validates depth - valid number', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_CHECKOUT_DEPTH = '5'; + + const config = validateEnvironment(); + expect(config.depth).toBe(5); + }); + + test('validates depth - zero for full clone', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_CHECKOUT_DEPTH = '0'; + + const config = validateEnvironment(); + expect(config.depth).toBe(0); + }); + + test('rejects invalid depth - not a number', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_CHECKOUT_DEPTH = 'invalid'; + + expect(() => validateEnvironment()).toThrow( + new GitCheckoutError('Invalid GIT_CHECKOUT_DEPTH: invalid', false), + ); + }); + + test('rejects negative depth', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_CHECKOUT_DEPTH = '-1'; + + expect(() => validateEnvironment()).toThrow( + new GitCheckoutError('Invalid GIT_CHECKOUT_DEPTH: -1', false), + ); + }); + + test('parses optional configuration correctly', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_FETCH_TAGS = 'true'; + process.env.GIT_TIMEOUT = '60000'; + process.env.GIT_MAX_RETRIES = '5'; + process.env.GIT_DRY_RUN = 'true'; + + const config = validateEnvironment(); + expect(config.fetchTags).toBe(true); + expect(config.timeout).toBe(60000); + expect(config.maxRetries).toBe(5); + expect(config.dryRun).toBe(true); + }); + + test('uses default values when optional env vars not set', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + + const config = validateEnvironment(); + expect(config.depth).toBe(1); + expect(config.fetchTags).toBe(false); + expect(config.filter).toBe(''); + expect(config.timeout).toBe(300000); + expect(config.maxRetries).toBe(3); + expect(config.dryRun).toBe(false); + expect(config.createBranch).toBe(false); + }); + + test('parses GIT_FILTER correctly', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_FILTER = 'blob:none'; + + const config = validateEnvironment(); + expect(config.filter).toBe('blob:none'); + }); + + test('parses GIT_CREATE_BRANCH=true correctly', () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_CREATE_BRANCH = 'true'; + + const config = validateEnvironment(); + expect(config.createBranch).toBe(true); + }); + }); + + describe('Command injection prevention', () => { + test('escapes repository URL with special characters', async () => { + const mockProcess = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await executeGitCommand('remote', [ + 'add', + 'origin', + 'https://github.com/user/repo.git; rm -rf /', + ]); + + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + [ + 'remote', + 'add', + 'origin', + 'https://github.com/user/repo.git; rm -rf /', + ], + expect.any(Object), + ); + // The command injection attempt is passed as a single argument, not executed + }); + + test('escapes commit SHA with command injection attempt', async () => { + const mockProcess = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await executeGitCommand('checkout', [ + '--detach', + 'abc123; cat /etc/passwd', + ]); + + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + ['checkout', '--detach', 'abc123; cat /etc/passwd'], + expect.any(Object), + ); + }); + + test('escapes branch name with shell metacharacters', async () => { + const mockProcess = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await executeGitCommand('checkout', ['-B', '$(whoami)', 'abc123']); + + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + ['checkout', '-B', '$(whoami)', 'abc123'], + expect.any(Object), + ); + }); + }); + + describe('Fetch logic', () => { + describe.each([ + { + commitSha: 'abc123', + depth: 1, + fetchTags: false, + expected: [ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + 'origin', + 'abc123', + ], + }, + { + commitSha: 'abc123', + depth: 5, + fetchTags: true, + expected: [ + '--tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=5', + 'origin', + 'abc123', + ], + }, + { + commitSha: 'abc123', + depth: 0, + fetchTags: false, + expected: [ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + ], + }, + { + commitSha: 'origin/main', + depth: 1, + fetchTags: false, + expected: [ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + 'origin', + 'main', + ], + }, + { + commitSha: 'pull/123/head', + depth: 1, + fetchTags: false, + expected: [ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + 'origin', + 'refs/pull/123/head:refs/remotes/origin/pull/123/head', + ], + }, + { + commitSha: 'refs/heads/main', + depth: 1, + fetchTags: false, + expected: [ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + 'origin', + '+refs/heads/main:refs/remotes/origin/main', + ], + }, + { + commitSha: 'refs/heads/feature/my-feature', + depth: 5, + fetchTags: true, + expected: [ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=5', + 'origin', + '+refs/heads/feature/my-feature:refs/remotes/origin/feature/my-feature', + ], + }, + ])( + 'with commitSha=$commitSha, depth=$depth, fetchTags=$fetchTags', + ({ commitSha, depth, fetchTags, expected }) => { + test('constructs correct fetch command', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha, + nxBranch: 'main', + depth, + fetchTags, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual(expected); + }); + }, + ); + + test('full clone with PR context - fetches PR refs', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'abc123def456', + nxBranch: '9203', // PR number + depth: 0, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/pull/9203/head:refs/remotes/origin/pr/9203/head', + '+refs/pull/9203/merge:refs/remotes/origin/pr/9203/merge', + ]); + }); + + test('full clone with branch context - does not fetch PR refs', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'abc123def456', + nxBranch: 'feature-branch', // Not a PR number + depth: 0, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + ]); + }); + + test('adds filter argument when filter is specified', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'abc123', + nxBranch: 'main', + depth: 1, + fetchTags: false, + filter: 'blob:none', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + '--filter=blob:none', + 'origin', + 'abc123', + ]); + }); + + test('adds filter with refs/heads/ format', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'refs/heads/main', + nxBranch: 'main', + depth: 1, + fetchTags: false, + filter: 'tree:0', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + '--filter=tree:0', + 'origin', + '+refs/heads/main:refs/remotes/origin/main', + ]); + }); + + test('adds filter with full clone', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'abc123', + nxBranch: 'main', + depth: 0, + fetchTags: false, + filter: 'blob:none', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + '--filter=blob:none', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + ]); + }); + }); + + describe('Multi-platform support', () => { + test('detects GitHub platform', () => { + expect(detectPlatform('https://github.com/user/repo.git')).toBe('github'); + expect( + detectPlatform('https://github.enterprise.com/user/repo.git'), + ).toBe('github'); + }); + + test('detects GitLab platform', () => { + expect(detectPlatform('https://gitlab.com/user/repo.git')).toBe('gitlab'); + expect(detectPlatform('https://gitlab.example.com/user/repo.git')).toBe( + 'gitlab', + ); + }); + + test('detects Bitbucket platform', () => { + expect(detectPlatform('https://bitbucket.org/user/repo.git')).toBe( + 'bitbucket', + ); + expect( + detectPlatform('https://bitbucket.company.com/user/repo.git'), + ).toBe('bitbucket'); + }); + + test('detects Azure DevOps platform', () => { + expect( + detectPlatform('https://dev.azure.com/org/project/_git/repo'), + ).toBe('azure'); + expect( + detectPlatform('https://org.visualstudio.com/project/_git/repo'), + ).toBe('azure'); + }); + + test('defaults to unknown for unknown platforms', () => { + expect(detectPlatform('https://git.company.com/user/repo.git')).toBe( + 'unknown', + ); + expect(detectPlatform('https://custom-git.example.org/repo.git')).toBe( + 'unknown', + ); + }); + + test('GitHub PR context - fetches GitHub PR refs', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'abc123def456', + nxBranch: '123', // PR number + depth: 0, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/pull/123/head:refs/remotes/origin/pr/123/head', + '+refs/pull/123/merge:refs/remotes/origin/pr/123/merge', + ]); + }); + + test('GitLab MR context - fetches GitLab MR refs', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://gitlab.com/user/repo.git', + commitSha: 'abc123def456', + nxBranch: '456', // MR number + depth: 0, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/merge-requests/456/head:refs/remotes/origin/mr/456/head', + '+refs/merge-requests/456/merge:refs/remotes/origin/mr/456/merge', + ]); + }); + + test('Bitbucket PR context - fetches Bitbucket PR refs', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://bitbucket.org/user/repo.git', + commitSha: 'abc123def456', + nxBranch: '789', // PR number + depth: 0, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/pull-requests/789/from:refs/remotes/origin/pr/789/from', + '+refs/pull-requests/789/merge:refs/remotes/origin/pr/789/merge', + ]); + }); + + test('Azure DevOps PR context - fetches Azure PR refs', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://dev.azure.com/org/project/_git/repo', + commitSha: 'abc123def456', + nxBranch: '101', // PR number + depth: 0, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/pull/101/merge:refs/remotes/origin/pr/101/merge', + ]); + }); + + test('Unknown platform - skips PR refs to avoid errors', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://git.company.com/user/repo.git', + commitSha: 'abc123def456', + nxBranch: '999', // Could be PR number but unknown platform + depth: 0, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + // No PR refs for unknown platform + ]); + }); + + test('validates GitLab merge request refs', () => { + expect(isPullRequestRef('gitlab', 'merge-requests/123/head')).toBe(true); + expect(isPullRequestRef('gitlab', 'refs/merge-requests/456/merge')).toBe( + true, + ); + expect(isPullRequestRef('gitlab', 'pull/123/head')).toBe(false); // GitHub format + }); + + test('validates Bitbucket pull request refs', () => { + expect(isPullRequestRef('bitbucket', 'pull-requests/789/from')).toBe( + true, + ); + expect( + isPullRequestRef('bitbucket', 'refs/pull-requests/101/merge'), + ).toBe(true); + expect(isPullRequestRef('bitbucket', 'pull/789/head')).toBe(false); // GitHub format + }); + + test('validates Azure DevOps pull request refs', () => { + expect(isPullRequestRef('azure', 'pull/555/merge')).toBe(true); + expect(isPullRequestRef('azure', 'refs/pull/666/merge')).toBe(true); + expect(isPullRequestRef('azure', 'pull/555/head')).toBe(false); // GitHub format + }); + }); + + describe('Merge queue support', () => { + test('detects GitHub merge queue refs', () => { + expect( + isMergeQueueRef( + 'github', + 'gh-readonly-queue/main/pr-123-abc123def', + 'some-branch', + ), + ).toBe(true); + expect( + isMergeQueueRef( + 'github', + 'refs/heads/gh-readonly-queue/main/pr-456-def789', + 'other-branch', + ), + ).toBe(true); + expect( + isMergeQueueRef( + 'github', + 'normal-branch', + 'gh-readonly-queue/main/pr-789-ghi012', + ), + ).toBe(true); + expect(isMergeQueueRef('github', 'normal-branch', 'regular-branch')).toBe( + false, + ); + }); + + test('detects GitLab merge train refs', () => { + expect(isMergeQueueRef('gitlab', 'train/main/123', 'some-branch')).toBe( + true, + ); + expect( + isMergeQueueRef( + 'gitlab', + 'refs/heads/train/develop/456', + 'other-branch', + ), + ).toBe(true); + expect( + isMergeQueueRef('gitlab', 'normal-branch', 'feature-123-merge-train'), + ).toBe(true); + expect(isMergeQueueRef('gitlab', 'normal-branch', 'train/main/789')).toBe( + true, + ); + expect(isMergeQueueRef('gitlab', 'normal-branch', 'regular-branch')).toBe( + false, + ); + }); + + test('detects Azure DevOps merge queue refs', () => { + expect( + isMergeQueueRef('azure', 'merge-queue/main/123', 'some-branch'), + ).toBe(true); + expect( + isMergeQueueRef( + 'azure', + 'refs/heads/merge-queue/develop/456', + 'other-branch', + ), + ).toBe(true); + expect( + isMergeQueueRef('azure', 'normal-branch', 'merge-queue/main/789'), + ).toBe(true); + expect(isMergeQueueRef('azure', 'normal-branch', 'regular-branch')).toBe( + false, + ); + }); + + test('GitHub merge queue - builds correct fetch command', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'gh-readonly-queue/main/pr-123-abc123def', + nxBranch: 'gh-readonly-queue/main/pr-123-abc123def', + depth: 1, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + 'origin', + '+refs/heads/gh-readonly-queue/main/pr-123-abc123def:refs/remotes/origin/gh-readonly-queue/main/pr-123-abc123def', + ]); + }); + + test('GitLab merge train - builds correct fetch command', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://gitlab.com/user/repo.git', + commitSha: 'refs/heads/train/main/456', + nxBranch: 'feature-456-merge-train', + depth: 1, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + 'origin', + '+refs/heads/train/main/456:refs/remotes/origin/train/main/456', + ]); + }); + + test('Azure merge queue - builds correct fetch command', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://dev.azure.com/org/project/_git/repo', + commitSha: 'merge-queue/main/789', + nxBranch: 'merge-queue/main/789', + depth: 2, + fetchTags: false, + filter: 'blob:none', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=2', + '--filter=blob:none', + 'origin', + '+refs/heads/merge-queue/main/789:refs/remotes/origin/merge-queue/main/789', + ]); + }); + + test('excludes merge queue from PR context detection in full clone', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'abc123def456', // Regular SHA + nxBranch: '123', // Numeric that would normally be treated as PR number + depth: 0, // Full clone + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/pull/123/head:refs/remotes/origin/pr/123/head', + '+refs/pull/123/merge:refs/remotes/origin/pr/123/merge', + ]); + }); + + test('merge queue branch name prevents PR context detection', () => { + const config: GitCheckoutConfig = { + repoUrl: 'https://github.com/user/repo.git', + commitSha: 'abc123def456', // Regular SHA that would be full clone + nxBranch: 'gh-readonly-queue/main/pr-123-abc123def', // Merge queue branch - NOT treated as PR + depth: 0, + fetchTags: false, + filter: '', + timeout: 300000, + maxRetries: 3, + dryRun: false, + createBranch: false, + }; + + const args = buildFetchCommand(config); + expect(args).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=0', + 'origin', + '+refs/heads/gh-readonly-queue/main/pr-123-abc123def:refs/remotes/origin/gh-readonly-queue/main/pr-123-abc123def', + ]); + }); + }); + + describe('Error classification', () => { + test('classifies connection timeout as retryable', () => { + const error = new Error('Connection timeout'); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(true); + expect(classified.message).toContain('Network error'); + }); + + test('classifies early EOF as retryable', () => { + const error = new Error('early EOF'); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(true); + expect(classified.message).toContain('Network error'); + }); + + test('classifies network unreachable as retryable', () => { + const error = new Error('Network is unreachable'); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(true); + expect(classified.message).toContain('Network error'); + }); + + test('classifies could not read from remote as retryable', () => { + const error = new Error('Could not read from remote repository'); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(true); + expect(classified.message).toContain('Network error'); + }); + + test('classifies unable to access as retryable', () => { + const error = new Error( + 'Unable to access https://github.com/user/repo.git', + ); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(true); + expect(classified.message).toContain('Network error'); + }); + + test("classifies couldn't resolve host as retryable", () => { + const error = new Error("Couldn't resolve host github.com"); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(true); + expect(classified.message).toContain('Network error'); + }); + + test('does not retry on authentication failure', () => { + const error = new Error('Authentication failed'); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(false); + expect(classified.message).toContain('Authentication error'); + }); + + test('does not retry on permission denied', () => { + const error = new Error('Permission denied (publickey)'); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(false); + expect(classified.message).toContain('Authentication error'); + }); + + test('does not retry on invalid username or password', () => { + const error = new Error('Invalid username or password'); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(false); + expect(classified.message).toContain('Authentication error'); + }); + + test('does not retry on reference not found', () => { + const error = new Error('Reference not found'); + const classified = classifyError(error, 'checkout'); + + expect(classified.isRetryable).toBe(false); + expect(classified.message).toContain('Reference error'); + }); + + test("does not retry on couldn't find remote ref", () => { + const error = new Error("Couldn't find remote ref abc123"); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(false); + expect(classified.message).toContain('Reference error'); + }); + + test('does not retry on pathspec did not match', () => { + const error = new Error("pathspec 'abc123' did not match any"); + const classified = classifyError(error, 'checkout'); + + expect(classified.isRetryable).toBe(false); + expect(classified.message).toContain('Reference error'); + }); + + test('defaults to non-retryable for unknown errors', () => { + const error = new Error('Unknown error occurred'); + const classified = classifyError(error, 'fetch'); + + expect(classified.isRetryable).toBe(false); + expect(classified.message).toContain('Git fetch failed'); + }); + }); + + describe('Retry behavior', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('retries on connection timeout', async () => { + let attempts = 0; + const fn = jest.fn<() => Promise>().mockImplementation(() => { + attempts++; + if (attempts < 3) { + throw new GitCheckoutError('Connection timeout', true); + } + return Promise.resolve('success'); + }); + + const promise = executeWithRetry(fn, 'test operation', 3); + + // Fast forward through first retry + await jest.advanceTimersByTimeAsync(15000); + + // Fast forward through second retry + await jest.advanceTimersByTimeAsync(50000); + + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + test('retries on early EOF', async () => { + let attempts = 0; + const fn = jest.fn<() => Promise>().mockImplementation(() => { + attempts++; + if (attempts < 2) { + throw new GitCheckoutError('early EOF', true); + } + return Promise.resolve('success'); + }); + + const promise = executeWithRetry(fn, 'test operation', 3); + + // Fast forward through first retry + await jest.advanceTimersByTimeAsync(15000); + + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + test('does not retry on authentication failure', async () => { + const fn = jest + .fn<() => Promise>() + .mockRejectedValue( + new GitCheckoutError('Authentication failed', false), + ); + + await expect(executeWithRetry(fn, 'test operation', 3)).rejects.toThrow( + 'Authentication failed', + ); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('does not retry on reference not found', async () => { + const fn = jest + .fn<() => Promise>() + .mockRejectedValue(new GitCheckoutError('Reference not found', false)); + + await expect(executeWithRetry(fn, 'test operation', 3)).rejects.toThrow( + 'Reference not found', + ); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('respects max retries limit', async () => { + const fn = jest.fn<() => Promise>().mockImplementation(() => { + throw new GitCheckoutError('Connection timeout foobar', true); + }); + + const promise = executeWithRetry(fn, 'test operation', 3); + + // Attach rejection handler immediately to prevent unhandled rejection warnings + promise.catch(() => {}); + + // Fast forward through first retry (attempt 2) + await jest.advanceTimersByTimeAsync(15000); + + // Fast forward through second retry (attempt 3) + await jest.advanceTimersByTimeAsync(50000); + + await expect(promise).rejects.toThrow('Connection timeout foobar'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + test('uses exponential backoff', async () => { + const fn = jest.fn<() => Promise>().mockImplementation(() => { + throw new GitCheckoutError('Connection timeout bazqux', true); + }); + + const consoleSpy = jest.spyOn(console, 'log'); + + const promise = executeWithRetry(fn, 'test operation', 3); + + // Attach rejection handler immediately to prevent unhandled rejection warnings + promise.catch(() => {}); + + // Fast-forward through first retry delay (attempt 2) + await jest.advanceTimersByTimeAsync(15000); // 10s base + jitter + + // Fast-forward through second retry delay (attempt 3) + await jest.advanceTimersByTimeAsync(50000); // 30s base * 1.5 + jitter + + await expect(promise).rejects.toThrow('Connection timeout bazqux'); + expect(fn).toHaveBeenCalledTimes(3); + consoleSpy.mockRestore(); + }); + + test('logs retry information', async () => { + const fn = jest + .fn<() => Promise>() + .mockRejectedValueOnce(new GitCheckoutError('Network error', true)) + .mockResolvedValueOnce('success'); + + const consoleSpy = jest.spyOn(console, 'log'); + + const promise = executeWithRetry(fn, 'git fetch', 3); + + // Fast forward through first retry + await jest.advanceTimersByTimeAsync(15000); + + await promise; + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('git fetch attempt 1 failed (retryable error)'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Error: Network error'), + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('executeGitCommand', () => { + test('executes command with proper spawn arguments', async () => { + const mockProcess = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await executeGitCommand('init', ['.'], { timeout: 5000 }); + + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + ['init', '.'], + expect.objectContaining({ + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: 5000, + }), + ); + }); + + test('returns stdout and stderr on success', async () => { + const mockProcess = { + stdout: { + on: jest.fn((event: string, callback: Function) => { + if (event === 'data') callback(Buffer.from('stdout output')); + }), + }, + stderr: { + on: jest.fn((event: string, callback: Function) => { + if (event === 'data') callback(Buffer.from('stderr output')); + }), + }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + const result = await executeGitCommand('status', []); + + expect(result.stdout).toBe('stdout output'); + expect(result.stderr).toBe('stderr output'); + }); + + test('rejects on non-zero exit code', async () => { + const mockProcess = { + stdout: { on: jest.fn() }, + stderr: { + on: jest.fn((event: string, callback: Function) => { + if (event === 'data') callback(Buffer.from('error message')); + }), + }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(1, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await expect(executeGitCommand('fetch', [])).rejects.toThrow( + 'Git fetch failed', + ); + }); + + test('handles timeout signal', async () => { + const mockProcess = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(null, 'SIGTERM'); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await expect( + executeGitCommand('fetch', [], { timeout: 5000 }), + ).rejects.toThrow('Git fetch timed out after 5000ms'); + }); + + test('dry run mode logs command without executing', async () => { + const consoleSpy = jest.spyOn(console, 'log'); + + const result = await executeGitCommand('fetch', ['origin', 'main'], { + dryRun: true, + }); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + '[DRY RUN] Would execute: git fetch origin main', + ); + expect(result).toEqual({ stdout: '', stderr: '' }); + + consoleSpy.mockRestore(); + }); + + test('shows real-time output for fetch command', async () => { + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + const mockProcess = { + stdout: { + on: jest.fn((event: string, callback: Function) => { + if (event === 'data') callback(Buffer.from('Fetching...')); + }), + }, + stderr: { + on: jest.fn((event: string, callback: Function) => { + if (event === 'data') callback(Buffer.from('Progress...')); + }), + }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await executeGitCommand('fetch', []); + + expect(stdoutSpy).toHaveBeenCalledWith('Fetching...'); + expect(stderrSpy).toHaveBeenCalledWith('Progress...'); + + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + test('shows real-time output for checkout command', async () => { + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + const mockProcess = { + stdout: { + on: jest.fn((event: string, callback: Function) => { + if (event === 'data') callback(Buffer.from('Checking out...')); + }), + }, + stderr: { on: jest.fn() }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await executeGitCommand('checkout', ['abc123']); + + expect(stdoutSpy).toHaveBeenCalledWith('Checking out...'); + + stdoutSpy.mockRestore(); + }); + }); + + describe('Checkout behavior', () => { + test('uses detached HEAD by default (createBranch=false)', async () => { + const mockProcess = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await executeGitCommand('checkout', [ + '--progress', + '--force', + '--detach', + 'abc123', + ]); + + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + ['checkout', '--progress', '--force', '--detach', 'abc123'], + expect.any(Object), + ); + }); + + test('creates branch when createBranch=true', async () => { + const mockProcess = { + stdout: { on: jest.fn() }, + stderr: { on: jest.fn() }, + on: jest.fn((event: string, callback: Function) => { + if (event === 'exit') callback(0, null); + }), + }; + mockSpawn.mockReturnValue(mockProcess as any); + + await executeGitCommand('checkout', [ + '--progress', + '--force', + '-B', + 'main', + 'abc123', + ]); + + expect(mockSpawn).toHaveBeenCalledWith( + 'git', + ['checkout', '--progress', '--force', '-B', 'main', 'abc123'], + expect.any(Object), + ); + }); + }); + + describe('writeToNxCloudEnv', () => { + beforeEach(() => { + jest.clearAllMocks(); + (fsPromises.appendFile as any).mockResolvedValue(undefined); + }); + + test('writes environment variable to NX_CLOUD_ENV file', async () => { + process.env.NX_CLOUD_ENV = '/path/to/env/file'; + + await writeToNxCloudEnv('TEST_VAR', 'test_value'); + + expect(fsPromises.appendFile).toHaveBeenCalledWith( + '/path/to/env/file', + "TEST_VAR='test_value'\n", + 'utf8', + ); + }); + + test('warns when NX_CLOUD_ENV is not set', async () => { + delete process.env.NX_CLOUD_ENV; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await writeToNxCloudEnv('TEST_VAR', 'test_value'); + + expect(warnSpy).toHaveBeenCalledWith( + 'NX_CLOUD_ENV not set, skipping environment variable write', + ); + expect(fsPromises.appendFile).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + test('handles file write errors gracefully', async () => { + process.env.NX_CLOUD_ENV = '/path/to/env/file'; + (fsPromises.appendFile as any).mockRejectedValue( + new Error('Permission denied'), + ); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await writeToNxCloudEnv('TEST_VAR', 'test_value'); + + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to write to NX_CLOUD_ENV: Error: Permission denied', + ); + + warnSpy.mockRestore(); + }); + }); + + describe('GitCheckoutError', () => { + test('creates error with retryable flag', () => { + const error = new GitCheckoutError('Test error', true); + + expect(error.message).toBe('Test error'); + expect(error.isRetryable).toBe(true); + expect(error.name).toBe('GitCheckoutError'); + }); + + test('creates error with original error reference', () => { + const originalError = new Error('Original'); + const error = new GitCheckoutError('Wrapped error', false, originalError); + + expect(error.message).toBe('Wrapped error'); + expect(error.isRetryable).toBe(false); + expect(error.originalError).toBe(originalError); + }); + + test('defaults to non-retryable', () => { + const error = new GitCheckoutError('Test error'); + + expect(error.isRetryable).toBe(false); + }); + }); + + describe('Integration scenarios', () => { + test('full checkout flow with regular SHA', async () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123def456'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_CHECKOUT_DEPTH = '1'; + + const config = validateEnvironment(); + const fetchArgs = buildFetchCommand(config); + + expect(fetchArgs).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + 'origin', + 'abc123def456', + ]); + }); + + test('full checkout flow with origin branch', async () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'origin/feature-branch'; + process.env.NX_BRANCH = 'feature-branch'; + + const config = validateEnvironment(); + const fetchArgs = buildFetchCommand(config); + + expect(fetchArgs).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + 'origin', + 'feature-branch', + ]); + }); + + test('full checkout flow with PR reference', async () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'pull/123/head'; + process.env.NX_BRANCH = 'pr-123'; + process.env.GIT_CHECKOUT_DEPTH = '5'; + + const config = validateEnvironment(); + const fetchArgs = buildFetchCommand(config); + + expect(fetchArgs).toEqual([ + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=5', + 'origin', + 'refs/pull/123/head:refs/remotes/origin/pull/123/head', + ]); + }); + + test('full clone when depth is 0', async () => { + process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; + process.env.NX_COMMIT_SHA = 'abc123'; + process.env.NX_BRANCH = 'main'; + process.env.GIT_CHECKOUT_DEPTH = '0'; + + const config = validateEnvironment(); + const fetchArgs = buildFetchCommand(config); + + expect(fetchArgs).toEqual([ + '--prune', + '--progress', + '--no-recurse-submodules', + '--tags', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + ]); + }); + }); +}); diff --git a/workflow-steps/checkout/main.ts b/workflow-steps/checkout/main.ts index 1624179..55db140 100644 --- a/workflow-steps/checkout/main.ts +++ b/workflow-steps/checkout/main.ts @@ -1,83 +1,761 @@ -import { execSync } from 'child_process'; - -const repoUrl = process.env.GIT_REPOSITORY_URL as string; -const commitSha = process.env.NX_COMMIT_SHA as string; -const nxBranch = process.env.NX_BRANCH as string; // This can be a PR number or a branch name -const depth = process.env.GIT_CHECKOUT_DEPTH || 1; -const fetchTags = process.env.GIT_FETCH_TAGS === 'true'; -const maxRetries = 3; - -async function main() { - if (process.platform != 'win32') { - execSync(`git config --global --add safe.directory $PWD`); - } - execSync('git init .'); - execSync(`git remote add origin ${repoUrl}`); - execSync(`echo "GIT_REPOSITORY_URL=''" >> $NX_CLOUD_ENV`); - - let fetchCommand: string; - if (commitSha.startsWith('origin/')) { - fetchCommand = `git fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin ${nxBranch}`; - } else { - if (depth === '0') { - fetchCommand = - 'git fetch --prune --progress --no-recurse-submodules --tags origin "+refs/heads/*:refs/remotes/origin/*"'; +import { spawn, SpawnOptions } from 'node:child_process'; +import { appendFile } from 'node:fs/promises'; + +interface GitCheckoutConfig { + repoUrl: string; + commitSha: string; + nxBranch: string; + depth: number; + fetchTags: boolean; + filter: string; + timeout: number; + maxRetries: number; + dryRun: boolean; + createBranch: boolean; +} + +interface RetryableError extends Error { + isRetryable: boolean; +} + +type GitPlatform = 'github' | 'gitlab' | 'bitbucket' | 'azure' | 'unknown'; + +/** + * Detects git platform from repository URL + */ +export function detectPlatform(repoUrl: string): GitPlatform { + const url = repoUrl.toLowerCase(); + + if (url.includes('github.com') || url.includes('github.')) { + return 'github'; + } + if (url.includes('gitlab.com') || url.includes('gitlab.')) { + return 'gitlab'; + } + if (url.includes('bitbucket.org') || url.includes('bitbucket.')) { + return 'bitbucket'; + } + if (url.includes('dev.azure.com') || url.includes('visualstudio.com')) { + return 'azure'; + } + + return 'unknown'; +} + +/** + * Detects if nxBranch represents a PR/MR number and returns platform-specific refs + */ +export function getPullRequestRefs( + platform: GitPlatform, + prNumber: string, +): string[] { + switch (platform) { + case 'github': + return [ + `+refs/pull/${prNumber}/head:refs/remotes/origin/pr/${prNumber}/head`, + `+refs/pull/${prNumber}/merge:refs/remotes/origin/pr/${prNumber}/merge`, + ]; + case 'gitlab': + return [ + `+refs/merge-requests/${prNumber}/head:refs/remotes/origin/mr/${prNumber}/head`, + `+refs/merge-requests/${prNumber}/merge:refs/remotes/origin/mr/${prNumber}/merge`, + ]; + case 'bitbucket': + return [ + `+refs/pull-requests/${prNumber}/from:refs/remotes/origin/pr/${prNumber}/from`, + `+refs/pull-requests/${prNumber}/merge:refs/remotes/origin/pr/${prNumber}/merge`, + ]; + case 'azure': + return [ + `+refs/pull/${prNumber}/merge:refs/remotes/origin/pr/${prNumber}/merge`, + ]; + case 'unknown': + default: + // For unknown platforms, don't fetch PR refs to avoid unexpected errors + return []; + } +} + +/** + * Detects if commitSha is a platform-specific PR/MR reference + */ +export function isPullRequestRef( + platform: GitPlatform, + commitSha: string, +): boolean { + switch (platform) { + case 'github': + return /^(refs\/)?pull\/\d+\/(head|merge)$/i.test(commitSha); + case 'gitlab': + return /^(refs\/)?merge-requests\/\d+\/(head|merge)$/i.test(commitSha); + case 'bitbucket': + return /^(refs\/)?pull-requests\/\d+\/(from|merge)$/i.test(commitSha); + case 'azure': + return /^(refs\/)?pull\/\d+\/merge$/i.test(commitSha); + default: + return false; + } +} + +/** + * Detects if commitSha or nxBranch represents a merge queue/train + */ +export function isMergeQueueRef( + platform: GitPlatform, + commitSha: string, + nxBranch: string, +): boolean { + switch (platform) { + case 'github': + // GitHub merge queue: gh-readonly-queue/main/pr-123-abc123def + return ( + /^(refs\/heads\/)?gh-readonly-queue\//i.test(commitSha) || + /^gh-readonly-queue\//i.test(nxBranch) + ); + case 'gitlab': + // GitLab merge train: train/main/123 or ends with -merge-train + return ( + /^(refs\/heads\/)?train\//i.test(commitSha) || + /^train\//i.test(nxBranch) || + /-merge-train$/i.test(nxBranch) + ); + case 'azure': + // Azure merge queue: merge-queue/main/123 + return ( + /^(refs\/heads\/)?merge-queue\//i.test(commitSha) || + /^merge-queue\//i.test(nxBranch) + ); + default: + return false; + } +} + +class GitCheckoutError extends Error implements RetryableError { + constructor( + message: string, + public readonly isRetryable: boolean = false, + public readonly originalError?: Error, + ) { + super(message); + this.name = 'GitCheckoutError'; + } +} + +/** + * Validates and parses environment variables into a typed configuration + * @throws {GitCheckoutError} If required environment variables are missing or invalid + */ +function validateEnvironment(): GitCheckoutConfig { + const repoUrl = process.env.GIT_REPOSITORY_URL; + const commitSha = process.env.NX_COMMIT_SHA; + const nxBranch = process.env.NX_BRANCH; + + if (!repoUrl) { + throw new GitCheckoutError( + 'GIT_REPOSITORY_URL environment variable is required', + false, + ); + } + + if (!commitSha) { + throw new GitCheckoutError( + 'NX_COMMIT_SHA environment variable is required', + false, + ); + } + + if (!nxBranch) { + throw new GitCheckoutError( + 'NX_BRANCH environment variable is required', + false, + ); + } + + // Validate URL format + try { + if (repoUrl.startsWith('git@')) { + // For SSH URLs, just do basic validation + if (!repoUrl.match(/^git@[\w\.\-]+:[\w\-\.\/]+(\.git)?$/)) { + throw new Error('Invalid SSH URL format'); + } } else { - const tagsArg = fetchTags ? ' --tags' : '--no-tags'; - fetchCommand = `git fetch ${tagsArg} --prune --progress --no-recurse-submodules --depth=${depth} origin ${commitSha}`; + new URL(repoUrl); } + } catch { + throw new GitCheckoutError(`Invalid GIT_REPOSITORY_URL: ${repoUrl}`, false); } - await runWithRetries(() => execSync(fetchCommand), 'git fetch', maxRetries); + // Validate commit SHA format - allow SHAs, branch refs, and any valid git ref format + // This is platform-agnostic and lets git itself validate the ref during fetch + if ( + !commitSha.match( + /^[a-fA-F0-9]{6,40}$|^origin\/[\w\-\.\/]+$|^refs\/[\w\-\.\/]+$|^[\w\-\.\/]+\/\d+\/[\w\-]+$/i, + ) + ) { + throw new GitCheckoutError( + `Invalid NX_COMMIT_SHA format: ${commitSha}`, + false, + ); + } - const checkoutCommand = `git checkout --progress --force -B ${nxBranch} ${commitSha}`; - await runWithRetries( - () => execSync(checkoutCommand), - 'git checkout', + // Parse and validate depth + const depthStr = process.env.GIT_CHECKOUT_DEPTH || '1'; + const depth = parseInt(depthStr, 10); + if (isNaN(depth) || depth < 0) { + throw new GitCheckoutError( + `Invalid GIT_CHECKOUT_DEPTH: ${depthStr}`, + false, + ); + } + + // Parse other configuration + const fetchTags = process.env.GIT_FETCH_TAGS === 'true'; + const filter = process.env.GIT_FILTER || ''; // Partial clone filter (e.g., 'blob:none', 'tree:0') + const timeout = parseInt(process.env.GIT_TIMEOUT || '300000', 10); // 5 minutes default + const maxRetries = parseInt(process.env.GIT_MAX_RETRIES || '3', 10); + const dryRun = process.env.GIT_DRY_RUN === 'true'; + const createBranch = process.env.GIT_CREATE_BRANCH === 'true'; + + return { + repoUrl, + commitSha, + nxBranch, + depth, + fetchTags, + filter, + timeout, maxRetries, + dryRun, + createBranch, + }; +} + +/** + * Executes a git command safely using spawn with proper argument escaping + * @param command Git subcommand (e.g., 'init', 'fetch', 'checkout') + * @param args Array of arguments for the git command + * @param options Configuration options including timeout and dry-run mode + * @returns Promise that resolves with the command output or rejects with an error + */ +async function executeGitCommand( + command: string, + args: string[], + options: { timeout?: number; cwd?: string; dryRun?: boolean } = {}, +): Promise<{ stdout: string; stderr: string }> { + const fullArgs = [command, ...args]; + + if (options.dryRun) { + console.log(`[DRY RUN] Would execute: git ${fullArgs.join(' ')}`); + return { stdout: '', stderr: '' }; + } + + return new Promise((resolve, reject) => { + const spawnOptions: SpawnOptions = { + cwd: options.cwd || process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: options.timeout, + }; + + const child = spawn('git', fullArgs, spawnOptions); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + const output = data.toString(); + stdout += output; + // Show real-time progress for long-running operations + if (command === 'fetch' || command === 'checkout') { + process.stdout.write(output); + } + }); + + child.stderr?.on('data', (data) => { + const output = data.toString(); + stderr += output; + // Git often outputs progress to stderr + if (command === 'fetch' || command === 'checkout') { + process.stderr.write(output); + } + }); + + child.on('error', (error) => { + reject(classifyError(error, command)); + }); + + child.on('exit', (code, signal) => { + if (signal === 'SIGTERM' && options.timeout) { + reject( + new GitCheckoutError( + `Git ${command} timed out after ${options.timeout}ms`, + true, + ), + ); + } else if (code !== 0) { + reject(classifyError(new Error(stderr || stdout), command)); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +/** + * Classifies errors to determine if they are retryable + * @param error The error to classify + * @param command The git command that failed + * @returns A GitCheckoutError with isRetryable flag set appropriately + */ +function classifyError(error: Error, command: string): GitCheckoutError { + const message = error.message || error.toString(); + const lowerMessage = message.toLowerCase(); + + // Network and connection errors - retryable + if ( + lowerMessage.includes('connection') || + lowerMessage.includes('timeout') || + lowerMessage.includes('early eof') || + lowerMessage.includes('network') || + lowerMessage.includes('could not read from remote') || + lowerMessage.includes('unable to access') || + lowerMessage.includes("couldn't resolve host") + ) { + return new GitCheckoutError( + `Network error during git ${command}: ${message}`, + true, + error, + ); + } + + // Authentication errors - not retryable + if ( + lowerMessage.includes('authentication') || + lowerMessage.includes('permission denied') || + lowerMessage.includes('could not read username') || + lowerMessage.includes('invalid username or password') + ) { + return new GitCheckoutError( + `Authentication error during git ${command}: ${message}`, + false, + error, + ); + } + + // Reference errors - not retryable + if ( + lowerMessage.includes('not found') || + lowerMessage.includes("couldn't find remote ref") || + lowerMessage.includes('invalid ref') || + lowerMessage.includes('pathspec') || + lowerMessage.includes('did not match any') + ) { + return new GitCheckoutError( + `Reference error during git ${command}: ${message}`, + false, + error, + ); + } + + // Default to non-retryable + return new GitCheckoutError( + `Git ${command} failed: ${message}`, + false, + error, ); } -async function runWithRetries( - fn: () => void, +/** + * Executes a function with retry logic using exponential backoff + * @param fn The async function to execute + * @param label A label for logging purposes + * @param maxRetries Maximum number of retry attempts + * @returns Promise that resolves when the function succeeds or rejects after all retries + */ +async function executeWithRetry( + fn: () => Promise, label: string, - maxRetriesLocal: number, -) { - let attempt = 0; - while (attempt < maxRetriesLocal) { + maxRetries: number, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - fn(); - return; - } catch (e) { - attempt++; + return await fn(); + } catch (error) { + lastError = error as Error; - if (attempt >= maxRetriesLocal) { - throw e; + // Check if error is retryable + const gitError = error as GitCheckoutError; + if (!gitError.isRetryable || attempt >= maxRetries) { + throw error; } - const delayMs = attempt === 1 ? 10_000 : 60_000; - const stderr = (e as any)?.stderr?.toString?.() || ''; - const stdout = (e as any)?.stdout?.toString?.() || ''; - if (stderr) { - console.error(stderr.trim()); - } - if (stdout) { - console.log(stdout.trim()); - } + // Calculate delay with exponential backoff and jitter + const baseDelay = attempt === 1 ? 10000 : 30000; // 10s for first retry, 30s for subsequent + // Add random jitter to prevent thundering herd problem - if multiple processes fail + // simultaneously, jitter spreads out retry attempts to avoid overwhelming the server + const jitter = Math.random() * 5000; // 0-5s random jitter + const delay = baseDelay * Math.pow(1.5, attempt - 1) + jitter; + const maxDelay = 120000; // Cap at 2 minutes + const actualDelay = Math.min(delay, maxDelay); + console.log( - `\n--- ${label} attempt ${attempt} failed; retrying in ${ - delayMs / 1000 - }s ---`, + `\n--- ${label} attempt ${attempt} failed (retryable error); retrying in ${Math.round(actualDelay / 1000)}s ---`, + ); + console.log(`Error: ${gitError.message}`); + + await new Promise((resolve) => setTimeout(resolve, actualDelay)); + } + } + + throw lastError; +} + +/** + * Writes environment variable to NX_CLOUD_ENV file in a cross-platform way + * @param key The environment variable key + * @param value The environment variable value + */ +async function writeToNxCloudEnv(key: string, value: string): Promise { + const nxCloudEnvPath = process.env.NX_CLOUD_ENV; + if (!nxCloudEnvPath) { + console.warn('NX_CLOUD_ENV not set, skipping environment variable write'); + return; + } + + try { + const line = `${key}='${value}'\n`; + await appendFile(nxCloudEnvPath, line, 'utf8'); + } catch (error) { + console.warn(`Failed to write to NX_CLOUD_ENV: ${error}`); + } +} + +/** + * Builds the appropriate git fetch command based on configuration + * @param config The validated configuration + * @returns Array of arguments for the git fetch command + */ +function buildFetchCommand(config: GitCheckoutConfig): string[] { + const args: string[] = []; + const platform = detectPlatform(config.repoUrl); + + // Handle refs/heads/ format (standard git ref format) + const headRefMatch = config.commitSha.match(/^refs\/heads\/(.+)$/i); + if (headRefMatch) { + const branchName = headRefMatch[1]; + args.push( + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + `--depth=${config.depth}`, + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push( + 'origin', + `+refs/heads/${branchName}:refs/remotes/origin/${branchName}`, + ); + } else if (config.commitSha.startsWith('origin/')) { + // This is a branch reference, not a SHA + const branchName = config.commitSha.replace('origin/', ''); + args.push( + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + '--depth=1', + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push('origin', branchName); + } else if (isMergeQueueRef(platform, config.commitSha, config.nxBranch)) { + // This is a merge queue/train branch - treat like a regular branch + // Use commitSha if it matches queue pattern, otherwise use nxBranch + let queueBranch: string; + if ( + /^(refs\/heads\/)?gh-readonly-queue\//i.test(config.commitSha) || + /^(refs\/heads\/)?train\//i.test(config.commitSha) || + /^(refs\/heads\/)?merge-queue\//i.test(config.commitSha) + ) { + queueBranch = config.commitSha.startsWith('refs/heads/') + ? config.commitSha.replace('refs/heads/', '') + : config.commitSha; + } else { + // commitSha is regular SHA, use nxBranch for branch name + queueBranch = config.nxBranch; + } + + args.push( + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + `--depth=${config.depth}`, + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push( + 'origin', + `+refs/heads/${queueBranch}:refs/remotes/origin/${queueBranch}`, + ); + } else if (isPullRequestRef(platform, config.commitSha)) { + // This is a platform-specific PR/MR reference + args.push( + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules', + `--depth=${config.depth}`, + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + + // Add appropriate ref spec based on platform + if (config.commitSha.startsWith('refs/')) { + args.push( + 'origin', + `${config.commitSha}:refs/remotes/origin/${config.commitSha}`, + ); + } else { + args.push( + 'origin', + `refs/${config.commitSha}:refs/remotes/origin/${config.commitSha}`, + ); + } + } else if (config.depth === 0) { + // Full clone - fetch all branches and tags + args.push('--prune', '--progress', '--no-recurse-submodules', '--tags'); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push('origin', '+refs/heads/*:refs/remotes/origin/*'); + + // Additionally fetch PR/MR refs if we're in a PR context + // PR context is detected when nxBranch is a numeric PR/MR number + // BUT exclude merge queues which may contain numeric patterns + if ( + config.nxBranch.match(/^\d+$/) && + !isMergeQueueRef(platform, config.commitSha, config.nxBranch) + ) { + const prNumber = config.nxBranch; + const prRefs = getPullRequestRefs(platform, prNumber); + args.push(...prRefs); + } + } else { + // Regular SHA with depth + const tagsArg = config.fetchTags ? '--tags' : '--no-tags'; + args.push( + tagsArg, + '--prune', + '--progress', + '--no-recurse-submodules', + `--depth=${config.depth}`, + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push('origin', config.commitSha); + } + + return args; +} + +/** + * Main function to perform git checkout with all safety and reliability features + */ +async function main(): Promise { + let config: GitCheckoutConfig; + + try { + config = validateEnvironment(); + } catch (error) { + console.error('Configuration error:', (error as Error).message); + process.exit(1); + } + + console.log('Git checkout configuration:'); + console.log(` Repository: ${config.repoUrl}`); + console.log(` Commit/Ref: ${config.commitSha}`); + console.log(` Branch: ${config.nxBranch}`); + console.log(` Depth: ${config.depth}`); + console.log(` Fetch tags: ${config.fetchTags}`); + if (config.filter) { + console.log(` Filter: ${config.filter}`); + } + console.log(` Create branch: ${config.createBranch}`); + console.log(` Timeout: ${config.timeout}ms`); + console.log(` Max retries: ${config.maxRetries}`); + if (config.dryRun) { + console.log(' Mode: DRY RUN'); + } + console.log(''); + + try { + /** + * Configure git safe directory (not on windows) + * + * Skips configuration on Windows because: + * 1. 'git config --global' typically requires elevated (admin) privileges on Windows. + * 2. The 'safe.directory' security feature is less significant on Windows due to + * differing permission and ownership semantics compared to Unix-like systems. + */ + if (process.platform !== 'win32') { + const cwd = process.cwd(); + await executeGitCommand( + 'config', + ['--global', '--add', 'safe.directory', cwd], + { + timeout: config.timeout, + dryRun: config.dryRun, + }, + ); + } + + // Initialize repository + console.log('Initializing git repository...'); + await executeGitCommand('init', ['.'], { + timeout: config.timeout, + dryRun: config.dryRun, + }); + + // Add remote + console.log('Adding remote origin...'); + await executeGitCommand('remote', ['add', 'origin', config.repoUrl], { + timeout: config.timeout, + dryRun: config.dryRun, + }); + + // Clear the repository URL from environment + await writeToNxCloudEnv('GIT_REPOSITORY_URL', ''); + + // Fetch with retries + console.log('Fetching from remote...'); + const fetchArgs = buildFetchCommand(config); + await executeWithRetry( + () => + executeGitCommand('fetch', fetchArgs, { + timeout: config.timeout, + dryRun: config.dryRun, + }), + 'git fetch', + config.maxRetries, + ); + + // Determine what to checkout + // Check if this is a refs/heads/ format (for both checkout target and branch detection) + const headRefMatch = config.commitSha.match(/^refs\/heads\/(.+)$/i); + + let checkoutTarget: string; + if (headRefMatch) { + // For refs/heads/branch, checkout origin/branch + checkoutTarget = `origin/${headRefMatch[1]}`; + } else if (config.commitSha.startsWith('origin/')) { + checkoutTarget = config.commitSha; + } else if (config.commitSha.startsWith('pull/')) { + checkoutTarget = `origin/${config.commitSha}`; + } else { + checkoutTarget = config.commitSha; + } + + // Checkout with retries + // Match GitHub Actions: create branch for refs/heads/*, detach for everything else + // Allow manual override via GIT_CREATE_BRANCH env var for backwards compatibility + const shouldCreateBranch = config.createBranch || headRefMatch; + + let checkoutArgs: string[]; + + if (shouldCreateBranch) { + // Determine branch name + const branchName = headRefMatch + ? headRefMatch[1] // Extract captured branch name from refs/heads/ + : config.nxBranch; // Manual override - use nxBranch + + checkoutArgs = [ + '--progress', + '--force', + '-B', + branchName, + checkoutTarget, + ]; + } else { + // Detached HEAD for PRs, tags, and SHAs (default CI behavior) + checkoutArgs = ['--progress', '--force', '--detach', checkoutTarget]; + } + + console.log(`Checking out ${checkoutTarget}...`); + await executeWithRetry( + () => + executeGitCommand('checkout', checkoutArgs, { + timeout: config.timeout, + dryRun: config.dryRun, + }), + 'git checkout', + config.maxRetries, + ); + + // Verify checkout (unless in dry-run mode) + if (!config.dryRun) { + console.log('Verifying checkout...'); + const { stdout: currentSha } = await executeGitCommand( + 'rev-parse', + ['HEAD'], + { + timeout: config.timeout, + }, ); - await new Promise((r) => setTimeout(r, delayMs)); + const expectedSha = + config.commitSha.startsWith('origin/') || + config.commitSha.startsWith('pull/') + ? currentSha.trim() // For branches/PRs, we accept whatever was checked out + : config.commitSha; + + if ( + !config.commitSha.startsWith('origin/') && + !config.commitSha.startsWith('pull/') && + !currentSha.trim().startsWith(expectedSha.substring(0, 7)) + ) { + throw new GitCheckoutError( + `Checkout verification failed. Expected ${expectedSha}, but HEAD is at ${currentSha.trim()}`, + false, + ); + } + + console.log(`Successfully checked out ${currentSha.trim()}`); } + } catch (error) { + const gitError = error as GitCheckoutError; + console.error('Git checkout failed:', gitError.message); + if (gitError.originalError) { + console.error('Original error:', gitError.originalError.message); + } + process.exit(1); } } -main() - .then(() => {}) - .catch((error) => { - console.error('Failed:', error); +// Export for testing +export { + buildFetchCommand, + classifyError, + executeGitCommand, + executeWithRetry, + GitCheckoutConfig, + GitCheckoutError, + validateEnvironment, + writeToNxCloudEnv, +}; + +// Run if this is the main module +if (require.main === module) { + main().catch((error) => { + console.error('Unexpected error:', error); process.exit(1); }); +} diff --git a/workflow-steps/checkout/output/main.js b/workflow-steps/checkout/output/main.js index 720ca67..94332d4 100644 --- a/workflow-steps/checkout/output/main.js +++ b/workflow-steps/checkout/output/main.js @@ -1,67 +1,540 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + // main.ts -var import_child_process = require("child_process"); -var repoUrl = process.env.GIT_REPOSITORY_URL; -var commitSha = process.env.NX_COMMIT_SHA; -var nxBranch = process.env.NX_BRANCH; -var depth = process.env.GIT_CHECKOUT_DEPTH || 1; -var fetchTags = process.env.GIT_FETCH_TAGS === "true"; -var maxRetries = 3; -async function main() { - if (process.platform != "win32") { - (0, import_child_process.execSync)(`git config --global --add safe.directory $PWD`); - } - (0, import_child_process.execSync)("git init ."); - (0, import_child_process.execSync)(`git remote add origin ${repoUrl}`); - (0, import_child_process.execSync)(`echo "GIT_REPOSITORY_URL=''" >> $NX_CLOUD_ENV`); - let fetchCommand; - if (commitSha.startsWith("origin/")) { - fetchCommand = `git fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin ${nxBranch}`; - } else { - if (depth === "0") { - fetchCommand = 'git fetch --prune --progress --no-recurse-submodules --tags origin "+refs/heads/*:refs/remotes/origin/*"'; +var main_exports = {}; +__export(main_exports, { + GitCheckoutError: () => GitCheckoutError, + buildFetchCommand: () => buildFetchCommand, + classifyError: () => classifyError, + detectPlatform: () => detectPlatform, + executeGitCommand: () => executeGitCommand, + executeWithRetry: () => executeWithRetry, + getPullRequestRefs: () => getPullRequestRefs, + isMergeQueueRef: () => isMergeQueueRef, + isPullRequestRef: () => isPullRequestRef, + validateEnvironment: () => validateEnvironment, + writeToNxCloudEnv: () => writeToNxCloudEnv +}); +module.exports = __toCommonJS(main_exports); +var import_node_child_process = require("node:child_process"); +var import_promises = require("node:fs/promises"); +function detectPlatform(repoUrl) { + const url = repoUrl.toLowerCase(); + if (url.includes("github.com") || url.includes("github.")) { + return "github"; + } + if (url.includes("gitlab.com") || url.includes("gitlab.")) { + return "gitlab"; + } + if (url.includes("bitbucket.org") || url.includes("bitbucket.")) { + return "bitbucket"; + } + if (url.includes("dev.azure.com") || url.includes("visualstudio.com")) { + return "azure"; + } + return "unknown"; +} +function getPullRequestRefs(platform, prNumber) { + switch (platform) { + case "github": + return [ + `+refs/pull/${prNumber}/head:refs/remotes/origin/pr/${prNumber}/head`, + `+refs/pull/${prNumber}/merge:refs/remotes/origin/pr/${prNumber}/merge` + ]; + case "gitlab": + return [ + `+refs/merge-requests/${prNumber}/head:refs/remotes/origin/mr/${prNumber}/head`, + `+refs/merge-requests/${prNumber}/merge:refs/remotes/origin/mr/${prNumber}/merge` + ]; + case "bitbucket": + return [ + `+refs/pull-requests/${prNumber}/from:refs/remotes/origin/pr/${prNumber}/from`, + `+refs/pull-requests/${prNumber}/merge:refs/remotes/origin/pr/${prNumber}/merge` + ]; + case "azure": + return [ + `+refs/pull/${prNumber}/merge:refs/remotes/origin/pr/${prNumber}/merge` + ]; + case "unknown": + default: + return []; + } +} +function isPullRequestRef(platform, commitSha) { + switch (platform) { + case "github": + return /^(refs\/)?pull\/\d+\/(head|merge)$/i.test(commitSha); + case "gitlab": + return /^(refs\/)?merge-requests\/\d+\/(head|merge)$/i.test(commitSha); + case "bitbucket": + return /^(refs\/)?pull-requests\/\d+\/(from|merge)$/i.test(commitSha); + case "azure": + return /^(refs\/)?pull\/\d+\/merge$/i.test(commitSha); + default: + return false; + } +} +function isMergeQueueRef(platform, commitSha, nxBranch) { + switch (platform) { + case "github": + return /^(refs\/heads\/)?gh-readonly-queue\//i.test(commitSha) || /^gh-readonly-queue\//i.test(nxBranch); + case "gitlab": + return /^(refs\/heads\/)?train\//i.test(commitSha) || /^train\//i.test(nxBranch) || /-merge-train$/i.test(nxBranch); + case "azure": + return /^(refs\/heads\/)?merge-queue\//i.test(commitSha) || /^merge-queue\//i.test(nxBranch); + default: + return false; + } +} +var GitCheckoutError = class extends Error { + constructor(message, isRetryable = false, originalError) { + super(message); + this.isRetryable = isRetryable; + this.originalError = originalError; + this.name = "GitCheckoutError"; + } +}; +function validateEnvironment() { + const repoUrl = process.env.GIT_REPOSITORY_URL; + const commitSha = process.env.NX_COMMIT_SHA; + const nxBranch = process.env.NX_BRANCH; + if (!repoUrl) { + throw new GitCheckoutError( + "GIT_REPOSITORY_URL environment variable is required", + false + ); + } + if (!commitSha) { + throw new GitCheckoutError( + "NX_COMMIT_SHA environment variable is required", + false + ); + } + if (!nxBranch) { + throw new GitCheckoutError( + "NX_BRANCH environment variable is required", + false + ); + } + try { + if (repoUrl.startsWith("git@")) { + if (!repoUrl.match(/^git@[\w\.\-]+:[\w\-\.\/]+(\.git)?$/)) { + throw new Error("Invalid SSH URL format"); + } } else { - const tagsArg = fetchTags ? " --tags" : "--no-tags"; - fetchCommand = `git fetch ${tagsArg} --prune --progress --no-recurse-submodules --depth=${depth} origin ${commitSha}`; + new URL(repoUrl); } + } catch { + throw new GitCheckoutError(`Invalid GIT_REPOSITORY_URL: ${repoUrl}`, false); } - await runWithRetries(() => (0, import_child_process.execSync)(fetchCommand), "git fetch", maxRetries); - const checkoutCommand = `git checkout --progress --force -B ${nxBranch} ${commitSha}`; - await runWithRetries( - () => (0, import_child_process.execSync)(checkoutCommand), - "git checkout", - maxRetries - ); + if (!commitSha.match( + /^[a-fA-F0-9]{6,40}$|^origin\/[\w\-\.\/]+$|^refs\/[\w\-\.\/]+$|^[\w\-\.\/]+\/\d+\/[\w\-]+$/i + )) { + throw new GitCheckoutError( + `Invalid NX_COMMIT_SHA format: ${commitSha}`, + false + ); + } + const depthStr = process.env.GIT_CHECKOUT_DEPTH || "1"; + const depth = parseInt(depthStr, 10); + if (isNaN(depth) || depth < 0) { + throw new GitCheckoutError( + `Invalid GIT_CHECKOUT_DEPTH: ${depthStr}`, + false + ); + } + const fetchTags = process.env.GIT_FETCH_TAGS === "true"; + const filter = process.env.GIT_FILTER || ""; + const timeout = parseInt(process.env.GIT_TIMEOUT || "300000", 10); + const maxRetries = parseInt(process.env.GIT_MAX_RETRIES || "3", 10); + const dryRun = process.env.GIT_DRY_RUN === "true"; + const createBranch = process.env.GIT_CREATE_BRANCH === "true"; + return { + repoUrl, + commitSha, + nxBranch, + depth, + fetchTags, + filter, + timeout, + maxRetries, + dryRun, + createBranch + }; } -async function runWithRetries(fn, label, maxRetriesLocal) { - let attempt = 0; - while (attempt < maxRetriesLocal) { - try { - fn(); - return; - } catch (e) { - attempt++; - if (attempt >= maxRetriesLocal) { - throw e; +async function executeGitCommand(command, args, options = {}) { + const fullArgs = [command, ...args]; + if (options.dryRun) { + console.log(`[DRY RUN] Would execute: git ${fullArgs.join(" ")}`); + return { stdout: "", stderr: "" }; + } + return new Promise((resolve, reject) => { + const spawnOptions = { + cwd: options.cwd || process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + timeout: options.timeout + }; + const child = (0, import_node_child_process.spawn)("git", fullArgs, spawnOptions); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (data) => { + const output = data.toString(); + stdout += output; + if (command === "fetch" || command === "checkout") { + process.stdout.write(output); } - const delayMs = attempt === 1 ? 1e4 : 6e4; - const stderr = e?.stderr?.toString?.() || ""; - const stdout = e?.stdout?.toString?.() || ""; - if (stderr) { - console.error(stderr.trim()); + }); + child.stderr?.on("data", (data) => { + const output = data.toString(); + stderr += output; + if (command === "fetch" || command === "checkout") { + process.stderr.write(output); } - if (stdout) { - console.log(stdout.trim()); + }); + child.on("error", (error) => { + reject(classifyError(error, command)); + }); + child.on("exit", (code, signal) => { + if (signal === "SIGTERM" && options.timeout) { + reject( + new GitCheckoutError( + `Git ${command} timed out after ${options.timeout}ms`, + true + ) + ); + } else if (code !== 0) { + reject(classifyError(new Error(stderr || stdout), command)); + } else { + resolve({ stdout, stderr }); } + }); + }); +} +function classifyError(error, command) { + const message = error.message || error.toString(); + const lowerMessage = message.toLowerCase(); + if (lowerMessage.includes("connection") || lowerMessage.includes("timeout") || lowerMessage.includes("early eof") || lowerMessage.includes("network") || lowerMessage.includes("could not read from remote") || lowerMessage.includes("unable to access") || lowerMessage.includes("couldn't resolve host")) { + return new GitCheckoutError( + `Network error during git ${command}: ${message}`, + true, + error + ); + } + if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied") || lowerMessage.includes("could not read username") || lowerMessage.includes("invalid username or password")) { + return new GitCheckoutError( + `Authentication error during git ${command}: ${message}`, + false, + error + ); + } + if (lowerMessage.includes("not found") || lowerMessage.includes("couldn't find remote ref") || lowerMessage.includes("invalid ref") || lowerMessage.includes("pathspec") || lowerMessage.includes("did not match any")) { + return new GitCheckoutError( + `Reference error during git ${command}: ${message}`, + false, + error + ); + } + return new GitCheckoutError( + `Git ${command} failed: ${message}`, + false, + error + ); +} +async function executeWithRetry(fn, label, maxRetries) { + let lastError; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + const gitError = error; + if (!gitError.isRetryable || attempt >= maxRetries) { + throw error; + } + const baseDelay = attempt === 1 ? 1e4 : 3e4; + const jitter = Math.random() * 5e3; + const delay = baseDelay * Math.pow(1.5, attempt - 1) + jitter; + const maxDelay = 12e4; + const actualDelay = Math.min(delay, maxDelay); console.log( ` ---- ${label} attempt ${attempt} failed; retrying in ${delayMs / 1e3}s ---` +--- ${label} attempt ${attempt} failed (retryable error); retrying in ${Math.round(actualDelay / 1e3)}s ---` ); - await new Promise((r) => setTimeout(r, delayMs)); + console.log(`Error: ${gitError.message}`); + await new Promise((resolve) => setTimeout(resolve, actualDelay)); } } + throw lastError; +} +async function writeToNxCloudEnv(key, value) { + const nxCloudEnvPath = process.env.NX_CLOUD_ENV; + if (!nxCloudEnvPath) { + console.warn("NX_CLOUD_ENV not set, skipping environment variable write"); + return; + } + try { + const line = `${key}='${value}' +`; + await (0, import_promises.appendFile)(nxCloudEnvPath, line, "utf8"); + } catch (error) { + console.warn(`Failed to write to NX_CLOUD_ENV: ${error}`); + } +} +function buildFetchCommand(config) { + const args = []; + const platform = detectPlatform(config.repoUrl); + const headRefMatch = config.commitSha.match(/^refs\/heads\/(.+)$/i); + if (headRefMatch) { + const branchName = headRefMatch[1]; + args.push( + "--no-tags", + "--prune", + "--progress", + "--no-recurse-submodules", + `--depth=${config.depth}` + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push( + "origin", + `+refs/heads/${branchName}:refs/remotes/origin/${branchName}` + ); + } else if (config.commitSha.startsWith("origin/")) { + const branchName = config.commitSha.replace("origin/", ""); + args.push( + "--no-tags", + "--prune", + "--progress", + "--no-recurse-submodules", + "--depth=1" + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push("origin", branchName); + } else if (isMergeQueueRef(platform, config.commitSha, config.nxBranch)) { + let queueBranch; + if (/^(refs\/heads\/)?gh-readonly-queue\//i.test(config.commitSha) || /^(refs\/heads\/)?train\//i.test(config.commitSha) || /^(refs\/heads\/)?merge-queue\//i.test(config.commitSha)) { + queueBranch = config.commitSha.startsWith("refs/heads/") ? config.commitSha.replace("refs/heads/", "") : config.commitSha; + } else { + queueBranch = config.nxBranch; + } + args.push( + "--no-tags", + "--prune", + "--progress", + "--no-recurse-submodules", + `--depth=${config.depth}` + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push( + "origin", + `+refs/heads/${queueBranch}:refs/remotes/origin/${queueBranch}` + ); + } else if (isPullRequestRef(platform, config.commitSha)) { + args.push( + "--no-tags", + "--prune", + "--progress", + "--no-recurse-submodules", + `--depth=${config.depth}` + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + if (config.commitSha.startsWith("refs/")) { + args.push( + "origin", + `${config.commitSha}:refs/remotes/origin/${config.commitSha}` + ); + } else { + args.push( + "origin", + `refs/${config.commitSha}:refs/remotes/origin/${config.commitSha}` + ); + } + } else if (config.depth === 0) { + args.push("--prune", "--progress", "--no-recurse-submodules", "--tags"); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push("origin", "+refs/heads/*:refs/remotes/origin/*"); + if (config.nxBranch.match(/^\d+$/) && !isMergeQueueRef(platform, config.commitSha, config.nxBranch)) { + const prNumber = config.nxBranch; + const prRefs = getPullRequestRefs(platform, prNumber); + args.push(...prRefs); + } + } else { + const tagsArg = config.fetchTags ? "--tags" : "--no-tags"; + args.push( + tagsArg, + "--prune", + "--progress", + "--no-recurse-submodules", + `--depth=${config.depth}` + ); + if (config.filter) { + args.push(`--filter=${config.filter}`); + } + args.push("origin", config.commitSha); + } + return args; +} +async function main() { + let config; + try { + config = validateEnvironment(); + } catch (error) { + console.error("Configuration error:", error.message); + process.exit(1); + } + console.log("Git checkout configuration:"); + console.log(` Repository: ${config.repoUrl}`); + console.log(` Commit/Ref: ${config.commitSha}`); + console.log(` Branch: ${config.nxBranch}`); + console.log(` Depth: ${config.depth}`); + console.log(` Fetch tags: ${config.fetchTags}`); + if (config.filter) { + console.log(` Filter: ${config.filter}`); + } + console.log(` Create branch: ${config.createBranch}`); + console.log(` Timeout: ${config.timeout}ms`); + console.log(` Max retries: ${config.maxRetries}`); + if (config.dryRun) { + console.log(" Mode: DRY RUN"); + } + console.log(""); + try { + if (process.platform !== "win32") { + const cwd = process.cwd(); + await executeGitCommand( + "config", + ["--global", "--add", "safe.directory", cwd], + { + timeout: config.timeout, + dryRun: config.dryRun + } + ); + } + console.log("Initializing git repository..."); + await executeGitCommand("init", ["."], { + timeout: config.timeout, + dryRun: config.dryRun + }); + console.log("Adding remote origin..."); + await executeGitCommand("remote", ["add", "origin", config.repoUrl], { + timeout: config.timeout, + dryRun: config.dryRun + }); + await writeToNxCloudEnv("GIT_REPOSITORY_URL", ""); + console.log("Fetching from remote..."); + const fetchArgs = buildFetchCommand(config); + await executeWithRetry( + () => executeGitCommand("fetch", fetchArgs, { + timeout: config.timeout, + dryRun: config.dryRun + }), + "git fetch", + config.maxRetries + ); + const headRefMatch = config.commitSha.match(/^refs\/heads\/(.+)$/i); + let checkoutTarget; + if (headRefMatch) { + checkoutTarget = `origin/${headRefMatch[1]}`; + } else if (config.commitSha.startsWith("origin/")) { + checkoutTarget = config.commitSha; + } else if (config.commitSha.startsWith("pull/")) { + checkoutTarget = `origin/${config.commitSha}`; + } else { + checkoutTarget = config.commitSha; + } + const shouldCreateBranch = config.createBranch || headRefMatch; + let checkoutArgs; + if (shouldCreateBranch) { + const branchName = headRefMatch ? headRefMatch[1] : config.nxBranch; + checkoutArgs = [ + "--progress", + "--force", + "-B", + branchName, + checkoutTarget + ]; + } else { + checkoutArgs = ["--progress", "--force", "--detach", checkoutTarget]; + } + console.log(`Checking out ${checkoutTarget}...`); + await executeWithRetry( + () => executeGitCommand("checkout", checkoutArgs, { + timeout: config.timeout, + dryRun: config.dryRun + }), + "git checkout", + config.maxRetries + ); + if (!config.dryRun) { + console.log("Verifying checkout..."); + const { stdout: currentSha } = await executeGitCommand( + "rev-parse", + ["HEAD"], + { + timeout: config.timeout + } + ); + const expectedSha = config.commitSha.startsWith("origin/") || config.commitSha.startsWith("pull/") ? currentSha.trim() : config.commitSha; + if (!config.commitSha.startsWith("origin/") && !config.commitSha.startsWith("pull/") && !currentSha.trim().startsWith(expectedSha.substring(0, 7))) { + throw new GitCheckoutError( + `Checkout verification failed. Expected ${expectedSha}, but HEAD is at ${currentSha.trim()}`, + false + ); + } + console.log(`Successfully checked out ${currentSha.trim()}`); + } + } catch (error) { + const gitError = error; + console.error("Git checkout failed:", gitError.message); + if (gitError.originalError) { + console.error("Original error:", gitError.originalError.message); + } + process.exit(1); + } +} +if (require.main === module) { + main().catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); + }); } -main().then(() => { -}).catch((error) => { - console.error("Failed:", error); - process.exit(1); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + GitCheckoutError, + buildFetchCommand, + classifyError, + detectPlatform, + executeGitCommand, + executeWithRetry, + getPullRequestRefs, + isMergeQueueRef, + isPullRequestRef, + validateEnvironment, + writeToNxCloudEnv }); diff --git a/workflow-steps/checkout/package.json b/workflow-steps/checkout/package.json index 8e128a4..467820d 100644 --- a/workflow-steps/checkout/package.json +++ b/workflow-steps/checkout/package.json @@ -1,9 +1,10 @@ { "name": "checkout-step", - "version": "0.0.0", + "private": true, "main": "output/main.js", - "devDependencies": {}, "scripts": { - "build": "npx esbuild main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=output/main.js" + "build": "npx esbuild main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=output/main.js", + "test": "jest", + "test:coverage": "jest --coverage" } }