feat: add pushBranchForJob helper (src/git/push.ts)

Runs git push -u origin <branch> in the worktree. Detects no-changes
(HEAD = origin/main) before pushing. Classifies push failures into
no-credentials, conflict, or unknown via stderr pattern matching.
5 unit tests covering all paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-01 11:57:14 +02:00
parent 48b67444cc
commit fbfaf905c8
2 changed files with 146 additions and 0 deletions

View file

@ -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<typeof vi.fn>
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 <branch>
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' })
})
})

53
src/git/push.ts Normal file
View file

@ -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<PushResult> {
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 }
}
}