diff --git a/__tests__/git/push.test.ts b/__tests__/git/push.test.ts new file mode 100644 index 0000000..5e216b1 --- /dev/null +++ b/__tests__/git/push.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})) + +import { execFile } from 'node:child_process' +import { pushBranchForJob } from '../../src/git/push.js' + +// promisify(execFile) will call execFile(cmd, args, opts, cb) internally +type ExecCallback = (err: Error | null, result?: { stdout: string; stderr: string }) => void +const mockExec = execFile as unknown as ReturnType + +const SHA_HEAD = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const SHA_BASE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('pushBranchForJob', () => { + it('returns pushed=true with remoteRef on successful push', async () => { + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' }) + if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + // git push -u origin + return cb(null, { stdout: '', stderr: '' }) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toEqual({ pushed: true, remoteRef: 'refs/heads/feat/job-abc' }) + }) + + it('returns no-changes when HEAD equals origin/main', async () => { + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD') || args.includes('origin/main')) { + return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + } + return cb(null, { stdout: '', stderr: '' }) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toEqual({ pushed: false, reason: 'no-changes', stderr: '' }) + }) + + it('returns no-credentials when push fails with Authentication failed', async () => { + const authError = Object.assign(new Error('git push failed'), { + stderr: 'fatal: Authentication failed for https://github.com/...', + }) + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' }) + if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + return cb(authError) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toMatchObject({ pushed: false, reason: 'no-credentials' }) + expect((result as { stderr: string }).stderr).toContain('Authentication failed') + }) + + it('returns conflict when push is rejected (non-fast-forward)', async () => { + const conflictError = Object.assign(new Error('git push failed'), { + stderr: '! [rejected] feat/job-abc -> feat/job-abc (non-fast-forward)', + }) + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' }) + if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + return cb(conflictError) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toMatchObject({ pushed: false, reason: 'conflict' }) + }) + + it('returns unknown for unrecognised push errors', async () => { + const unknownError = Object.assign(new Error('git push failed'), { + stderr: 'error: some unexpected thing happened', + }) + mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => { + if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' }) + if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' }) + return cb(unknownError) + }) + + const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' }) + + expect(result).toMatchObject({ pushed: false, reason: 'unknown' }) + }) +}) diff --git a/src/git/push.ts b/src/git/push.ts new file mode 100644 index 0000000..6003dc3 --- /dev/null +++ b/src/git/push.ts @@ -0,0 +1,53 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const exec = promisify(execFile) + +type PushSuccess = { pushed: true; remoteRef: string } +type PushFailure = { + pushed: false + reason: 'no-credentials' | 'conflict' | 'no-changes' | 'unknown' + stderr: string +} + +export type PushResult = PushSuccess | PushFailure + +export async function pushBranchForJob(opts: { + worktreePath: string + branchName: string +}): Promise { + const { worktreePath, branchName } = opts + + // Detect no new commits vs origin/main + let headSha: string + let baseSha: string + try { + const [headResult, baseResult] = await Promise.all([ + exec('git', ['rev-parse', 'HEAD'], { cwd: worktreePath }), + exec('git', ['rev-parse', 'origin/main'], { cwd: worktreePath }), + ]) + headSha = headResult.stdout.trim() + baseSha = baseResult.stdout.trim() + } catch (err) { + return { pushed: false, reason: 'unknown', stderr: (err as Error).message } + } + + if (headSha === baseSha) { + return { pushed: false, reason: 'no-changes', stderr: '' } + } + + // Push + try { + await exec('git', ['push', '-u', 'origin', branchName], { cwd: worktreePath }) + return { pushed: true, remoteRef: `refs/heads/${branchName}` } + } catch (err) { + const stderr = (err as { stderr?: string }).stderr ?? (err as Error).message ?? '' + if (/Authentication failed|could not read Username/i.test(stderr)) { + return { pushed: false, reason: 'no-credentials', stderr } + } + if (/non-fast-forward|already exists on the remote|rejected/i.test(stderr)) { + return { pushed: false, reason: 'conflict', stderr } + } + return { pushed: false, reason: 'unknown', stderr } + } +}