From 96089e80d1c8d50929248a7c6d2a19a96ec40442 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Fri, 15 May 2026 14:58:57 +0100 Subject: [PATCH] fix: seed private mirror over gh-authenticated HTTPS to stop setup hang `venfork setup` hung forever at "Pushing to private mirror repository". The upstream clone uses `gh repo clone` (HTTPS + gh token), but the seeding push forced a raw SSH URL (git@github.com:...). When SSH is not usable (no key, passphrase-protected key, or unknown host key), ssh prints an interactive prompt that is invisible behind the @clack spinner, with no timeout and no progress, so setup hangs indefinitely. - Add `netExec`: execa `$` bound with stdin ignored (credential/host-key prompts fail fast instead of hanging), a hard timeout (VENFORK_GIT_TIMEOUT, default 10m), non-interactive env (GIT_TERMINAL_PROMPT=0, GCM_INTERACTIVE=never, GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=15"), and inherited stderr for live git progress. - Add `runNetOp`: wraps a network op with start/stop messages and turns a timeout into a clear GitError instead of an indefinite hang. - Seed the new mirror over gh-authenticated HTTPS using gh as a per-invocation git credential helper, with http.lowSpeedLimit/Time so a stalled transfer aborts in 60s. No SSH dependency, no global gitconfig mutation, no token in argv. Saved remote URLs are unchanged. - Route both `gh repo clone` calls through `runNetOp` with --progress. Tests: extend the @clack mock with log.success/step; assert the seeding push uses HTTPS + gh credential helper + --progress and never the raw SSH URL, and that both clones pass --progress. --- src/commands.ts | 94 +++++++++++++++++++++++++++++++++++++----- tests/commands.test.ts | 42 ++++++++++++++++++- 2 files changed, 125 insertions(+), 11 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 95e418a..82454b1 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -15,6 +15,7 @@ import { import { AuthenticationError, BranchNotFoundError, + GitError, NotInRepositoryError, RemoteNotFoundError, } from './errors.js'; @@ -46,6 +47,63 @@ async function pathExists(filePath: string): Promise { } } +/** Hard cap for a single network git/gh op. Override with VENFORK_GIT_TIMEOUT + * (ms) for very large upstream repos. Defaults to 10 minutes. */ +const GIT_NET_TIMEOUT_MS = Number(process.env.VENFORK_GIT_TIMEOUT ?? 600_000); + +/** Env applied to every network git/gh op so a misconfigured credential or + * SSH path fails fast instead of blocking on an invisible prompt. */ +const NET_ENV = { + GIT_TERMINAL_PROMPT: '0', + GCM_INTERACTIVE: 'never', + GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o ConnectTimeout=15', +}; + +/** + * An execa `$` bound for a heavy network git/gh op: no stdin (any + * credential/host-key prompt fails fast instead of hanging on an invisible + * prompt), a hard timeout, and live progress on stderr. + * + * @param cwd Working directory for the command, or undefined for the default. + */ +function netExec(cwd?: string) { + return $({ + ...(cwd ? { cwd } : {}), + env: NET_ENV, + timeout: GIT_NET_TIMEOUT_MS, + stdio: ['ignore', 'inherit', 'inherit'], + }); +} + +/** + * Run a long-running network operation (built with {@link netExec}) with a + * start/stop message and a clear, fast failure when it times out. + * + * @param startMsg Message shown before the op starts. + * @param stopMsg Message shown when the op completes. + * @param op Performs the command, returning the execa promise. + */ +async function runNetOp( + startMsg: string, + stopMsg: string, + op: () => Promise +): Promise { + p.log.info(`${startMsg}…`); + try { + await op(); + } catch (err) { + if ((err as { timedOut?: boolean })?.timedOut) { + throw new GitError( + `${startMsg} timed out after ${GIT_NET_TIMEOUT_MS / 1000}s. ` + + 'Check network and GitHub auth, or raise VENFORK_GIT_TIMEOUT.', + startMsg + ); + } + throw err; + } + p.log.success(stopMsg); +} + /** * `p.confirm` wrapper that can return `true` immediately when * `VENFORK_NONINTERACTIVE=1` is set in the environment, but only for @@ -861,6 +919,10 @@ export async function setupCommand( ? `${organization}/${config.privateMirrorName}` : `${owner}/${config.privateMirrorName}`; const privateCloneUrl = `git@github.com:${owner}/${config.privateMirrorName}.git`; + // Transient URL used only to seed the new mirror. HTTPS + gh's git + // credential helper keeps this consistent with the gh-based clones and + // avoids depending on the user's SSH setup (the cause of setup hangs). + const privateHttpsUrl = `https://github.com/${owner}/${config.privateMirrorName}.git`; const publicForkUrl = noPublic ? undefined : `git@github.com:${owner}/${publicForkName}.git`; @@ -941,9 +1003,12 @@ export async function setupCommand( // Steps 3–5: Seed a brand-new private mirror from upstream if (needsInitialPopulate) { - s.start('Cloning upstream repository'); - await $`gh repo clone ${upstreamRepoPath} ${tempDir}`; - s.stop('Upstream cloned'); + await runNetOp( + 'Cloning upstream repository', + 'Upstream cloned', + () => + netExec()`gh repo clone ${upstreamRepoPath} ${tempDir} -- --progress` + ); s.start('Detecting default branch'); const result = await $({ @@ -962,11 +1027,15 @@ export async function setupCommand( } s.stop(`Default branch: ${defaultBranch}`); - s.start(`Pushing ${defaultBranch} to private mirror repository`); - await $({ - cwd: tempDir, - })`git push ${privateCloneUrl} ${defaultBranch}:${defaultBranch}`; - s.stop('Pushed to private mirror repository'); + 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}` + ); } // Step 6: Local clone of the private mirror @@ -1000,8 +1069,13 @@ export async function setupCommand( } s.stop('Using existing local clone'); } else { - await $`gh repo clone ${privateMirrorGhPath} ${repoDir}`; - s.stop('Private mirror repository cloned'); + s.stop('Preparing local private mirror clone'); + await runNetOp( + 'Cloning private mirror repository', + 'Private mirror repository cloned', + () => + netExec()`gh repo clone ${privateMirrorGhPath} ${repoDir} -- --progress` + ); } // Step 7: Configure remotes diff --git a/tests/commands.test.ts b/tests/commands.test.ts index d590c26..4ef7e5b 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -185,7 +185,13 @@ mock.module('@clack/prompts', () => ({ }), outro: mock(() => {}), cancel: mock(() => {}), - log: { error: mock(() => {}), warn: mock(() => {}), info: mock(() => {}) }, + log: { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + success: mock(() => {}), + step: mock(() => {}), + }, group: mock(() => Promise.resolve({})), text: mock(() => Promise.resolve('')), confirm: mock(() => Promise.resolve(confirmResponse)), // Use dynamic confirmResponse @@ -395,6 +401,40 @@ describe('setupCommand - execution tests', () => { ) ).toBe(true); }); + + test('seeds the private mirror over gh-authenticated HTTPS, not SSH', async () => { + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } + + const seedPush = execaCalls.find( + (cmd) => + cmd.includes(' push ') && + cmd.includes('https://github.com/testuser/test-vendor.git') + ); + + expect(seedPush).toBeDefined(); + expect(seedPush).toContain('credential.https://github.com.helper'); + expect(seedPush).toContain('--progress'); + expect(seedPush).toContain('main:main'); + // The hang fix: the seeding push must not use the raw SSH URL. + expect(seedPush).not.toContain('git@github.com:'); + }); + + test('passes --progress to upstream and private mirror clones', async () => { + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } + + const cloneCalls = execaCalls.filter((c) => c.includes('gh repo clone')); + + expect(cloneCalls.length).toBeGreaterThanOrEqual(2); + expect(cloneCalls.every((c) => c.includes('-- --progress'))).toBe(true); + }); }); describe('setupCommand - idempotent recovery', () => {