From 59386da43b283b36b1dd3aed9f6568cb28cfac99 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Fri, 15 May 2026 16:25:52 +0100 Subject: [PATCH 01/11] feat: seed mirror with commit-batched pushes for large upstreams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single `git push` of a large upstream's full history fails: GitHub enforces an HTTP request-duration limit and the monolithic pack POST exceeds it (`HTTP 408`, `the remote end hung up unexpectedly`). Reproduced seeding genkit-ai/genkit (~203 MB) into a private mirror. The Source Imports REST API (which could offload this server-side) was deprecated and returns an error since 2024-04-12, so venfork must transfer the pack itself. Seed the default branch in commit batches instead: walk `git rev-list --reverse ` and push in batches of VENFORK_SEED_CHUNK commits (default 1000, force, --no-thin) so each POST stays under GitHub's timeout, then push the real tip. History and SHAs stay identical to upstream (required for sync/stage/PRs). Each push retries a few times to ride out transient 408s. Small repos still take a single push (loop body skipped) — no regression. Pushes carry http.postBuffer + HTTP/1.1 + low-speed abort alongside the existing gh credential helper and netExec timeout/non-interactive env. Closes #44 --- src/commands.ts | 86 +++++++++++++++++++++++++++++++++++++----- tests/commands.test.ts | 39 ++++++++++++++++++- 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 82454b1..4a3c135 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -104,6 +104,82 @@ async function runNetOp( p.log.success(stopMsg); } +/** `-c` flags that make a large seed push survive: gh credentials over + * HTTPS, a big post buffer + HTTP/1.1 (avoids chunked-encoding issues on + * big POSTs), and a stalled-transfer abort. */ +const SEED_PUSH_CONFIG = [ + '-c', + 'credential.https://github.com.helper=!gh auth git-credential', + '-c', + 'http.postBuffer=524288000', + '-c', + 'http.version=HTTP/1.1', + '-c', + 'http.lowSpeedLimit=1000', + '-c', + 'http.lowSpeedTime=60', +]; + +/** + * Seed a fresh private mirror from a local upstream clone. + * + * A single `git push` of a large repo's full history 408s — GitHub enforces + * a request-duration limit and the monolithic pack POST exceeds it. So push + * the default branch in commit batches (each POST stays small), then push the + * real tip. History/SHAs are identical to upstream, which `sync`/`stage`/PRs + * require. Each push is retried a few times to ride out transient 408s. + * + * Batch size is `VENFORK_SEED_CHUNK` commits (default 1000); small repos take + * a single push (loop body is skipped). + * + * @param tempDir Local upstream clone. + * @param httpsUrl HTTPS URL of the private mirror. + * @param branch Default branch to seed. + */ +async function seedMirrorInChunks( + tempDir: string, + httpsUrl: string, + branch: string +): Promise { + const chunk = Number(process.env.VENFORK_SEED_CHUNK ?? 1000); + + const revList = await $({ + cwd: tempDir, + })`git rev-list --reverse ${branch}`; + const commits = revList.stdout.split('\n').filter(Boolean); + const total = commits.length; + + const pushRef = async (src: string, label: string): Promise => { + const attempts = 4; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await runNetOp( + `Pushing ${label} to private mirror repository` + + (attempt > 1 ? ` (attempt ${attempt})` : ''), + `Pushed ${label}`, + () => + netExec( + tempDir + )`git ${SEED_PUSH_CONFIG} push --force --no-thin --progress ${httpsUrl} ${src}:refs/heads/${branch}` + ); + return; + } catch (err) { + if (attempt === attempts) throw err; + p.log.warn( + `Push of ${label} failed; retrying in 8s ` + + `(${attempt}/${attempts - 1})` + ); + await new Promise((resolve) => setTimeout(resolve, 8000)); + } + } + }; + + for (let i = chunk; i < total; i += chunk) { + await pushRef(commits[i - 1], `commits 1–${i}/${total}`); + } + await pushRef(branch, `${branch} (final)`); +} + /** * `p.confirm` wrapper that can return `true` immediately when * `VENFORK_NONINTERACTIVE=1` is set in the environment, but only for @@ -1027,15 +1103,7 @@ export async function setupCommand( } s.stop(`Default branch: ${defaultBranch}`); - const ghCredentialHelper = '!gh auth git-credential'; - await runNetOp( - `Pushing ${defaultBranch} to private mirror repository`, - 'Pushed to private mirror repository', - () => - netExec( - tempDir - )`git -c credential.https://github.com.helper=${ghCredentialHelper} -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=60 push --progress ${privateHttpsUrl} ${defaultBranch}:${defaultBranch}` - ); + await seedMirrorInChunks(tempDir, privateHttpsUrl, defaultBranch); } // Step 6: Local clone of the private mirror diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 4ef7e5b..495eb03 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -418,11 +418,48 @@ describe('setupCommand - execution tests', () => { expect(seedPush).toBeDefined(); expect(seedPush).toContain('credential.https://github.com.helper'); expect(seedPush).toContain('--progress'); - expect(seedPush).toContain('main:main'); + expect(seedPush).toContain('main:refs/heads/main'); // The hang fix: the seeding push must not use the raw SSH URL. expect(seedPush).not.toContain('git@github.com:'); }); + test('seeds large history in commit-batched pushes, then the real tip', async () => { + process.env.VENFORK_SEED_CHUNK = '2'; + mockResponses.set('git rev-list --reverse main', { + exitCode: 0, + stdout: 'aaa\nbbb\nccc\nddd\neee', + stderr: '', + }); + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } finally { + delete process.env.VENFORK_SEED_CHUNK; + } + + const seedPushes = execaCalls.filter( + (cmd) => + cmd.includes(' push ') && + cmd.includes('https://github.com/testuser/test-vendor.git') && + cmd.includes(':refs/heads/main') + ); + + // 5 commits, chunk 2 -> push at commit 2 (bbb), commit 4 (ddd), + // then the real branch tip. + expect(seedPushes.length).toBe(3); + expect(seedPushes.some((c) => c.includes('bbb:refs/heads/main'))).toBe( + true + ); + expect(seedPushes.some((c) => c.includes('ddd:refs/heads/main'))).toBe( + true + ); + expect(seedPushes.some((c) => c.includes('main:refs/heads/main'))).toBe( + true + ); + expect(seedPushes.every((c) => c.includes('--force'))).toBe(true); + }); + test('passes --progress to upstream and private mirror clones', async () => { try { await setupCommand('git@github.com:test/repo.git', 'test-vendor'); From d321997105a292de102737945f511de77bb6bf2a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Fri, 15 May 2026 16:38:34 +0100 Subject: [PATCH 02/11] fix: validate VENFORK_SEED_CHUNK and fail fast on seed-push timeout Review follow-ups on the chunked seed push: - VENFORK_SEED_CHUNK was used unvalidated: `0`/negative caused an infinite loop (pushing `commits[-1]` forever) and non-numeric values silently disabled chunking. Clamp to a positive integer, falling back to 1000 otherwise. - pushRef retried every error including runNetOp's hard-timeout GitError, so a genuine timeout could block for up to 4x GIT_NET_TIMEOUT. Only retry transient push failures (e.g. HTTP 408); rethrow GitError immediately. Adds a test asserting an invalid chunk value cannot infinite-loop. --- src/commands.ts | 8 ++++++-- tests/commands.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 4a3c135..272cfca 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -141,7 +141,9 @@ async function seedMirrorInChunks( httpsUrl: string, branch: string ): Promise { - const chunk = Number(process.env.VENFORK_SEED_CHUNK ?? 1000); + const rawChunk = Number(process.env.VENFORK_SEED_CHUNK ?? 1000); + // Guard against 0/negative (infinite loop) and NaN (chunking disabled). + const chunk = Number.isInteger(rawChunk) && rawChunk > 0 ? rawChunk : 1000; const revList = await $({ cwd: tempDir, @@ -164,7 +166,9 @@ async function seedMirrorInChunks( ); return; } catch (err) { - if (attempt === attempts) throw err; + // A hard timeout (runNetOp -> GitError) should fail fast; only + // transient push failures (e.g. HTTP 408) are worth retrying. + if (err instanceof GitError || attempt === attempts) throw err; p.log.warn( `Push of ${label} failed; retrying in 8s ` + `(${attempt}/${attempts - 1})` diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 495eb03..5c6a919 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -460,6 +460,32 @@ describe('setupCommand - execution tests', () => { expect(seedPushes.every((c) => c.includes('--force'))).toBe(true); }); + test('invalid VENFORK_SEED_CHUNK falls back to default (no infinite loop)', async () => { + process.env.VENFORK_SEED_CHUNK = '0'; + mockResponses.set('git rev-list --reverse main', { + exitCode: 0, + stdout: 'aaa\nbbb\nccc\nddd\neee', + stderr: '', + }); + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } finally { + delete process.env.VENFORK_SEED_CHUNK; + } + + // chunk=0 -> guarded to 1000 -> 5 commits skip the loop -> single tip push. + const seedPushes = execaCalls.filter( + (cmd) => + cmd.includes(' push ') && + cmd.includes('https://github.com/testuser/test-vendor.git') && + cmd.includes(':refs/heads/main') + ); + expect(seedPushes.length).toBe(1); + expect(seedPushes[0]).toContain('main:refs/heads/main'); + }); + test('passes --progress to upstream and private mirror clones', async () => { try { await setupCommand('git@github.com:test/repo.git', 'test-vendor'); From 986623ef05a62585db83fbfc38faf915ab05594a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Fri, 15 May 2026 16:56:44 +0100 Subject: [PATCH 03/11] fix: classify transient seed-push errors; clarify chunk fallback Address Copilot review on #45: - Retry only recognized transient push failures (HTTP 408, 5xx/gateway, dropped connection, "remote end hung up", RPC failed, ...). Permanent errors (bad credentials, missing access, repo not found, protected branch) and any unrecognized failure now fail fast instead of waiting through three backoffs. New isTransientPushError() does the classification from the push stderr/stdout. - Reword the VENFORK_SEED_CHUNK comment: an invalid/NaN value falls back to the default chunk size (still chunks >1000-commit repos); it does not disable chunking. - Retry backoff is now VENFORK_SEED_RETRY_MS (default 8000) so the retry path is testable without real delays. Tests: permanent error -> single push (no retry); transient error -> exactly 4 attempts. --- src/commands.ts | 78 ++++++++++++++++++++++++++++++++++++++---- tests/commands.test.ts | 53 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 272cfca..80d6e10 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -120,6 +120,67 @@ const SEED_PUSH_CONFIG = [ 'http.lowSpeedTime=60', ]; +/** + * Whether a failed seed push is worth retrying. Retry only recognized + * server-side/transport failures during a large transfer (timeouts, + * gateways, dropped connections). Auth/permission/not-found/protected-branch + * and any unrecognized failure fail fast — retrying those just delays a + * permanent setup error. + */ +function isTransientPushError(err: unknown): boolean { + if (err instanceof GitError) return false; // hard timeout + const e = err as { + stderr?: unknown; + stdout?: unknown; + shortMessage?: unknown; + message?: unknown; + }; + const text = [e.stderr, e.stdout, e.shortMessage, e.message] + .map((v) => (typeof v === 'string' ? v : '')) + .join('\n') + .toLowerCase(); + if (!text) return false; + + const permanent = [ + 'http 401', + 'http 403', + 'http 404', + 'authentication failed', + 'permission denied', + 'access denied', + 'repository not found', + 'could not read from remote repository', + 'protected branch', + 'pre-receive hook declined', + 'remote rejected', + 'remote: error: gh', + ]; + if (permanent.some((p) => text.includes(p))) return false; + + const transient = [ + 'http 408', + 'error: 408', + 'http 500', + 'http 502', + 'http 503', + 'http 504', + 'bad gateway', + 'gateway time', + 'service unavailable', + 'the remote end hung up', + 'unexpected disconnect', + 'rpc failed', + 'early eof', + 'connection reset', + 'connection timed out', + 'operation timed out', + 'failed to connect', + 'recv failure', + 'transfer closed', + ]; + return transient.some((p) => text.includes(p)); +} + /** * Seed a fresh private mirror from a local upstream clone. * @@ -142,7 +203,8 @@ async function seedMirrorInChunks( branch: string ): Promise { const rawChunk = Number(process.env.VENFORK_SEED_CHUNK ?? 1000); - // Guard against 0/negative (infinite loop) and NaN (chunking disabled). + // Fall back to the default chunk size for invalid values: 0/negative + // (would infinite-loop) or NaN (non-numeric env). const chunk = Number.isInteger(rawChunk) && rawChunk > 0 ? rawChunk : 1000; const revList = await $({ @@ -151,6 +213,8 @@ async function seedMirrorInChunks( const commits = revList.stdout.split('\n').filter(Boolean); const total = commits.length; + const backoffMs = Number(process.env.VENFORK_SEED_RETRY_MS ?? 8000); + const pushRef = async (src: string, label: string): Promise => { const attempts = 4; for (let attempt = 1; attempt <= attempts; attempt++) { @@ -166,14 +230,14 @@ async function seedMirrorInChunks( ); return; } catch (err) { - // A hard timeout (runNetOp -> GitError) should fail fast; only - // transient push failures (e.g. HTTP 408) are worth retrying. - if (err instanceof GitError || attempt === attempts) throw err; + // Fail fast on the last attempt, hard timeouts, and permanent + // errors (bad creds, missing access, protected branch, …); only + // recognized transient failures (e.g. HTTP 408) are retried. + if (attempt === attempts || !isTransientPushError(err)) throw err; p.log.warn( - `Push of ${label} failed; retrying in 8s ` + - `(${attempt}/${attempts - 1})` + `Push of ${label} failed; retrying ` + `(${attempt}/${attempts - 1})` ); - await new Promise((resolve) => setTimeout(resolve, 8000)); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); } } }; diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 5c6a919..eeacd15 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -486,6 +486,59 @@ describe('setupCommand - execution tests', () => { expect(seedPushes[0]).toContain('main:refs/heads/main'); }); + test('does not retry permanent seed-push failures (fails fast)', async () => { + mockResponses.set('push --force --no-thin', () => + Promise.reject( + Object.assign(new Error('Command failed'), { + stderr: + 'remote: Permission to testuser/test-vendor.git denied.\n' + + 'fatal: unable to access: The requested URL returned error: HTTP 403', + exitCode: 128, + }) + ) + ); + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } + + const seedPushes = execaCalls.filter( + (cmd) => + cmd.includes('push --force --no-thin') && + cmd.includes('https://github.com/testuser/test-vendor.git') + ); + expect(seedPushes.length).toBe(1); // no retries for a permanent error + }); + + test('retries transient seed-push failures up to the attempt limit', async () => { + process.env.VENFORK_SEED_RETRY_MS = '0'; + mockResponses.set('push --force --no-thin', () => + Promise.reject( + Object.assign(new Error('Command failed'), { + stderr: + 'error: RPC failed; HTTP 408 curl 22\n' + + 'fatal: the remote end hung up unexpectedly', + exitCode: 128, + }) + ) + ); + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } finally { + delete process.env.VENFORK_SEED_RETRY_MS; + } + + const seedPushes = execaCalls.filter( + (cmd) => + cmd.includes('push --force --no-thin') && + cmd.includes('https://github.com/testuser/test-vendor.git') + ); + expect(seedPushes.length).toBe(4); // 1 + 3 retries + }); + test('passes --progress to upstream and private mirror clones', async () => { try { await setupCommand('git@github.com:test/repo.git', 'test-vendor'); From feb95ffd9b50657a81451a466282b3edf56a61a6 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 16 May 2026 12:17:18 +0100 Subject: [PATCH 04/11] fix: guard VENFORK_SEED_RETRY_MS against invalid values Fresh-review follow-up: backoffMs lacked the NaN/negative guard that chunk has. A non-numeric VENFORK_SEED_RETRY_MS made setTimeout receive NaN (treated as 0), turning retries into a backoff-less storm. Clamp to the 8000ms default for negative/NaN values, matching the chunk guard. --- src/commands.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands.ts b/src/commands.ts index 80d6e10..f77b9ad 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -213,7 +213,10 @@ async function seedMirrorInChunks( const commits = revList.stdout.split('\n').filter(Boolean); const total = commits.length; - const backoffMs = Number(process.env.VENFORK_SEED_RETRY_MS ?? 8000); + const rawBackoff = Number(process.env.VENFORK_SEED_RETRY_MS ?? 8000); + // Fall back to the default for invalid values (negative or NaN). + const backoffMs = + Number.isFinite(rawBackoff) && rawBackoff >= 0 ? rawBackoff : 8000; const pushRef = async (src: string, label: string): Promise => { const attempts = 4; From f3fc90fb527f9d5f3f1b6eda8412b81c2fcacc0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:43:14 +0000 Subject: [PATCH 05/11] fix: capture seed push output for retry classification Agent-Logs-Url: https://github.com/cabljac/venfork/sessions/226b745f-115b-4480-b2a8-f94f1f6347b0 Co-authored-by: cabljac <32874567+cabljac@users.noreply.github.com> --- src/commands.ts | 65 +++++++++++++++++++++++++++++++++++++----- tests/commands.test.ts | 23 +++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index f77b9ad..c5a5ee7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -66,12 +66,16 @@ const NET_ENV = { * * @param cwd Working directory for the command, or undefined for the default. */ -function netExec(cwd?: string) { +function netExec(cwd?: string, opts?: { captureOutput?: boolean }) { + const captureOutput = opts?.captureOutput === true; return $({ ...(cwd ? { cwd } : {}), env: NET_ENV, timeout: GIT_NET_TIMEOUT_MS, - stdio: ['ignore', 'inherit', 'inherit'], + stdio: captureOutput + ? ['ignore', 'pipe', 'pipe'] + : ['ignore', 'inherit', 'inherit'], + ...(captureOutput ? { buffer: false } : {}), }); } @@ -169,7 +173,6 @@ function isTransientPushError(err: unknown): boolean { 'service unavailable', 'the remote end hung up', 'unexpected disconnect', - 'rpc failed', 'early eof', 'connection reset', 'connection timed out', @@ -181,6 +184,57 @@ function isTransientPushError(err: unknown): boolean { return transient.some((p) => text.includes(p)); } +const PUSH_ERROR_TAIL_CHARS = 16_384; + +function appendTail(text: string, chunk: string): string { + const next = text + chunk; + return next.length > PUSH_ERROR_TAIL_CHARS + ? next.slice(-PUSH_ERROR_TAIL_CHARS) + : next; +} + +async function pushSeedRef( + tempDir: string, + httpsUrl: string, + src: string, + branch: string +): Promise { + const push = netExec(tempDir, { + captureOutput: true, + })`git ${SEED_PUSH_CONFIG} push --force --no-thin --progress ${httpsUrl} ${src}:refs/heads/${branch}`; + let stdoutTail = ''; + let stderrTail = ''; + push.stdout?.on('data', (chunk: string | Buffer) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString(); + process.stdout.write(text); + stdoutTail = appendTail(stdoutTail, text); + }); + push.stderr?.on('data', (chunk: string | Buffer) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString(); + process.stderr.write(text); + stderrTail = appendTail(stderrTail, text); + }); + + try { + await push; + } catch (err) { + const e = err as { stdout?: unknown; stderr?: unknown }; + if ( + (typeof e.stdout !== 'string' || e.stdout.length === 0) && + stdoutTail.length > 0 + ) { + e.stdout = stdoutTail; + } + if ( + (typeof e.stderr !== 'string' || e.stderr.length === 0) && + stderrTail.length > 0 + ) { + e.stderr = stderrTail; + } + throw err; + } +} + /** * Seed a fresh private mirror from a local upstream clone. * @@ -226,10 +280,7 @@ async function seedMirrorInChunks( `Pushing ${label} to private mirror repository` + (attempt > 1 ? ` (attempt ${attempt})` : ''), `Pushed ${label}`, - () => - netExec( - tempDir - )`git ${SEED_PUSH_CONFIG} push --force --no-thin --progress ${httpsUrl} ${src}:refs/heads/${branch}` + () => pushSeedRef(tempDir, httpsUrl, src, branch) ); return; } catch (err) { diff --git a/tests/commands.test.ts b/tests/commands.test.ts index eeacd15..f26edb0 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -539,6 +539,29 @@ describe('setupCommand - execution tests', () => { expect(seedPushes.length).toBe(4); // 1 + 3 retries }); + test('does not retry generic rpc-failed wrappers without transient status', async () => { + mockResponses.set('push --force --no-thin', () => + Promise.reject( + Object.assign(new Error('Command failed'), { + stderr: 'error: RPC failed; HTTP 413 curl 22', + exitCode: 128, + }) + ) + ); + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } + + const seedPushes = execaCalls.filter( + (cmd) => + cmd.includes('push --force --no-thin') && + cmd.includes('https://github.com/testuser/test-vendor.git') + ); + expect(seedPushes.length).toBe(1); // no retries for unrecognized wrappers + }); + test('passes --progress to upstream and private mirror clones', async () => { try { await setupCommand('git@github.com:test/repo.git', 'test-vendor'); From 324941400ef88cdafd948a331a17533dcb3e6a68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:44:43 +0000 Subject: [PATCH 06/11] chore: rename push output tail constant Agent-Logs-Url: https://github.com/cabljac/venfork/sessions/226b745f-115b-4480-b2a8-f94f1f6347b0 Co-authored-by: cabljac <32874567+cabljac@users.noreply.github.com> --- src/commands.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index c5a5ee7..6650209 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -184,12 +184,12 @@ function isTransientPushError(err: unknown): boolean { return transient.some((p) => text.includes(p)); } -const PUSH_ERROR_TAIL_CHARS = 16_384; +const PUSH_OUTPUT_TAIL_CHARS = 16_384; function appendTail(text: string, chunk: string): string { const next = text + chunk; - return next.length > PUSH_ERROR_TAIL_CHARS - ? next.slice(-PUSH_ERROR_TAIL_CHARS) + return next.length > PUSH_OUTPUT_TAIL_CHARS + ? next.slice(-PUSH_OUTPUT_TAIL_CHARS) : next; } From 56ada5ccaaa1a58cf7669ab60e1963ae82e16bde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:54:24 +0000 Subject: [PATCH 07/11] fix: use first-parent chunk tips and test streamed retry path Agent-Logs-Url: https://github.com/cabljac/venfork/sessions/c40592d8-164c-4312-912a-4ffe81dc24e5 Co-authored-by: cabljac <32874567+cabljac@users.noreply.github.com> --- src/commands.ts | 4 +++- tests/commands.test.ts | 52 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 6650209..668035e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -261,9 +261,11 @@ async function seedMirrorInChunks( // (would infinite-loop) or NaN (non-numeric env). const chunk = Number.isInteger(rawChunk) && rawChunk > 0 ? rawChunk : 1000; + // Select chunk tips along the branch's first-parent ancestry so each pushed + // tip is guaranteed to descend from the previous one. const revList = await $({ cwd: tempDir, - })`git rev-list --reverse ${branch}`; + })`git rev-list --first-parent --reverse ${branch}`; const commits = revList.stdout.split('\n').filter(Boolean); const total = commits.length; diff --git a/tests/commands.test.ts b/tests/commands.test.ts index f26edb0..c0f484f 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { PassThrough } from 'node:stream'; /** * Command tests with execution-based mocking @@ -17,7 +18,7 @@ interface WriteFileCall { type SignalHandler = () => void | Promise; type MockResponse = | { exitCode: number; stdout: string; stderr: string } - | ((command: string) => Promise); + | ((command: string) => unknown); // Track calls to our mocks const execaCalls: string[] = []; @@ -150,6 +151,22 @@ function getMockExecaResponse(command: string) { return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); } +function streamRejectedPush(stderr: string): Promise & { + stdout: PassThrough; + stderr: PassThrough; +} { + const stdout = new PassThrough(); + const stderrStream = new PassThrough(); + const promise = new Promise((_resolve, reject) => { + queueMicrotask(() => { + stderrStream.write(stderr); + stderrStream.end(); + reject(Object.assign(new Error('Command failed'), { exitCode: 128 })); + }); + }); + return Object.assign(promise, { stdout, stderr: stderrStream }); +} + // Mock fs.rm and fs.access BEFORE any imports mock.module('node:fs/promises', () => ({ mkdtemp: mock((prefix: string) => { @@ -425,7 +442,7 @@ describe('setupCommand - execution tests', () => { test('seeds large history in commit-batched pushes, then the real tip', async () => { process.env.VENFORK_SEED_CHUNK = '2'; - mockResponses.set('git rev-list --reverse main', { + mockResponses.set('git rev-list --first-parent --reverse main', { exitCode: 0, stdout: 'aaa\nbbb\nccc\nddd\neee', stderr: '', @@ -458,11 +475,16 @@ describe('setupCommand - execution tests', () => { true ); expect(seedPushes.every((c) => c.includes('--force'))).toBe(true); + expect( + execaCalls.some((c) => + c.includes('git rev-list --first-parent --reverse main') + ) + ).toBe(true); }); test('invalid VENFORK_SEED_CHUNK falls back to default (no infinite loop)', async () => { process.env.VENFORK_SEED_CHUNK = '0'; - mockResponses.set('git rev-list --reverse main', { + mockResponses.set('git rev-list --first-parent --reverse main', { exitCode: 0, stdout: 'aaa\nbbb\nccc\nddd\neee', stderr: '', @@ -539,6 +561,30 @@ describe('setupCommand - execution tests', () => { expect(seedPushes.length).toBe(4); // 1 + 3 retries }); + test('retries transient seed-push failures from streamed stderr when error has no buffered output', async () => { + process.env.VENFORK_SEED_RETRY_MS = '0'; + mockResponses.set('push --force --no-thin', () => + streamRejectedPush( + 'error: RPC failed; HTTP 408 curl 22\n' + + 'fatal: the remote end hung up unexpectedly' + ) + ); + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } finally { + delete process.env.VENFORK_SEED_RETRY_MS; + } + + const seedPushes = execaCalls.filter( + (cmd) => + cmd.includes('push --force --no-thin') && + cmd.includes('https://github.com/testuser/test-vendor.git') + ); + expect(seedPushes.length).toBe(4); // 1 + 3 retries from streamed stderr tail + }); + test('does not retry generic rpc-failed wrappers without transient status', async () => { mockResponses.set('push --force --no-thin', () => Promise.reject( From 55fe618cbd41a105937bfb155686d1cbc316430c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:55:57 +0000 Subject: [PATCH 08/11] chore: tighten mock response typing and docs Agent-Logs-Url: https://github.com/cabljac/venfork/sessions/c40592d8-164c-4312-912a-4ffe81dc24e5 Co-authored-by: cabljac <32874567+cabljac@users.noreply.github.com> --- tests/commands.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/commands.test.ts b/tests/commands.test.ts index c0f484f..f7cfc76 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -18,7 +18,7 @@ interface WriteFileCall { type SignalHandler = () => void | Promise; type MockResponse = | { exitCode: number; stdout: string; stderr: string } - | ((command: string) => unknown); + | ((command: string) => Promise); // Track calls to our mocks const execaCalls: string[] = []; @@ -151,6 +151,11 @@ function getMockExecaResponse(command: string) { return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); } +/** + * Simulate execa with `buffer: false`: push fails with no buffered + * stdout/stderr on the error object, while stderr is only available via the + * live stream attached to the returned promise. + */ function streamRejectedPush(stderr: string): Promise & { stdout: PassThrough; stderr: PassThrough; From d30a831d5a02f00f169db1a8e49f2f6e2b2daad0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:57:16 +0000 Subject: [PATCH 09/11] chore: refine streamed retry test implementation Agent-Logs-Url: https://github.com/cabljac/venfork/sessions/c40592d8-164c-4312-912a-4ffe81dc24e5 Co-authored-by: cabljac <32874567+cabljac@users.noreply.github.com> --- tests/commands.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/commands.test.ts b/tests/commands.test.ts index f7cfc76..171149b 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -162,13 +162,12 @@ function streamRejectedPush(stderr: string): Promise & { } { const stdout = new PassThrough(); const stderrStream = new PassThrough(); - const promise = new Promise((_resolve, reject) => { - queueMicrotask(() => { - stderrStream.write(stderr); - stderrStream.end(); - reject(Object.assign(new Error('Command failed'), { exitCode: 128 })); - }); - }); + const promise = (async (): Promise => { + await Promise.resolve(); + stderrStream.write(stderr); + stderrStream.end(); + throw Object.assign(new Error('Command failed'), { exitCode: 128 }); + })(); return Object.assign(promise, { stdout, stderr: stderrStream }); } @@ -563,7 +562,7 @@ describe('setupCommand - execution tests', () => { cmd.includes('push --force --no-thin') && cmd.includes('https://github.com/testuser/test-vendor.git') ); - expect(seedPushes.length).toBe(4); // 1 + 3 retries + expect(seedPushes.length).toBe(4); // 1 + 3 retries from transient stderr }); test('retries transient seed-push failures from streamed stderr when error has no buffered output', async () => { From 34da799f9fa40aa3a38c67d20168e1537a5b1d44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:58:33 +0000 Subject: [PATCH 10/11] test: stabilize streamed stderr retry mock timing Agent-Logs-Url: https://github.com/cabljac/venfork/sessions/c40592d8-164c-4312-912a-4ffe81dc24e5 Co-authored-by: cabljac <32874567+cabljac@users.noreply.github.com> --- tests/commands.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 171149b..7e66137 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -162,8 +162,9 @@ function streamRejectedPush(stderr: string): Promise & { } { const stdout = new PassThrough(); const stderrStream = new PassThrough(); + stdout.end(); const promise = (async (): Promise => { - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); stderrStream.write(stderr); stderrStream.end(); throw Object.assign(new Error('Command failed'), { exitCode: 128 }); From b4392e4857f3bbeff248edcbb4474a3162779335 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 18 May 2026 15:00:26 +0100 Subject: [PATCH 11/11] fix: treat empty VENFORK_SEED_RETRY_MS as unset Number('') === 0, so an empty env value passed the finite/>=0 guard and silently disabled retry backoff. Now empty/whitespace-only is treated as unset (default 8000ms); explicit '0' stays valid. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands.ts b/src/commands.ts index 668035e..67e2966 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -269,7 +269,12 @@ async function seedMirrorInChunks( const commits = revList.stdout.split('\n').filter(Boolean); const total = commits.length; - const rawBackoff = Number(process.env.VENFORK_SEED_RETRY_MS ?? 8000); + // Treat unset or empty/whitespace-only env as "use default" so an empty + // string doesn't coerce to 0 (Number('') === 0) and silently disable + // backoff. An explicit '0' stays valid (0ms backoff). + const rawRetryMs = process.env.VENFORK_SEED_RETRY_MS; + const rawBackoff = + rawRetryMs != null && rawRetryMs.trim() !== '' ? Number(rawRetryMs) : 8000; // Fall back to the default for invalid values (negative or NaN). const backoffMs = Number.isFinite(rawBackoff) && rawBackoff >= 0 ? rawBackoff : 8000;