Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ venfork status
- Check which remotes are configured
- See your current branch

### `venfork stage <branch>`
### `venfork stage <branch> [--create-pr] [--copy-pr-body]`

Push a branch to the public fork, making it visible and ready for PR to upstream.

Expand All @@ -250,13 +250,17 @@ Push a branch to the public fork, making it visible and ready for PR to upstream
```bash
venfork stage feature-auth
venfork stage bugfix/issue-123
venfork stage feature-auth --create-pr
venfork stage feature-auth --create-pr --copy-pr-body
```

**What it does:**
1. Verifies branch exists
2. Shows staging details and confirmation
3. Pushes to public fork
4. Provides PR creation link
4. Optionally creates a draft PR to upstream (`--create-pr`)
5. Optionally copies title/body from private origin PR (`--copy-pr-body`, requires `--create-pr`)
6. Provides PR creation link

## Environment Variables

Expand Down
102 changes: 98 additions & 4 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,44 @@ async function ensureVenforkRemotes(
await $({ cwd })`git remote set-url --push upstream DISABLE`;
}

async function getPrivateOriginPrForBranch(
branch: string
): Promise<{ title: string; body: string } | null> {
const originResult = await $({
reject: false,
})`git remote get-url origin`;
if (originResult.exitCode !== 0) {
return null;
}

const originUrl = originResult.stdout.trim();
const originRepoPath = parseRepoPath(originUrl);
const originOwner = parseOwner(originUrl);
if (!originRepoPath || !originOwner) {
return null;
}

const prsResult = await $({
reject: false,
})`gh pr list --repo ${originRepoPath} --head ${`${originOwner}:${branch}`} --state open --limit 1 --json title,body`;
if (prsResult.exitCode !== 0) {
return null;
}

try {
const prs = JSON.parse(prsResult.stdout) as Array<{
title: string;
body: string;
}>;
if (!prs.length) {
return null;
}
return prs[0];
} catch {
return null;
}
}

/**
* Setup command: Create private mirror and public fork
*
Expand Down Expand Up @@ -705,7 +743,10 @@ To force sync anyway: git push origin upstream/${defaultBranch}:${defaultBranch}
/**
* Stage command: Push branch to public fork for PR to upstream
*/
export async function stageCommand(branch: string): Promise<void> {
export async function stageCommand(
branch: string,
options?: { createPr?: boolean; copyPrBody?: boolean }
): Promise<void> {
p.intro('📤 Venfork Stage');

// Check GitHub CLI authentication
Expand All @@ -716,7 +757,7 @@ export async function stageCommand(branch: string): Promise<void> {

if (!branch) {
p.log.error('Branch name is required');
p.outro('Usage: venfork stage <branch>');
p.outro('Usage: venfork stage <branch> [--create-pr] [--copy-pr-body]');
process.exit(1);
}

Expand All @@ -742,6 +783,23 @@ export async function stageCommand(branch: string): Promise<void> {
}
const publicUrl = publicUrlResult.stdout.trim();
const publicRepoPath = parseRepoPath(publicUrl);
const publicOwner = parseOwner(publicUrl);

Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

publicRepoPath/publicOwner are used to build the compare URL and to set gh pr create --head, but neither value is validated. If the public remote URL isn’t a parseable github.com URL, parseRepoPath/parseOwner will return an empty string and --head ${publicOwner}:${branch} will be malformed. Add validation (e.g., if !publicRepoPath or (when --create-pr) !publicOwner, fail with a clear error) before constructing URLs/commands.

Suggested change
if (!publicRepoPath) {
p.log.error(
"Could not determine the GitHub repository path from the 'public' remote URL. " +
"Please ensure the 'public' remote points to a valid github.com repository.",
);
process.exit(1);
}
if (createPr && !publicOwner) {
p.log.error(
"Could not determine the GitHub owner from the 'public' remote URL required for --create-pr. " +
"Please ensure the 'public' remote points to a valid github.com repository.",
);
process.exit(1);
}

Copilot uses AI. Check for mistakes.
if (!publicRepoPath) {
p.log.error(
"Could not determine the GitHub repository path from the 'public' remote URL. " +
"Please ensure the 'public' remote points to a valid github.com repository.",
);
process.exit(1);
}

if (options?.createPr && !publicOwner) {
p.log.error(
"Could not determine the GitHub owner from the 'public' remote URL required for --create-pr. " +
"Please ensure the 'public' remote points to a valid github.com repository.",
);
process.exit(1);
}

// Step 3: Get upstream URL for PR link
const upstreamUrlResult = await $({
Expand Down Expand Up @@ -786,9 +844,43 @@ This makes your work visible and ready for PR to upstream.

// Step 7: Show PR creation link
const prUrl = `https://github.com/${upstreamRepoPath}/compare/${upstreamDefaultBranch}...${publicRepoPath.split('/')[0]}:${branch}?expand=1`;
let createdPrUrl: string | null = null;

const shouldCreatePr = options?.createPr ?? false;
const shouldCopyPrBody = options?.copyPrBody ?? false;
if (shouldCopyPrBody && !shouldCreatePr) {
throw new Error('--copy-pr-body requires --create-pr');
}

if (shouldCreatePr) {
s.start('Creating draft PR to upstream');
const copied = shouldCopyPrBody
? await getPrivateOriginPrForBranch(branch)
: null;
const title = copied?.title || `Stage ${branch} for upstream`;
const body =
copied?.body ||
`Staged from private mirror branch \`${branch}\` via \`venfork stage --create-pr\`.`;

const createResult = await $({
reject: false,
})`gh pr create --repo ${upstreamRepoPath} --head ${`${publicOwner}:${branch}`} --base ${upstreamDefaultBranch} --title ${title} --body ${body} --draft`;
if (createResult.exitCode === 0) {
createdPrUrl = createResult.stdout.trim() || null;
s.stop('Draft PR created');
} else {
s.stop('Draft PR not created');
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When gh pr create fails, the code only stops the spinner with "Draft PR not created" and discards the actual stderr/exit reason. This makes it hard to diagnose common cases (already-open PR, missing permissions, invalid head/base). Consider logging createResult.stderr as a warning (or including it in the error path) so users know what to do next.

Suggested change
s.stop('Draft PR not created');
s.stop('Draft PR not created');
const stderr = createResult.stderr?.toString().trim();
if (stderr) {
p.log.warn(`gh pr create failed: ${stderr}`);
}

Copilot uses AI. Check for mistakes.
const stderr = createResult.stderr?.toString().trim();
if (stderr) {
p.log.warn(`gh pr create failed: ${stderr}`);
}
}
}

p.note(
`Your branch is now on the public fork!\n\nCreate a pull request to upstream:\n ${prUrl}`,
createdPrUrl
? `Your branch is now on the public fork!\n\nDraft PR created:\n ${createdPrUrl}\n\nCompare URL:\n ${prUrl}`
: `Your branch is now on the public fork!\n\nCreate a pull request to upstream:\n ${prUrl}`,
'Next Steps'
);

Expand Down Expand Up @@ -907,8 +999,10 @@ venfork sync [branch]
Update default branches of origin and public to match upstream
Syncs main/master branch without affecting your current work

venfork stage <branch>
venfork stage <branch> [--create-pr] [--copy-pr-body]
Push branch to public fork for PR to upstream
Optional: create draft upstream PR automatically
Optional: copy title/body from private origin PR when creating PR
This is when your work becomes visible to the client`,
'Available Commands'
);
Expand Down
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
syncCommand,
} from './commands.js';
import { parseSetupCliArgs } from './setup-args.js';
import { parseStageCliArgs } from './stage-args.js';

/**
* Main CLI entry point
Expand Down Expand Up @@ -45,9 +46,14 @@ async function main(): Promise<void> {
case 'sync':
await syncCommand(args[1]);
break;
case 'stage':
await stageCommand(args[1]);
case 'stage': {
const parsed = parseStageCliArgs(args.slice(1));
await stageCommand(parsed.branch || '', {
createPr: parsed.createPr,
copyPrBody: parsed.copyPrBody,
});
break;
}
case 'status':
await statusCommand();
break;
Expand Down
36 changes: 36 additions & 0 deletions src/stage-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type ParsedStageArgs = {
branch?: string;
createPr: boolean;
copyPrBody: boolean;
};

/**
* Parse `venfork stage ...` argv after the `stage` token.
*/
export function parseStageCliArgs(stageArgs: string[]): ParsedStageArgs {
const positional: string[] = [];
let createPr = false;
let copyPrBody = false;

for (const arg of stageArgs) {
if (arg === '--create-pr') {
createPr = true;
continue;
}
if (arg === '--copy-pr-body') {
copyPrBody = true;
continue;
}
positional.push(arg);
}

if (copyPrBody && !createPr) {
throw new Error('--copy-pr-body requires --create-pr');
}

return {
branch: positional[0],
createPr,
copyPrBody,
};
}
42 changes: 42 additions & 0 deletions tests/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,48 @@ describe('stageCommand', () => {
// Process.exit should have been called
expect(process.exit).toHaveBeenCalled();
});

test('creates draft PR when createPr option is enabled', async () => {
try {
await stageCommand('feature-branch', { createPr: true });
} catch {
// Expected in mocked environment
}

expect(execaCalls.some((cmd) => cmd.includes('gh pr create --repo'))).toBe(
true
);
expect(execaCalls.some((cmd) => cmd.includes('--draft'))).toBe(true);
});

test('copies private PR body when copyPrBody is enabled', async () => {
mockResponses.set(
'gh pr list --repo test/repo --head test:feature-branch',
{
exitCode: 0,
stdout: JSON.stringify([
{ title: 'Internal PR title', body: 'Internal PR body' },
]),
stderr: '',
}
);

try {
await stageCommand('feature-branch', {
createPr: true,
copyPrBody: true,
});
} catch {
// Expected in mocked environment
}

expect(
execaCalls.some((cmd) => cmd.includes('gh pr list --repo test/repo'))
).toBe(true);
expect(
execaCalls.some((cmd) => cmd.includes('--title Internal PR title'))
).toBe(true);
});
});

describe('statusCommand', () => {
Expand Down
28 changes: 28 additions & 0 deletions tests/stage-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import { parseStageCliArgs } from '../src/stage-args.js';

describe('parseStageCliArgs', () => {
test('parses branch-only invocation', () => {
expect(parseStageCliArgs(['feature/x'])).toEqual({
branch: 'feature/x',
createPr: false,
copyPrBody: false,
});
});

test('parses create-pr and copy-pr-body flags', () => {
expect(
parseStageCliArgs(['feature/x', '--create-pr', '--copy-pr-body'])
).toEqual({
branch: 'feature/x',
createPr: true,
copyPrBody: true,
});
});

test('throws when copy-pr-body used without create-pr', () => {
expect(() => parseStageCliArgs(['feature/x', '--copy-pr-body'])).toThrow(
'--copy-pr-body requires --create-pr'
);
});
});