diff --git a/.agents/skills/verify-venfork/references/features/clone.md b/.agents/skills/verify-venfork/references/features/clone.md index e365833..462471c 100644 --- a/.agents/skills/verify-venfork/references/features/clone.md +++ b/.agents/skills/verify-venfork/references/features/clone.md @@ -15,16 +15,16 @@ tests: - Needs gh auth first (`AuthenticationError`). - Refuses an empty or invalid repo ("Invalid vendor repository") and an existing `./` directory ("Directory '' already exists."). Both exit 1. -- `gh repo clone `, then reads `venfork-config` from the mirror. When it exists it is authoritative: `--no-public` on a standard config and a different `--upstream` are refused. -- Legacy mirror without a config: guesses the public fork by stripping `-private` (`gh repo view`), then the upstream from the fork's parent. Each failed guess prompts for a URL. `--no-public` skips the fork; `--upstream` skips the parent lookup. +- `gh repo clone `, then reads `venfork-config` from the mirror. When it exists it is authoritative: `--no-public` on a standard config, `--public-fork` on a no-public config, and an `--upstream` or `--public-fork` naming another repo are refused (exit 1). A flag naming the recorded repo in another URL form is accepted. +- Legacy mirror without a config: guesses the public fork by stripping `-private` (`gh repo view`), then the upstream from the fork's parent. Each failed guess prompts for a URL; an empty answer is rejected by `validate`, and the fork answer must be a GitHub URL or `owner/repo`. `--no-public` skips the fork; `--public-fork ` (URL or `owner/repo`) skips the fork guess and its prompt, and the parent lookup then runs on that fork; `--upstream` skips the parent lookup. `--public-fork` with `--no-public` is refused, and an invalid `--public-fork` exits 1 before cloning. - Adds `public` (standard mode) and `upstream` with push URL `DISABLE`. URLs follow `gh config get git_protocol`. Runs `gh repo set-default`. -- A cancelled URL prompt exits 1, not 130. A prompt that hits stdin EOF exits 130. -- Never prompts without a TTY on stdin, with `CI` set, with `VENFORK_NONINTERACTIVE=1` or with `--no-input`: a URL prompt exits 2 (`MissingInputError`) naming `--upstream ` (or `--no-public --upstream ` for the public fork URL). `--yes` is refused: clone has no confirm. Any failure or cancel after the clone removes the cloned directory, so the rerun with the named flag works. +- A cancelled URL prompt exits 130 (`CommandExitError(130)`), and so does a prompt that hits stdin EOF. +- Never prompts without a TTY on stdin, with `CI` set, with `VENFORK_NONINTERACTIVE=1` or with `--no-input`: a URL prompt exits 2 (`MissingInputError`) naming `--public-fork ` for the public fork URL and `--upstream ` for the upstream URL. `--yes` is refused: clone has no confirm. Any failure or cancel after the clone removes the cloned directory, so the rerun with the named flag works. - It never writes `venfork-config` and never pushes. ## How a user reaches it -`venfork clone [--no-public] [--upstream ] [--no-input]`. `` is the private mirror as a GitHub URL or `owner/repo`. +`venfork clone [--no-public] [--upstream ] [--public-fork ] [--no-input]`. `` is the private mirror as a GitHub URL or `owner/repo`. ## How to drive it @@ -39,7 +39,8 @@ venfork clone a/b-private &1 | grep "gh stub" # exit ``` - Already current: a second clone into the same directory is refused, which is the idempotence guard. -- Cancel at a URL prompt, the exit-2 URL prompts and the concurrent lease path are not reachable on the fixture: the first gh call fails first. `tests/unit/commands.test.ts` pins the `--no-input` exit-2 path. +- The fixture's gh stub fails `gh repo clone`, so the legacy paths need a gh that clones the fixture origin. `tests/integration/cli.test.ts` ("clone on a legacy mirror without venfork-config") deletes origin's `venfork-config` branch and uses such a stub: `clone acme/legacy-private --public-fork other-org/legacy` exits 0 with `public` = `git@github.com:other-org/legacy.git` and `upstream` = the fixture upstream (push `DISABLE`), and the same clone without `--public-fork` and without a TTY exits 2 with "Pass --public-fork .". +- Cancel at a URL prompt needs a TTY; `tests/unit/commands.test.ts` pins the exit-130 paths and the `validate` rules. ## What proves it diff --git a/.agents/skills/verify-venfork/references/features/setup.md b/.agents/skills/verify-venfork/references/features/setup.md index ab7191b..e3e4548 100644 --- a/.agents/skills/verify-venfork/references/features/setup.md +++ b/.agents/skills/verify-venfork/references/features/setup.md @@ -14,7 +14,7 @@ tests: ## What exists - Needs gh auth first (`AuthenticationError`: "GitHub CLI is not authenticated. Please run: gh auth login"). -- Prompts for the upstream when it is missing and for the mirror name when it is missing (default `-private`). With no `--org` and no `VENFORK_ORG`, asks "Continue with personal account?"; `--yes` answers it. +- Prompts for the upstream when it is missing and for the mirror name when it is missing (default `-private`). With no `--org` and no `VENFORK_ORG`, asks "Continue with personal account?"; `--yes` answers it. `--org` set to your own gh username (any case) is consent: the repos go under the personal account (no `gh repo fork --org`) and the confirm is skipped, with or without a TTY. `VENFORK_ORG` set to your username is not consent: the repos still go under the personal account, but the confirm runs (exit 2 naming `--yes` without a TTY). - Never prompts without a TTY on stdin, with `CI` set (not `false`/`0`), with `VENFORK_NONINTERACTIVE=1` or with `--no-input`. Then a missing mirror name becomes `-private` (logged on stderr), a missing upstream exits 2 naming "the argument", and the personal-account confirm without `--yes` exits 2 naming `--yes` (`MissingInputError`). stdout stays empty. - Standard mode: `gh repo fork` (public fork), `gh repo create --private` (mirror), seeds a new mirror from a temp clone of upstream in chunks (`VENFORK_SEED_CHUNK`, `VENFORK_SEED_RETRY_MS`), `gh repo clone`s the mirror into `./`, `gh repo set-default`. - Writes the orphan `venfork-config` branch. Never overwrites one: an existing config that agrees is kept, one that disagrees is a `ConfigError` ("The existing venfork-config disagrees with this setup"). diff --git a/README.md b/README.md index 2702d2c..ed91295 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Creates the complete vendor workflow setup: - `--org ` - (Optional) Create repos under organization instead of personal account - `--fork-name ` - (Optional) Public fork repo name under that org/user (default: same basename as upstream) - `--no-public` - (Optional) Skip the public-fork hop; stage pushes directly to upstream -- `--yes`, `-y` - (Optional) Answer the personal-account confirm with yes +- `--yes`, `-y` - (Optional) Answer the personal-account confirm with yes. `--org ` (compared with your gh login ignoring case) also counts as consent: the repos go under your personal account with no confirm. `VENFORK_ORG` set to your username does not: it is ambient, so the confirm still runs (and needs `--yes` without a terminal). - `--no-input` - (Optional) Never prompt. A missing `name` defaults to `{repo}-private` (the choice is logged on stderr). A missing `upstream`, or a personal account without `--yes`, exits 2. See [Non-interactive use](#non-interactive-use). **Examples:** @@ -174,7 +174,7 @@ venfork setup my-org/foo my-foo-private --org my-org --no-public Pure failures (for example name taken by a **different** repo) still abort setup. If recovery sync stops due to divergent default branches, fix or move those commits, then run **`venfork sync`** again from inside the private mirror. -### `venfork clone [--no-public] [--upstream ] [--no-input]` +### `venfork clone [--no-public] [--upstream ] [--public-fork ] [--no-input]` Clone an existing vendor setup and automatically configure all remotes. @@ -186,12 +186,13 @@ Clone an existing vendor setup and automatically configure all remotes. - Falls back to auto-detection when the config branch is absent: - Public fork (by stripping `-private` suffix) - Upstream repository (from public fork's parent) -- Configures the appropriate remotes (origin/upstream — plus public in standard mode) +- Configures the appropriate remotes (origin/upstream, plus public in standard mode) - Disables push to upstream (read-only) -**Options** (only meaningful for **legacy mirrors** without a `venfork-config` branch — when config is present it's authoritative and the flags will error on conflict): +**Options** (only meaningful for **legacy mirrors** without a `venfork-config` branch. When config is present it is authoritative: a flag that names the recorded value is accepted, and a flag that contradicts it is an error): - `--no-public` - Declare a no-public layout (origin + upstream only). Use when the original setup was created with `venfork setup --no-public` but the config branch is missing. - `--upstream ` - Provide the upstream URL explicitly. Useful with `--no-public` (no public fork to derive it from) or to skip the auto-detect prompt. +- `--public-fork ` - Provide the public fork (GitHub URL or `owner/repo`) explicitly. Skips the public-fork detection and prompt; upstream is still read from the fork's parent unless `--upstream` is given. Cannot be combined with `--no-public`. `--no-input` never prompts: a URL that clone would ask for exits 2 and names the flag that supplies it. `clone` has no confirm, so it does not take `--yes`. A clone that fails or is cancelled after the repository was cloned removes the cloned directory, so you can rerun it with the named flag. @@ -220,7 +221,8 @@ venfork clone git@github.com:acme-corp/awesome-project-private.git **Interactive prompts:** - If public fork cannot be auto-detected, you'll be prompted for the URL - If upstream cannot be auto-detected (no parent), you'll be prompted for the URL -- Without a terminal (see [Non-interactive use](#non-interactive-use)) these prompts exit 2 instead: pass `--upstream `, or `--no-public --upstream ` when the mirror has no public fork +- An empty answer is rejected and the prompt asks again; cancelling a prompt (Ctrl-C) exits 130 +- Without a terminal (see [Non-interactive use](#non-interactive-use)) these prompts exit 2 instead and name the flag: `--public-fork ` for the public fork, `--upstream ` for upstream. Use `--no-public --upstream ` when the mirror has no public fork ### `venfork sync [branch] [--report-issues]` diff --git a/docs/agents/gotchas.md b/docs/agents/gotchas.md index d32827f..9321d14 100644 --- a/docs/agents/gotchas.md +++ b/docs/agents/gotchas.md @@ -28,3 +28,6 @@ After `bun run check`, re-read a file before editing by string match. ## Subagents must not run gh stack Stack metadata is shared in `.git` across worktrees. Only the main checkout drives a stack. + +## execa `$` does not parse quotes +In a `$` template, literal text is split on spaces and quotes are kept: `--jq '.parent.url'` passes `'.parent.url'` with the quotes to gh. Interpolate a value (`--jq ${expr}`) or write an unquoted single word. diff --git a/src/clone-args.ts b/src/clone-args.ts index 85e2f4d..c8c3a48 100644 --- a/src/clone-args.ts +++ b/src/clone-args.ts @@ -10,24 +10,26 @@ export type ParsedCloneArgs = { vendorRepoUrl?: string; noPublic: boolean; upstreamUrl?: string; + publicForkUrl?: string; /** `--no-input`; clone has no confirm, so `yes` is always false. */ prompt: PromptContext; }; const USAGE = - 'venfork clone [--no-public] [--upstream ] [--no-input]'; + 'venfork clone [--no-public] [--upstream ] [--public-fork ] [--no-input]'; /** * Parse `venfork clone ...` argv after the `clone` token. * - * `--no-public` and `--upstream` are escape hatches for **legacy mirrors** + * `--no-public`, `--upstream` and `--public-fork` are escape hatches for **legacy mirrors** * that pre-date the `venfork-config` branch. When the config branch is * present, the layout is read from there and these flags are unnecessary - * (and will conflict with the recorded `mode` if set inconsistently). + * (clone refuses any that contradict the recorded layout). */ export function parseCloneCliArgs(args: string[]): ParsedCloneArgs { let noPublic = false; let upstreamUrl: string | undefined; + let publicForkUrl: string | undefined; const promptFlags = parsePromptFlags({ allowYes: false }); const positional = scanArgs(args, USAGE, (a, i) => { @@ -41,6 +43,11 @@ export function parseCloneCliArgs(args: string[]): ParsedCloneArgs { upstreamUrl = value; return consumed; } + if (a === '--public-fork' || a.startsWith('--public-fork=')) { + const { value, consumed } = consumeValue('--public-fork', args, i); + publicForkUrl = value; + return consumed; + } return undefined; }); if (positional.length > 1) throw unexpectedArgument(positional[1], USAGE); @@ -49,6 +56,7 @@ export function parseCloneCliArgs(args: string[]): ParsedCloneArgs { vendorRepoUrl: positional[0], noPublic, upstreamUrl: upstreamUrl?.trim() || undefined, + publicForkUrl: publicForkUrl?.trim() || undefined, prompt: promptFlags.prompt, }; } diff --git a/src/commands/clone.ts b/src/commands/clone.ts index a114edb..700cf91 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -25,15 +25,47 @@ async function removePartialClone(dir: string): Promise { } } +/** True when two remote URLs name the same repository. */ +function sameRepo(a: string, b: string): boolean { + const pathA = parseRepoPath(a).toLowerCase(); + const pathB = parseRepoPath(b).toLowerCase(); + if (pathA && pathB) return pathA === pathB; + return a.trim() === b.trim(); +} + +/** Canonical URL of a GitHub public fork given as a URL or owner/repo, or ''. */ +function publicForkInput(value: string): string { + const url = normalizeGitHubRepoInput(value); + return parseRepoPath(url) ? url : ''; +} + +function validatePublicFork(value: string): string | undefined { + if (!value.trim()) return 'Public fork URL is required'; + if (!publicForkInput(value)) { + return 'Use a GitHub URL or owner/repo (e.g. acme/project)'; + } + return undefined; +} + +function validateUpstream(value: string): string | undefined { + return value.trim() ? undefined : 'Upstream URL is required'; +} + /** * Clone command: Clone vendor repository and configure all remotes. A run * that fails or is cancelled after cloning removes the clone. + * + * @param options.noPublic - Legacy mirrors only: the mirror has no public fork. + * @param options.upstreamUrl - Legacy mirrors only: upstream URL, skips detection. + * @param options.publicForkUrl - Legacy mirrors only: public fork URL or owner/repo, skips detection. + * @param options.prompt - `--no-input`: never prompt. */ export async function cloneCommand( vendorRepoUrl?: string, options: { noPublic?: boolean; upstreamUrl?: string; + publicForkUrl?: string; /** `--no-input`. */ prompt?: PromptContext; } = {} @@ -56,6 +88,22 @@ export async function cloneCommand( throw new CommandExitError(1); } + if (options.noPublic && options.publicForkUrl) { + throw new Error( + '--public-fork cannot be combined with --no-public: --no-public means the mirror has no public fork.' + ); + } + const publicForkFlag = options.publicForkUrl + ? publicForkInput(options.publicForkUrl) + : undefined; + if (publicForkFlag === '') { + p.log.error( + 'Invalid --public-fork. Use a GitHub URL or owner/repo (e.g. acme/project).' + ); + p.outro('❌ Clone failed'); + throw new CommandExitError(1); + } + const s = p.spinner(); let clonedDir: string | undefined; @@ -103,7 +151,23 @@ export async function cloneCommand( 'Refusing to clone with --no-public: the venfork-config branch records mode=standard. To convert an existing setup, re-run `venfork setup` instead.' ); } - if (options.upstreamUrl && options.upstreamUrl !== config.upstreamUrl) { + if (publicForkFlag && configMode === 'no-public') { + throw new Error( + 'Refusing to clone with --public-fork: the venfork-config branch records mode=no-public.' + ); + } + if ( + publicForkFlag && + !sameRepo(publicForkFlag, config.publicForkUrl ?? '') + ) { + throw new Error( + `Refusing to override venfork-config: --public-fork='${options.publicForkUrl}' but config records publicForkUrl='${config.publicForkUrl ?? '(missing)'}'.` + ); + } + if ( + options.upstreamUrl && + !sameRepo(options.upstreamUrl, config.upstreamUrl) + ) { throw new Error( `Refusing to override venfork-config: --upstream='${options.upstreamUrl}' but config records upstreamUrl='${config.upstreamUrl}'.` ); @@ -132,8 +196,7 @@ export async function cloneCommand( ); } } else { - // No config branch (legacy mirror). Fall back to auto-detection, - // honoring `--no-public` / `--upstream` flags as the explicit override. + // No config branch (legacy mirror): flags override auto-detection. noPublic = options.noPublic === true; s.stop( noPublic @@ -153,59 +216,31 @@ export async function cloneCommand( 'Upstream URL? (no venfork-config branch was found, so it cannot be auto-detected)', flag: '--upstream ', placeholder: 'git@github.com:owner/repo.git', + validate: validateUpstream, }, options.prompt ); - if (isCancel(response) || !response.trim()) { + if (isCancel(response)) { p.outro('❌ Clone cancelled'); - throw new CommandExitError(1); + throw new CommandExitError(130); } upstreamUrl = response.trim(); } } else { - // Step 3a: Auto-detect public fork - s.start('Detecting public fork'); - - // Try to strip -private suffix - let publicRepoName = vendorRepoName; - if (vendorRepoName.endsWith('-private')) { - publicRepoName = vendorRepoName.replace(/-private$/, ''); - } - - // Verify public fork exists - try { - await $`gh repo view ${owner}/${publicRepoName}`; - publicForkUrl = `git@github.com:${owner}/${publicRepoName}.git`; - s.stop(`Found public fork: ${owner}/${publicRepoName}`); - } catch { - s.stop('Public fork not found'); - - p.log.warn('⚠️ Could not auto-detect public fork.'); - p.note( - `Tried: ${owner}/${publicRepoName}\n\nTip: if this mirror has no public fork, re-run with --no-public --upstream .`, - 'Detection Failed' - ); - - const response = await promptText( - { - message: 'Please provide the public fork URL:', - flag: '--no-public --upstream (when the mirror has no public fork)', - placeholder: 'git@github.com:owner/repo.git', - }, + if (publicForkFlag) { + publicForkUrl = publicForkFlag; + p.log.info(`Using --public-fork: ${parseRepoPath(publicForkUrl)}`); + } else { + publicForkUrl = await detectPublicFork( + owner, + vendorRepoName, + s, options.prompt ); - - if (isCancel(response)) { - p.outro('❌ Clone cancelled'); - throw new CommandExitError(1); - } - - publicForkUrl = response; - publicRepoName = parseRepoName(publicForkUrl); } + const publicRepoPath = parseRepoPath(publicForkUrl); - // Step 3b: Auto-detect upstream from public fork's parent — or use - // --upstream if the user supplied it explicitly. + // Upstream is the public fork's parent unless --upstream names it. s.start('Detecting upstream repository'); if (options.upstreamUrl) { @@ -214,7 +249,7 @@ export async function cloneCommand( } else { try { const result = - await $`gh repo view ${owner}/${publicRepoName} --json parent --jq '.parent.url'`; + await $`gh repo view ${publicRepoPath} --json parent --jq .parent.url`; upstreamUrl = result.stdout.trim(); if (!upstreamUrl || upstreamUrl === 'null') { @@ -233,16 +268,17 @@ export async function cloneCommand( message: 'Please provide the upstream URL:', flag: '--upstream ', placeholder: 'git@github.com:original/repo.git', + validate: validateUpstream, }, options.prompt ); if (isCancel(response)) { p.outro('❌ Clone cancelled'); - throw new CommandExitError(1); + throw new CommandExitError(130); } - upstreamUrl = response; + upstreamUrl = response.trim(); } } } @@ -303,3 +339,47 @@ export async function cloneCommand( throw new CommandExitError(1); } } + +/** + * Finds the public fork of a legacy mirror: `/` + * when it exists, else the answer to a prompt. + */ +async function detectPublicFork( + owner: string, + vendorRepoName: string, + s: ReturnType, + prompt: PromptContext | undefined +): Promise { + s.start('Detecting public fork'); + const publicRepoName = vendorRepoName.replace(/-private$/, ''); + + try { + await $`gh repo view ${owner}/${publicRepoName}`; + s.stop(`Found public fork: ${owner}/${publicRepoName}`); + return `git@github.com:${owner}/${publicRepoName}.git`; + } catch { + s.stop('Public fork not found'); + } + + p.log.warn('⚠️ Could not auto-detect public fork.'); + p.note( + `Tried: ${owner}/${publicRepoName}\n\nTip: pass --public-fork , or --no-public --upstream if this mirror has no public fork.`, + 'Detection Failed' + ); + + const response = await promptText( + { + message: 'Please provide the public fork URL:', + flag: '--public-fork ', + placeholder: 'git@github.com:owner/repo.git', + validate: validatePublicFork, + }, + prompt + ); + + if (isCancel(response)) { + p.outro('❌ Clone cancelled'); + throw new CommandExitError(130); + } + return publicForkInput(response); +} diff --git a/src/commands/help.ts b/src/commands/help.ts index c73c6e2..dcc9170 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -9,7 +9,8 @@ const COMMAND_HELP: ReadonlyArray = [ : GitHub HTTPS/SSH URL, or shorthand owner/repo (e.g. facebook/react) Options: - • --org Create repos under organization instead of user account + • --org Create repos under organization instead of user account. + Your own username keeps the personal account with no confirm • --fork-name Public fork repo name under owner (gh repo fork --fork-name). Use when upstream is already owner/repo so the fork needs a different name. • --no-public Skip the public fork hop entirely. Only origin + upstream are @@ -27,7 +28,7 @@ const COMMAND_HELP: ReadonlyArray = [ ], [ 'clone', - `venfork clone [--no-public] [--upstream ] [--no-input] + `venfork clone [--no-public] [--upstream ] [--public-fork ] [--no-input] Clone an existing vendor setup and configure remotes automatically : URL or owner/repo for the private mirror @@ -40,6 +41,8 @@ const COMMAND_HELP: ReadonlyArray = [ Options (only meaningful when venfork-config is absent): • --no-public Declare a no-public layout (origin + upstream only) • --upstream Provide the upstream URL explicitly (skips auto-detect/prompt) + • --public-fork Provide the public fork URL or owner/repo (skips auto-detect/prompt); + not with --no-public • --no-input Never prompt; a URL that would be asked for exits 2`, ], [ diff --git a/src/commands/setup.ts b/src/commands/setup.ts index c3b3118..102b4f0 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -112,6 +112,7 @@ async function assertExistingConfigAgrees( * @param publicForkRepoName - Optional GitHub repo name for the public fork under `owner` (see `gh repo fork --fork-name`). Defaults to the upstream repo basename. Use when the fork must differ (e.g. same org as upstream). * @param options.noPublic - Skip the public-fork hop entirely. Only `origin` (private mirror) and `upstream` are configured; `stage` later pushes branches directly to `upstream`. Mutually exclusive with `publicForkRepoName`. * @param options.prompt - `--yes` answers the personal-account confirm; `--no-input` never prompts. + * @param options.organizationFromEnv - `organization` came from `VENFORK_ORG`, so naming your own account there is not consent. * @param options.syncMirror - Quietly syncs the local clone at `cwd` when the fork or mirror already existed on GitHub. */ export async function setupCommand( @@ -123,6 +124,7 @@ export async function setupCommand( noPublic?: boolean; syncMirror: (cwd: string) => Promise; prompt?: PromptContext; + organizationFromEnv?: boolean; } ): Promise { const noPublic = options.noPublic === true; @@ -225,13 +227,17 @@ export async function setupCommand( const s = p.spinner(); const username = await getGitHubUsername(); - // If organization is the user's personal account, treat it as no organization - if (organization && organization === username) { + const ownAccount = + organization !== undefined && + username !== '' && + organization.toLowerCase() === username.toLowerCase(); + // Only `--org ` is consent; a VENFORK_ORG value is ambient. + const personalAccountChosen = ownAccount && !options.organizationFromEnv; + if (ownAccount) { organization = undefined; } - // If no organization is specified, confirm before using personal account - if (!organization) { + if (!organization && !personalAccountChosen) { p.log.warn('⚠️ No organization specified'); p.log.info( `Repos will be created under your personal account (username: ${username})` diff --git a/src/index.ts b/src/index.ts index 690bb61..9cbfc1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,7 @@ async function main(): Promise { noPublic: parsed.noPublic, syncMirror: setupSyncMirror(), prompt: parsed.prompt, + organizationFromEnv: parsed.organizationFromEnv, } ); break; @@ -96,6 +97,7 @@ async function main(): Promise { await cloneCommand(parsed.vendorRepoUrl, { noPublic: parsed.noPublic, upstreamUrl: parsed.upstreamUrl, + publicForkUrl: parsed.publicForkUrl, prompt: parsed.prompt, }); break; diff --git a/src/setup-args.ts b/src/setup-args.ts index 942c2f5..af8db67 100644 --- a/src/setup-args.ts +++ b/src/setup-args.ts @@ -10,6 +10,8 @@ export type ParsedSetupArgs = { upstreamUrl?: string; privateMirrorName?: string; organization?: string; + /** True when `organization` came from `VENFORK_ORG`, not `--org`. */ + organizationFromEnv?: boolean; publicForkRepoName?: string; noPublic: boolean; /** `--yes` / `--no-input`. */ @@ -59,6 +61,9 @@ export function parseSetupCliArgs(setupArgs: string[]): ParsedSetupArgs { upstreamUrl: positional[0], privateMirrorName: positional[1], organization: organization ?? process.env.VENFORK_ORG, + ...(organization === undefined && process.env.VENFORK_ORG !== undefined + ? { organizationFromEnv: true } + : {}), publicForkRepoName: trimmedForkName, noPublic, prompt: promptFlags.prompt, diff --git a/tests/expected-counts.json b/tests/expected-counts.json index 8af1bf3..7c86c87 100644 --- a/tests/expected-counts.json +++ b/tests/expected-counts.json @@ -1,4 +1,4 @@ { - "unit": 999, - "integration": 323 + "unit": 1021, + "integration": 325 } diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 6895599..3b98b66 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -228,6 +228,13 @@ async function hasBranch(repo: string, branch: string): Promise { return result.exitCode === 0; } +async function pathExists(file: string): Promise { + return stat(file).then( + () => true, + () => false + ); +} + describe('no TTY: fail fast instead of prompting', () => { test('setup without an upstream exits 2 naming the argument', async () => { const result = await runCliWithoutTty(tmp, ['setup', '--org', 'acme']); @@ -396,20 +403,16 @@ describe('no TTY: fail fast instead of prompting', () => { ); expect(refused.timedOut).toBe(false); expect(refused.exitCode).toBe(2); - expect(refused.stderr).toContain('--upstream '); - expect( - await stat(path.join(work, 'proj-private')).then( - () => true, - () => false - ) - ).toBe(false); + expect(refused.stderr).toContain('Pass --public-fork .'); + expect(await pathExists(path.join(work, 'proj-private'))).toBe(false); const cloned = await runCliWithoutTty( work, [ 'clone', 'acme/proj-private', - '--no-public', + '--public-fork', + 'acme/proj', '--upstream', 'git@github.com:client/proj.git', ], @@ -476,6 +479,99 @@ describe('no TTY: fail fast instead of prompting', () => { }); }); +describe('clone on a legacy mirror without venfork-config', () => { + /** + * Strips the fixture's config branch and returns a PATH whose gh clones + * the fixture origin, reports the fixture upstream as every fork's parent + * and finds no other repository. Each gh call is appended to `ghLog`. + */ + async function legacyMirror( + fx: MirrorFixture + ): Promise<{ path: string; ghLog: string }> { + await fx.git(fx.origin, 'branch', '-D', 'venfork-config'); + const bin = path.join(fx.root, 'gh-bin'); + const ghLog = path.join(fx.root, 'gh.log'); + await mkdir(bin); + await writeFile( + path.join(bin, 'gh'), + [ + '#!/bin/sh', + `echo "$*" >> '${ghLog}'`, + 'case "$1 $2" in', + ' "auth status"|"repo set-default") exit 0 ;;', + ' "config get") echo ssh; exit 0 ;;', + ' "repo clone")', + ' dir="$4"; shift 4; [ "$1" = "--" ] && shift', + ` exec git clone --quiet "$@" '${fx.origin}' "$dir" ;;`, + ' "repo view")', + ` case "$*" in *"--json parent"*) echo '${fx.upstream}'; exit 0 ;; esac ;;`, + 'esac', + 'exit 1', + '', + ].join('\n') + ); + await chmod(path.join(bin, 'gh'), 0o755); + return { path: `${bin}${path.delimiter}${process.env.PATH ?? ''}`, ghLog }; + } + + test('--public-fork wires public and upstream from that fork, with no prompt', async () => { + const fx = await createMirrorFixture(); + try { + const { path: ghPath, ghLog } = await legacyMirror(fx); + + const result = await runCliWithoutTty( + fx.root, + ['clone', 'acme/legacy-private', '--public-fork', 'other-org/legacy'], + { PATH: ghPath } + ); + + expect(result.timedOut).toBe(false); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + const clone = path.join(fx.root, 'legacy-private'); + expect(await fx.git(clone, 'remote', 'get-url', 'public')).toBe( + 'git@github.com:other-org/legacy.git' + ); + expect(await fx.git(clone, 'remote', 'get-url', 'upstream')).toBe( + fx.upstream + ); + expect( + await fx.git(clone, 'remote', 'get-url', '--push', 'upstream') + ).toBe('DISABLE'); + const calls = (await readFile(ghLog, 'utf8')).trim().split('\n'); + expect(calls.filter((c) => c.startsWith('repo view'))).toEqual([ + 'repo view other-org/legacy --json parent --jq .parent.url', + ]); + } finally { + await fx.cleanup(); + } + }); + + test('without --public-fork, an undetected fork exits 2 naming --public-fork ', async () => { + const fx = await createMirrorFixture(); + try { + const { path: ghPath } = await legacyMirror(fx); + + const result = await runCliWithoutTty( + fx.root, + ['clone', 'acme/legacy-private'], + { PATH: ghPath } + ); + + expect(result.timedOut).toBe(false); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Please provide the public fork URL:'); + expect(result.stderr).toContain('Pass --public-fork .'); + expect(await pathExists(path.join(fx.root, 'legacy-private'))).toBe( + false + ); + } finally { + await fx.cleanup(); + } + }); +}); + describe('per-command help', () => { for (const [command, usage] of [ ['sync', 'venfork sync [branch] [--report-issues]'], diff --git a/tests/unit/clone-args.test.ts b/tests/unit/clone-args.test.ts index 3cd4cb5..45a1786 100644 --- a/tests/unit/clone-args.test.ts +++ b/tests/unit/clone-args.test.ts @@ -122,3 +122,54 @@ describe('parseCloneCliArgs prompt flags', () => { ); }); }); + +describe('parseCloneCliArgs --public-fork', () => { + test('value form', () => { + expect( + parseCloneCliArgs(['a/b-private', '--public-fork', 'acme/b']) + .publicForkUrl + ).toBe('acme/b'); + }); + + test('= form, trimmed', () => { + expect( + parseCloneCliArgs([ + 'a/b-private', + '--public-fork= git@github.com:acme/b.git ', + ]).publicForkUrl + ).toBe('git@github.com:acme/b.git'); + }); + + test('absent leaves it undefined', () => { + expect(parseCloneCliArgs(['a/b-private']).publicForkUrl).toBeUndefined(); + }); + + test('combines with --upstream', () => { + expect( + parseCloneCliArgs([ + '--public-fork=acme/b', + '--upstream', + 'up/b', + 'a/b-private', + ]) + ).toEqual({ + vendorRepoUrl: 'a/b-private', + noPublic: false, + upstreamUrl: 'up/b', + publicForkUrl: 'acme/b', + prompt: { yes: false, noInput: false }, + }); + }); + + test('throws when the value is missing or empty', () => { + expect(() => parseCloneCliArgs(['a/b', '--public-fork'])).toThrow( + '--public-fork requires a value' + ); + expect(() => + parseCloneCliArgs(['a/b', '--public-fork', '--no-input']) + ).toThrow('--public-fork requires a value'); + expect(() => parseCloneCliArgs(['a/b', '--public-fork='])).toThrow( + '--public-fork requires a value' + ); + }); +}); diff --git a/tests/unit/commands.test.ts b/tests/unit/commands.test.ts index 5b82730..1c5a547 100644 --- a/tests/unit/commands.test.ts +++ b/tests/unit/commands.test.ts @@ -328,6 +328,7 @@ function setupCommand( noPublic?: boolean; syncMirror?: (cwd: string) => Promise; prompt?: { yes: boolean; noInput: boolean }; + organizationFromEnv?: boolean; } = {} ): Promise { return runSetupCommand( @@ -3419,6 +3420,69 @@ describe('setupCommand - VENFORK_ORG environment variable', () => { ); expect(createCalls.length).toBe(0); }); + test('VENFORK_ORG set to own username still needs --yes without a TTY', async () => { + process.stdin.isTTY = false; + + const err = await setupCommand( + 'git@github.com:test/repo.git', + 'test-vendor', + 'testuser', + undefined, + { organizationFromEnv: true } + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(MissingInputError); + expect((err as MissingInputError).flag).toBe('--yes'); + expect(execaCalls.some((cmd) => cmd.includes('gh repo fork'))).toBe(false); + }); + + test('--org matches the gh login case-insensitively', async () => { + process.stdin.isTTY = false; + const confirm = clack.confirm as unknown as ReturnType; + confirm.mockClear(); + + try { + await setupCommand( + 'git@github.com:test/repo.git', + 'test-vendor', + 'TestUser' + ); + } catch { + // Expected in mocked env + } + + expect(confirm).not.toHaveBeenCalled(); + const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork')); + expect(forkCalls.length).toBeGreaterThan(0); + expect(forkCalls.some((cmd) => cmd.includes('--org'))).toBe(false); + }); + + test('--org uses the personal account with no confirm, even without a TTY', async () => { + process.stdin.isTTY = false; + const confirm = clack.confirm as unknown as ReturnType; + confirm.mockClear(); + + try { + await setupCommand( + 'git@github.com:test/repo.git', + 'test-vendor', + 'testuser' + ); + } catch { + // Expected in mocked env + } + + expect(confirm).not.toHaveBeenCalled(); + const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork')); + expect(forkCalls.length).toBeGreaterThan(0); + expect(forkCalls.some((cmd) => cmd.includes('--org'))).toBe(false); + expect( + execaCalls.some( + (cmd) => + cmd.includes('gh repo clone') && cmd.includes('testuser/test-vendor') + ) + ).toBe(true); + }); }); describe('setupCommand - error paths', () => { @@ -3623,6 +3687,262 @@ describe('cloneCommand - error paths', () => { }); }); +describe('cloneCommand --public-fork and legacy prompts', () => { + type TextOptions = { + message: string; + validate?: (value: string | undefined) => string | undefined; + }; + const textMock = () => clack.text as unknown as ReturnType; + const textCalls = (): TextOptions[] => + textMock().mock.calls.map((call) => call[0] as TextOptions); + const remoteAdds = () => + execaCalls.filter((cmd) => cmd.startsWith('git remote add')); + const forkLookups = () => + execaCalls.filter( + (cmd) => cmd.startsWith('gh repo view') && !cmd.includes('--json') + ); + + function useSsh(): void { + mockResponses.set('gh config get git_protocol', { + exitCode: 0, + stdout: 'ssh\n', + stderr: '', + }); + } + + function useConfig(config: Record): void { + mockResponses.set('git show FETCH_HEAD:.venfork/config.json', { + exitCode: 0, + stdout: JSON.stringify({ version: '1', ...config }), + stderr: '', + }); + } + + function failForkDetection(): void { + mockResponses.set('gh repo view acme/project', (command) => + command.includes('--json parent') + ? Promise.resolve({ + exitCode: 0, + stdout: 'https://github.com/upstream/original.git', + stderr: '', + }) + : Promise.reject(new Error('Could not resolve to a Repository')) + ); + } + + function failParentLookup(): void { + mockResponses.set('--json parent', { + exitCode: 0, + stdout: 'null', + stderr: '', + }); + } + + beforeEach(() => { + textMock().mockClear(); + useSsh(); + }); + + test('legacy mirror: --public-fork skips fork detection and reads upstream from that fork', async () => { + await cloneCommand('acme/project-private', { + publicForkUrl: 'other-org/project-fork', + }); + + expect(forkLookups()).toEqual([]); + expect( + execaCalls.some((cmd) => + cmd.startsWith('gh repo view other-org/project-fork --json parent') + ) + ).toBe(true); + expect(remoteAdds()).toEqual([ + 'git remote add public git@github.com:other-org/project-fork.git', + 'git remote add upstream git@github.com:upstream/original.git', + ]); + expect(textMock()).not.toHaveBeenCalled(); + }); + + test('legacy mirror: --public-fork with --upstream makes no gh repo view call', async () => { + await cloneCommand('acme/project-private', { + publicForkUrl: 'https://github.com/other-org/project-fork', + upstreamUrl: 'git@github.com:upstream/original.git', + }); + + expect(execaCalls.some((cmd) => cmd.startsWith('gh repo view'))).toBe( + false + ); + expect(remoteAdds()).toEqual([ + 'git remote add public git@github.com:other-org/project-fork.git', + 'git remote add upstream git@github.com:upstream/original.git', + ]); + }); + + test('an invalid --public-fork exits 1 before cloning', async () => { + await expect( + cloneCommand('acme/project-private', { publicForkUrl: 'not a repo' }) + ).rejects.toThrow(new CommandExitError(1)); + + expect(clack.log.error).toHaveBeenCalledWith( + 'Invalid --public-fork. Use a GitHub URL or owner/repo (e.g. acme/project).', + ON_STDERR + ); + expect(execaCalls.some((cmd) => cmd.includes('gh repo clone'))).toBe(false); + }); + + test('--public-fork with --no-public is refused before cloning', async () => { + await expect( + cloneCommand('acme/project-private', { + noPublic: true, + publicForkUrl: 'acme/project', + }) + ).rejects.toThrow('--public-fork cannot be combined with --no-public'); + + expect(execaCalls.some((cmd) => cmd.includes('gh repo clone'))).toBe(false); + }); + + test('with a config branch, a --public-fork naming the recorded fork is accepted', async () => { + useConfig({ + publicForkUrl: 'https://github.com/acme/project.git', + upstreamUrl: 'git@github.com:upstream/original.git', + }); + + await cloneCommand('acme/project-private', { + publicForkUrl: 'acme/project', + }); + + expect(remoteAdds()).toEqual([ + 'git remote add public git@github.com:acme/project.git', + 'git remote add upstream git@github.com:upstream/original.git', + ]); + }); + + test('with a config branch, a different --public-fork is refused and adds no remotes', async () => { + useConfig({ + publicForkUrl: 'git@github.com:acme/project.git', + upstreamUrl: 'git@github.com:upstream/original.git', + }); + + await expect( + cloneCommand('acme/project-private', { + publicForkUrl: 'other-org/project', + }) + ).rejects.toThrow(new CommandExitError(1)); + + expect(clack.log.error).toHaveBeenCalledWith( + "Refusing to override venfork-config: --public-fork='other-org/project' but config records publicForkUrl='git@github.com:acme/project.git'.", + ON_STDERR + ); + expect(remoteAdds()).toEqual([]); + }); + + test('with a no-public config branch, --public-fork is refused', async () => { + useConfig({ + mode: 'no-public', + upstreamUrl: 'git@github.com:upstream/original.git', + }); + + await expect( + cloneCommand('acme/project-private', { publicForkUrl: 'acme/project' }) + ).rejects.toThrow(new CommandExitError(1)); + + expect(clack.log.error).toHaveBeenCalledWith( + 'Refusing to clone with --public-fork: the venfork-config branch records mode=no-public.', + ON_STDERR + ); + expect(remoteAdds()).toEqual([]); + }); + + test('with a config branch, --upstream naming the recorded repo over another transport is accepted', async () => { + useConfig({ + publicForkUrl: 'git@github.com:acme/project.git', + upstreamUrl: 'git@github.com:upstream/original.git', + }); + + await cloneCommand('acme/project-private', { + upstreamUrl: 'https://github.com/upstream/original', + }); + + expect(remoteAdds()).toContain( + 'git remote add upstream git@github.com:upstream/original.git' + ); + }); + + test('legacy mirror without a TTY: the fork prompt throws naming --public-fork ', async () => { + failForkDetection(); + process.stdin.isTTY = false; + + const err = await cloneCommand('acme/project-private').catch( + (e: unknown) => e + ); + + expect(err).toBeInstanceOf(MissingInputError); + expect((err as MissingInputError).flag).toBe('--public-fork '); + expect(remoteAdds()).toEqual([]); + }); + + test('the fork prompt rejects an empty or non-GitHub answer and normalizes owner/repo', async () => { + failForkDetection(); + textMock().mockImplementationOnce(() => Promise.resolve('other-org/fork')); + + await cloneCommand('acme/project-private'); + + const forkPrompt = textCalls()[0]; + expect(forkPrompt.message).toBe('Please provide the public fork URL:'); + expect(forkPrompt.validate?.(undefined)).toBe( + 'Public fork URL is required' + ); + expect(forkPrompt.validate?.(' ')).toBe('Public fork URL is required'); + expect(forkPrompt.validate?.('not a repo')).toBe( + 'Use a GitHub URL or owner/repo (e.g. acme/project)' + ); + expect(forkPrompt.validate?.('other-org/fork')).toBeUndefined(); + expect(remoteAdds()[0]).toBe( + 'git remote add public git@github.com:other-org/fork.git' + ); + }); + + test('the upstream prompts reject an empty answer', async () => { + failParentLookup(); + textMock().mockImplementationOnce(() => + Promise.resolve(' git@github.com:upstream/original.git ') + ); + + await cloneCommand('acme/project-private'); + await cloneCommand('acme/project-private', { noPublic: true }).catch( + () => undefined + ); + + const [parentPrompt, noPublicPrompt] = textCalls(); + expect(parentPrompt.message).toBe('Please provide the upstream URL:'); + expect(noPublicPrompt.message).toStartWith('Upstream URL?'); + for (const prompt of [parentPrompt, noPublicPrompt]) { + expect(prompt.validate?.(undefined)).toBe('Upstream URL is required'); + expect(prompt.validate?.(' ')).toBe('Upstream URL is required'); + expect(prompt.validate?.('git@github.com:a/b.git')).toBeUndefined(); + } + expect(remoteAdds()).toContain( + 'git remote add upstream git@github.com:upstream/original.git' + ); + }); + + test.each([ + ['public fork', failForkDetection, {}], + ['upstream (parent lookup)', failParentLookup, {}], + ['upstream (--no-public)', () => {}, { noPublic: true }], + ])( + 'a cancelled %s prompt exits 130 and adds no remotes', + async (_name, arrange, options) => { + arrange(); + promptCancelled = true; + + await expect( + cloneCommand('acme/project-private', options) + ).rejects.toThrow(new CommandExitError(130)); + + expect(remoteAdds()).toEqual([]); + } + ); +}); + describe('syncCommand - error paths', () => { test('a failing divergence range aborts sync without pushing', async () => { const stderr = 'fatal: bad revision upstream0tip..origin0tip'; diff --git a/tests/unit/setup-args.test.ts b/tests/unit/setup-args.test.ts index 0daad35..1a5969b 100644 --- a/tests/unit/setup-args.test.ts +++ b/tests/unit/setup-args.test.ts @@ -78,6 +78,7 @@ describe('parseSetupCliArgs', () => { upstreamUrl: 'a/b', privateMirrorName: undefined, organization: 'from-env', + organizationFromEnv: true, publicForkRepoName: undefined, noPublic: false, prompt: { yes: false, noInput: false },