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:
parent
48b67444cc
commit
fbfaf905c8
2 changed files with 146 additions and 0 deletions
93
__tests__/git/push.test.ts
Normal file
93
__tests__/git/push.test.ts
Normal 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
53
src/git/push.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue