From 1e50a7cf55bc99c4d8dbc05a47e66befb3fb6f5e Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sat, 25 Apr 2026 17:34:25 -0700 Subject: [PATCH 1/3] First pass at `gw pr` command --- .lvimrc.lua | 29 +++++ AGENTS.md | 8 +- README.md | 28 +++-- src/cli.ts | 2 + src/commands/pr.ts | 137 +++++++++++++++++++++++ src/core/git.ts | 25 +++++ src/core/github.ts | 106 ++++++++++++++++++ src/core/pull-requests.ts | 45 ++++++++ src/core/shell.ts | 8 +- test/cli/commands.integration.test.ts | 155 +++++++++++++++++++++++++- test/core/github.test.ts | 19 ++++ test/core/pull-requests.test.ts | 46 ++++++++ test/core/shell.test.ts | 13 +++ 13 files changed, 605 insertions(+), 16 deletions(-) create mode 100644 .lvimrc.lua create mode 100644 src/commands/pr.ts create mode 100644 src/core/github.ts create mode 100644 src/core/pull-requests.ts create mode 100644 test/core/github.test.ts create mode 100644 test/core/pull-requests.test.ts create mode 100644 test/core/shell.test.ts diff --git a/.lvimrc.lua b/.lvimrc.lua new file mode 100644 index 0000000..00d039b --- /dev/null +++ b/.lvimrc.lua @@ -0,0 +1,29 @@ +-- NOTE: Only source this file once per session +if vim.g.localvimrc_sourced_once == 1 then + return +end + +vim.g.localvimrc_sourced_once = 1 +vim.g.startify_disable_at_vimenter = 1 +vim.opt.tabstop = 2 +vim.opt.shiftwidth = 2 +vim.opt.expandtab = true + +-- ALEFix Config +vim.g.ale_fixers = { + typescript = { "oxfmt" }, + javascript = { "oxfmt" }, + typescriptreact = { "oxfmt" }, + javascriptreact = { "oxfmt" }, + json = { "oxfmt" }, + jsonc = { "oxfmt" }, + -- css = { "oxfmt" }, + markdown = { "oxfmt" }, + -- html = { "oxfmt" }, + -- mdx = { "oxfmt" }, + conf = { "oxfmt" }, + -- lua = { "stylua" }, +} +vim.g.ale_fix_on_save = 1 + +print("git-gw .lvimrc has been sourced") diff --git a/AGENTS.md b/AGENTS.md index 3c13dc4..82500af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,10 +35,10 @@ Project conventions to keep updated as the repo evolves. - Preserve compatibility with existing `.gw_project` files and the `flat-tilde` worktree folder layout. -- `gw switch` and `gw clone` are the commands that need shell-side directory - changes. They communicate target directories through `GW_CWD_FILE`; keep the - shell wrappers in `src/core/shell.ts` synchronized if that command set - changes. +- `gw switch`, `gw clone`, and `gw pr` are the commands that need shell-side + directory changes. They communicate target directories through `GW_CWD_FILE`; + keep the shell wrappers in `src/core/shell.ts` synchronized if that command + set changes. - Shell integration targets `bash`, `zsh`, `fish`, and `nu`. Missing shells may cause local smoke tests to skip, not fail. diff --git a/README.md b/README.md index 313ae72..566408e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ everything fully tested. - Node.js 18.18 or newer - Git +- GitHub CLI (`gh`) for `gw pr` - npm for installation - Bun only for repository development @@ -37,8 +38,8 @@ Replace `zsh` with `bash`, `fish`, or `nu` as needed. ## Shell Integration -Shell integration is required for `gw clone` and `gw switch` to change the -current shell directory. Without it, the Node CLI can only print the target +Shell integration is required for `gw clone`, `gw switch`, and `gw pr` to change +the current shell directory. Without it, the Node CLI can only print the target path. For a current-session setup: @@ -105,6 +106,12 @@ Switch to an existing branch or create a worktree for it: gw switch feature/login ``` +Check out a GitHub pull request into a `pr_` worktree and switch to it: + +```bash +gw pr 123 +``` + Remove a worktree and its local branch: ```bash @@ -150,6 +157,7 @@ error instead of guessing. ```text gw list gw switch [--ignore-prefix] [branch] +gw pr gw remove|rm [--force] [--remote] [--ignore-prefix] gw clone [--branch-prefix ] gw init [--branch-prefix ] @@ -174,6 +182,9 @@ gw help `~`, so `feature/login` becomes `feature~login`. - `gw switch` first accepts an existing worktree folder name, then checks local branches, remote branches, and branch-prefix-aware fallbacks. +- `gw pr ` requires `gh`, reads the PR head branch and owner, creates or + reuses a fork remote, checks out local branch `pr_`, and switches to + the `pr_` worktree. - `gw remove` refuses to remove the primary branch and refuses to remove the current worktree while the shell is inside it. - Relative `core.hooksPath` directories are copied from the primary worktree to @@ -279,12 +290,12 @@ Recommended branch/PR beta workflow: ## Troubleshooting -`gw clone` or `gw switch` prints a path but does not change directories: shell -integration is not active in the current shell. Run `gw setup`, or run the -session activation command for your shell. +`gw clone`, `gw switch`, or `gw pr` prints a path but does not change +directories: shell integration is not active in the current shell. Run +`gw setup`, or run the session activation command for your shell. -`command gw switch ...` does not change directories: `command gw` bypasses the -shell wrapper. Use `gw switch ...`. +`command gw switch ...` or `command gw pr ...` does not change directories: +`command gw` bypasses the shell wrapper. Use `gw switch ...` or `gw pr ...`. `gw switch` without a branch fails in CI or scripts: the picker requires an interactive terminal. Pass an explicit branch name. @@ -292,6 +303,9 @@ interactive terminal. Pass an explicit branch name. `gw clone` cannot detect the remote default branch: verify that the repo URL is reachable and that the remote advertises `HEAD`. +`gw pr` says GitHub CLI is required: install `gh` from https://cli.github.com/ +and run `gh auth login`, then retry the command. + Persistent setup succeeded but the command still behaves the same: the shell wrapper was not active yet. Open a new shell or run the session activation command printed by `gw setup`. In `fish`, run the printed diff --git a/src/cli.ts b/src/cli.ts index a4bb64f..97d1d40 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { registerCloneCommand } from '@/commands/clone'; import { registerHelpCommand } from '@/commands/help'; import { registerInitCommand } from '@/commands/init'; import { registerListCommand } from '@/commands/list'; +import { registerPrCommand } from '@/commands/pr'; import { registerRemoveCommand } from '@/commands/remove'; import { registerSetupCommand } from '@/commands/setup'; import { registerShellInitCommand } from '@/commands/shell-init'; @@ -18,6 +19,7 @@ program registerListCommand(program); registerSwitchCommand(program); +registerPrCommand(program); registerRemoveCommand(program); registerCloneCommand(program); registerInitCommand(program); diff --git a/src/commands/pr.ts b/src/commands/pr.ts new file mode 100644 index 0000000..989c7a6 --- /dev/null +++ b/src/commands/pr.ts @@ -0,0 +1,137 @@ +import type { Command } from 'commander'; +import { join } from 'node:path'; + +import { + commandAction, + loadProjectContext, + pathExists, + printGwError, + requestDirectoryChange, +} from '@/commands/shared'; +import { getRemoteName } from '@/core/config'; +import { + addRemote, + addWorktree, + branchExists, + fetchRemoteBranchRef, + getRemoteUrl, + listRemotes, + setBranchUpstream, + syncRelativeHooksPath, +} from '@/core/git'; +import { getPullRequestHead } from '@/core/github'; +import { + choosePullRequestRemoteName, + derivePullRequestRemoteUrl, +} from '@/core/pull-requests'; +import { findWorktreeForBranch, listWorktrees } from '@/core/worktrees'; + +async function findRemoteByUrl( + repoPath: string, + remoteUrl: string +): Promise { + const remotes = await listRemotes(repoPath); + + for (const remote of remotes) { + if ((await getRemoteUrl(repoPath, remote)) === remoteUrl) { + return remote; + } + } + + return null; +} + +async function ensurePullRequestRemote( + repoPath: string, + headRepositoryOwner: string, + remoteUrl: string +): Promise { + const existingRemote = await findRemoteByUrl(repoPath, remoteUrl); + if (existingRemote) { + return existingRemote; + } + + const existingRemotes = await listRemotes(repoPath); + const remoteName = choosePullRequestRemoteName( + headRepositoryOwner, + existingRemotes + ); + await addRemote(repoPath, remoteName, remoteUrl); + + return remoteName; +} + +export function registerPrCommand(program: Command): void { + program + .command('pr') + .description('Check out a GitHub pull request into a worktree') + .argument('') + .action( + commandAction(async (prNumber: string) => { + if (!/^[0-9]+$/u.test(prNumber)) { + throw new Error('PR number must be numeric'); + } + + const context = await loadProjectContext(); + const branchName = `pr_${prNumber}`; + const targetPath = join(context.projectRoot, branchName); + const worktrees = await listWorktrees(context.anchorRepo); + const existingWorktree = findWorktreeForBranch(worktrees, branchName); + + if (existingWorktree) { + await requestDirectoryChange(existingWorktree); + return; + } + + if (await pathExists(targetPath)) { + throw new Error(`target path already exists: ${targetPath}`); + } + + const pullRequestHead = await getPullRequestHead( + context.anchorRepo, + prNumber + ); + const baseRemoteName = getRemoteName(context.config); + const baseRemoteUrl = await getRemoteUrl( + context.anchorRepo, + baseRemoteName + ); + const pullRequestRemoteUrl = derivePullRequestRemoteUrl( + baseRemoteUrl, + pullRequestHead.headRepositoryOwner + ); + const pullRequestRemoteName = await ensurePullRequestRemote( + context.anchorRepo, + pullRequestHead.headRepositoryOwner, + pullRequestRemoteUrl + ); + const upstreamRef = `${pullRequestRemoteName}/${pullRequestHead.headRefName}`; + + await fetchRemoteBranchRef( + context.anchorRepo, + pullRequestRemoteName, + pullRequestHead.headRefName + ); + + if (await branchExists(context.anchorRepo, branchName)) { + await addWorktree(context.anchorRepo, targetPath, { branchName }); + } else { + await addWorktree(context.anchorRepo, targetPath, { + branchName, + createBranch: true, + startPoint: upstreamRef, + }); + } + + await setBranchUpstream(targetPath, branchName, upstreamRef); + + try { + await syncRelativeHooksPath(context.anchorRepo, targetPath); + } catch { + printGwError(`failed to sync hooks into new worktree: ${targetPath}`); + } + + await requestDirectoryChange(targetPath); + }) + ); +} diff --git a/src/core/git.ts b/src/core/git.ts index 4153ee7..adf3b7a 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -229,6 +229,31 @@ export async function listRemotes(repoPath: string): Promise { .filter(Boolean); } +export async function getRemoteUrl( + repoPath: string, + remoteName: string +): Promise { + const stdout = await expectGitSuccess( + ['remote', 'get-url', remoteName], + repoPath + ); + + const remoteUrl = stdout.trim(); + if (!remoteUrl) { + throw new Error(`remote is missing: ${remoteName}`); + } + + return remoteUrl; +} + +export async function addRemote( + repoPath: string, + remoteName: string, + remoteUrl: string +): Promise { + await expectGitSuccess(['remote', 'add', remoteName, remoteUrl], repoPath); +} + export async function getPreferredRemote(repoPath: string): Promise { const remotes = await listRemotes(repoPath); diff --git a/src/core/github.ts b/src/core/github.ts new file mode 100644 index 0000000..fa8cae1 --- /dev/null +++ b/src/core/github.ts @@ -0,0 +1,106 @@ +import { execa } from 'execa'; + +import type { PullRequestHead } from '@/core/pull-requests'; + +const MISSING_GH_MESSAGE = + "GitHub CLI is required for gw pr. Install it from https://cli.github.com/ and run 'gh auth login'."; + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + error != null && + typeof error === 'object' && + 'code' in error && + error.code === code + ); +} + +function getOutputText(output: unknown): string { + return typeof output === 'string' ? output : ''; +} + +function isMissingGhExit( + exitCode: number | undefined, + stderr: string, + message: string +): boolean { + const diagnostic = `${stderr}\n${message}`; + + return ( + (exitCode === 127 && + (stderr === '' || + /command not found|not found|ENOENT|No such file/iu.test(stderr))) || + (exitCode == null && + /ENOENT|Executable not found|not found in \$PATH/iu.test(diagnostic)) + ); +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object'; +} + +function parsePullRequestHead(stdout: string): PullRequestHead | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + + if (!isRecord(parsed) || typeof parsed.headRefName !== 'string') { + return null; + } + + const owner = parsed.headRepositoryOwner; + if (!isRecord(owner) || typeof owner.login !== 'string') { + return null; + } + + return { + headRefName: parsed.headRefName, + headRepositoryOwner: owner.login, + }; +} + +export async function getPullRequestHead( + repoPath: string, + prNumber: string, + env?: NodeJS.ProcessEnv +): Promise { + try { + const result = await execa( + 'gh', + ['pr', 'view', prNumber, '--json', 'headRefName,headRepositoryOwner'], + { + cwd: repoPath, + env, + reject: false, + stderr: 'pipe', + } + ); + + const stdout = getOutputText(result.stdout); + const stderr = getOutputText(result.stderr).trim(); + const message = getOutputText(result.message); + + if (isMissingGhExit(result.exitCode, stderr, message)) { + throw new Error(MISSING_GH_MESSAGE); + } + + if ((result.exitCode ?? 1) !== 0) { + throw new Error(stderr || `gh pr view ${prNumber} failed`); + } + + const pullRequestHead = parsePullRequestHead(stdout); + if (!pullRequestHead) { + throw new Error('failed to read PR head branch information from gh'); + } + + return pullRequestHead; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + throw new Error(MISSING_GH_MESSAGE); + } + + throw error; + } +} diff --git a/src/core/pull-requests.ts b/src/core/pull-requests.ts new file mode 100644 index 0000000..9dc32c9 --- /dev/null +++ b/src/core/pull-requests.ts @@ -0,0 +1,45 @@ +export interface PullRequestHead { + headRefName: string; + headRepositoryOwner: string; +} + +export function derivePullRequestRemoteUrl( + baseRemoteUrl: string, + headRepositoryOwner: string +): string { + const urlMatch = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/[^/]+\/)([^/]+)\/(.+)$/u.exec( + baseRemoteUrl + ); + if (urlMatch) { + return `${urlMatch[1]}${headRepositoryOwner}/${urlMatch[3]}`; + } + + const scpMatch = /^([^@]+@[^:]+:)([^/]+)\/(.+)$/u.exec(baseRemoteUrl); + if (scpMatch) { + return `${scpMatch[1]}${headRepositoryOwner}/${scpMatch[3]}`; + } + + throw new Error(`unsupported remote URL: ${baseRemoteUrl}`); +} + +export function choosePullRequestRemoteName( + headRepositoryOwner: string, + existingRemotes: string[] +): string { + const baseName = headRepositoryOwner.toLowerCase(); + if (!existingRemotes.includes(baseName)) { + return baseName; + } + + const prName = `${baseName}-pr`; + if (!existingRemotes.includes(prName)) { + return prName; + } + + let suffix = 2; + while (existingRemotes.includes(`${prName}-${suffix}`)) { + suffix += 1; + } + + return `${prName}-${suffix}`; +} diff --git a/src/core/shell.ts b/src/core/shell.ts index 856c2ff..3b6f4f1 100644 --- a/src/core/shell.ts +++ b/src/core/shell.ts @@ -3,7 +3,7 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, dirname, join } from 'node:path'; -export const DIRECTORY_CHANGE_COMMANDS = ['switch', 'clone'] as const; +export const DIRECTORY_CHANGE_COMMANDS = ['switch', 'clone', 'pr'] as const; export const SHELL_NAMES = ['bash', 'zsh', 'fish', 'nu'] as const; export type ShellName = 'bash' | 'zsh' | 'fish' | 'nu'; @@ -275,7 +275,7 @@ export function getSessionActivationCommand(shell: ShellName): string { function renderBashLikeShellInit(): string { return `__gw_needs_cd() { case "$1" in - switch|clone) return 0 ;; + switch|clone|pr) return 0 ;; *) return 1 ;; esac } @@ -334,7 +334,7 @@ gw() { function renderFishShellInit(): string { return `function __gw_needs_cd switch $argv[1] - case switch clone + case switch clone pr return 0 case '*' return 1 @@ -406,7 +406,7 @@ end function renderNuShellInit(): string { return `def __gw_needs_cd [cmd: string] { - $cmd in ['switch', 'clone'] + $cmd in ['switch', 'clone', 'pr'] } def __gw_needs_source [cmd: string] { diff --git a/test/cli/commands.integration.test.ts b/test/cli/commands.integration.test.ts index 11d95d9..1333d0d 100644 --- a/test/cli/commands.integration.test.ts +++ b/test/cli/commands.integration.test.ts @@ -1,19 +1,85 @@ import { canonicalPath, + commitEmpty, createRemoteFixture, createWorkDir, + makeTempDir, pathExists, runCli, runCliWithCwdCapture, runGit, } from '@test/helpers'; -import { mkdir } from 'node:fs/promises'; +import { execa } from 'execa'; +import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { hasGwConfig } from '@/core/config'; import { branchExists } from '@/core/git'; +async function createFakeGh( + rootDir: string, + prNumber: string, + headRefName: string, + headRepositoryOwner: string +): Promise { + const binDir = join(rootDir, 'fake-bin'); + const ghPath = join(binDir, 'gh'); + const response = JSON.stringify({ + headRefName, + headRepositoryOwner: { + login: headRepositoryOwner, + }, + }); + + await mkdir(binDir, { recursive: true }); + await writeFile( + ghPath, + `#!/bin/sh +if [ "$1" = "pr" ] && [ "$2" = "view" ] && [ "$3" = "${prNumber}" ]; then + printf '%s\n' '${response}' + exit 0 +fi + +printf 'unexpected gh args: %s\n' "$*" >&2 +exit 1 +`, + 'utf8' + ); + await chmod(ghPath, 0o755); + + return binDir; +} + +async function createForkRemote( + rootDir: string, + baseOriginPath: string, + headRefName: string +): Promise { + const forkPath = join(rootDir, 'fork.git'); + const forkSeedPath = join(rootDir, 'fork-seed'); + + await runGit(['init', '--bare', forkPath]); + await runGit(['clone', baseOriginPath, forkSeedPath]); + await runGit(['checkout', '-b', headRefName], { cwd: forkSeedPath }); + await commitEmpty(forkSeedPath, headRefName); + await runGit(['remote', 'add', 'fork', forkPath], { cwd: forkSeedPath }); + await runGit(['push', '-u', 'fork', headRefName], { cwd: forkSeedPath }); + + return forkPath; +} + +async function createPathWithoutGh(): Promise { + const binDir = await makeTempDir('gw-no-gh-bin-'); + + for (const command of ['bun', 'git']) { + const commandPath = (await execa('which', [command])).stdout.trim(); + await symlink(commandPath, join(binDir, command)); + } + + return binDir; +} + describe('CLI integration', () => { it('covers clone, list, switch, and remove against a real remote', async () => { const fixture = await createRemoteFixture(['feature/test'], 'main'); @@ -74,6 +140,93 @@ describe('CLI integration', () => { expect(listResult.stdout).toContain('./main -> main'); }, 60_000); + it('checks out a GitHub PR into a worktree and captures cwd', async () => { + const fixture = await createRemoteFixture([], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + const headRefName = 'feature/pr-checkout'; + const forkPath = await createForkRemote( + fixture.rootDir, + fixture.originPath, + headRefName + ); + const fakeGhBin = await createFakeGh( + fixture.rootDir, + '123', + headRefName, + 'contrib' + ); + + await runCliWithCwdCapture(['clone', 'demo', fixture.originPath], { + cwd: workDir, + }); + + const mainPath = join(workDir, 'demo', 'main'); + const prPath = join(workDir, 'demo', 'pr_123'); + + await runGit( + ['remote', 'set-url', 'origin', 'https://example.test/base/repo.git'], + { cwd: mainPath } + ); + await runGit( + [ + 'config', + `url.${forkPath}.insteadOf`, + 'https://example.test/contrib/repo.git', + ], + { cwd: mainPath } + ); + + const prResult = await runCliWithCwdCapture(['pr', '123'], { + cwd: mainPath, + env: { + ...process.env, + PATH: `${fakeGhBin}:${process.env.PATH || ''}`, + }, + }); + + expect(prResult.result.exitCode).toBe(0); + expect(prResult.targetPath).toBe(await canonicalPath(prPath)); + expect(await pathExists(prPath)).toBe(true); + + const currentBranch = await runGit(['branch', '--show-current'], { + cwd: prPath, + }); + expect(currentBranch.stdout).toBe('pr_123'); + + const upstream = await runGit( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + { cwd: prPath } + ); + expect(upstream.stdout).toBe(`contrib/${headRefName}`); + + const remoteUrl = await runGit(['config', '--get', 'remote.contrib.url'], { + cwd: mainPath, + }); + expect(remoteUrl.stdout).toBe('https://example.test/contrib/repo.git'); + }, 60_000); + + it('prints setup guidance when gh is missing', async () => { + const fixture = await createRemoteFixture([], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + const pathWithoutGh = await createPathWithoutGh(); + + await runCliWithCwdCapture(['clone', 'demo', fixture.originPath], { + cwd: workDir, + }); + + const result = await runCli(['pr', '123'], { + cwd: join(workDir, 'demo', 'main'), + env: { + ...process.env, + PATH: pathWithoutGh, + }, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('GitHub CLI is required for gw pr'); + expect(result.stderr).toContain('gh auth login'); + }, 60_000); + it('preserves the non-interactive switch error when no branch is provided', async () => { const fixture = await createRemoteFixture([], 'main'); const workDir = await createWorkDir(fixture.rootDir); diff --git a/test/core/github.test.ts b/test/core/github.test.ts new file mode 100644 index 0000000..c001916 --- /dev/null +++ b/test/core/github.test.ts @@ -0,0 +1,19 @@ +import { makeTempDir } from '@test/helpers'; +import { describe, expect, it } from 'vitest'; + +import { getPullRequestHead } from '@/core/github'; + +describe('GitHub CLI helpers', () => { + it('explains how to set up gh when the command is missing', async () => { + const repoPath = await makeTempDir('gw-gh-missing-repo-'); + const emptyPath = await makeTempDir('gw-gh-missing-path-'); + + await expect( + getPullRequestHead(repoPath, '123', { + PATH: emptyPath, + }) + ).rejects.toThrow( + "GitHub CLI is required for gw pr. Install it from https://cli.github.com/ and run 'gh auth login'." + ); + }); +}); diff --git a/test/core/pull-requests.test.ts b/test/core/pull-requests.test.ts new file mode 100644 index 0000000..7b15087 --- /dev/null +++ b/test/core/pull-requests.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + choosePullRequestRemoteName, + derivePullRequestRemoteUrl, +} from '@/core/pull-requests'; + +describe('pull request helpers', () => { + it('derives a fork URL from an HTTPS remote URL', () => { + expect( + derivePullRequestRemoteUrl( + 'https://github.com/base/repo.git', + 'contributor' + ) + ).toBe('https://github.com/contributor/repo.git'); + }); + + it('derives a fork URL from an SSH remote URL', () => { + expect( + derivePullRequestRemoteUrl('git@github.com:base/repo.git', 'contributor') + ).toBe('git@github.com:contributor/repo.git'); + }); + + it('rejects unsupported remote URL shapes', () => { + expect(() => + derivePullRequestRemoteUrl('/tmp/base/repo.git', 'contributor') + ).toThrow('unsupported remote URL'); + }); + + it('chooses a stable non-conflicting PR remote name', () => { + expect(choosePullRequestRemoteName('Contributor', ['origin'])).toBe( + 'contributor' + ); + expect( + choosePullRequestRemoteName('Contributor', ['origin', 'contributor']) + ).toBe('contributor-pr'); + expect( + choosePullRequestRemoteName('Contributor', [ + 'origin', + 'contributor', + 'contributor-pr', + 'contributor-pr-2', + ]) + ).toBe('contributor-pr-3'); + }); +}); diff --git a/test/core/shell.test.ts b/test/core/shell.test.ts new file mode 100644 index 0000000..aadff3d --- /dev/null +++ b/test/core/shell.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { DIRECTORY_CHANGE_COMMANDS, renderShellInit } from '@/core/shell'; + +describe('shell integration helpers', () => { + it('treats gw pr as a directory-changing command', () => { + expect(DIRECTORY_CHANGE_COMMANDS).toContain('pr'); + expect(renderShellInit('bash')).toContain('switch|clone|pr'); + expect(renderShellInit('zsh')).toContain('switch|clone|pr'); + expect(renderShellInit('fish')).toContain('case switch clone pr'); + expect(renderShellInit('nu')).toContain("['switch', 'clone', 'pr']"); + }); +}); From af6e5126aad65681b0aa980586686464f5d8cd5a Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sat, 25 Apr 2026 17:38:43 -0700 Subject: [PATCH 2/3] supposed test hardening for same origin pr --- src/core/git.ts | 2 +- test/cli/commands.integration.test.ts | 54 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/core/git.ts b/src/core/git.ts index adf3b7a..a66a8f7 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -234,7 +234,7 @@ export async function getRemoteUrl( remoteName: string ): Promise { const stdout = await expectGitSuccess( - ['remote', 'get-url', remoteName], + ['config', '--get', `remote.${remoteName}.url`], repoPath ); diff --git a/test/cli/commands.integration.test.ts b/test/cli/commands.integration.test.ts index 1333d0d..4f8f96e 100644 --- a/test/cli/commands.integration.test.ts +++ b/test/cli/commands.integration.test.ts @@ -205,6 +205,60 @@ describe('CLI integration', () => { expect(remoteUrl.stdout).toBe('https://example.test/contrib/repo.git'); }, 60_000); + it('reuses origin for a PR branch from the base repository', async () => { + const headRefName = 'feature/origin-pr'; + const fixture = await createRemoteFixture([headRefName], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + const fakeGhBin = await createFakeGh( + fixture.rootDir, + '124', + headRefName, + 'base' + ); + + await runCliWithCwdCapture(['clone', 'demo', fixture.originPath], { + cwd: workDir, + }); + + const mainPath = join(workDir, 'demo', 'main'); + const prPath = join(workDir, 'demo', 'pr_124'); + + await runGit( + ['remote', 'set-url', 'origin', 'https://example.test/base/repo.git'], + { cwd: mainPath } + ); + await runGit( + [ + 'config', + `url.${fixture.originPath}.insteadOf`, + 'https://example.test/base/repo.git', + ], + { cwd: mainPath } + ); + + const prResult = await runCliWithCwdCapture(['pr', '124'], { + cwd: mainPath, + env: { + ...process.env, + PATH: `${fakeGhBin}:${process.env.PATH || ''}`, + }, + }); + + expect(prResult.result.exitCode).toBe(0); + expect(prResult.targetPath).toBe(await canonicalPath(prPath)); + + const upstream = await runGit( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + { cwd: prPath } + ); + expect(upstream.stdout).toBe(`origin/${headRefName}`); + + const remotes = await runGit(['remote'], { cwd: mainPath }); + const remotesStdout = + typeof remotes.stdout === 'string' ? remotes.stdout : ''; + expect(remotesStdout.split(/\r?\n/u).filter(Boolean)).toEqual(['origin']); + }, 60_000); + it('prints setup guidance when gh is missing', async () => { const fixture = await createRemoteFixture([], 'main'); const workDir = await createWorkDir(fixture.rootDir); From 1f038f31d0d7117131365295ec2bb521b9099715 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sat, 25 Apr 2026 18:02:29 -0700 Subject: [PATCH 3/3] fix duplicate tests --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f00f6c..5ffc3a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: CI on: push: + branches: + - main pull_request: permissions: