Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: CI

on:
push:
branches:
- main
pull_request:

permissions:
Expand Down
29 changes: 29 additions & 0 deletions .lvimrc.lua
Original file line number Diff line number Diff line change
@@ -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")
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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_<number>` worktree and switch to it:

```bash
gw pr 123
```

Remove a worktree and its local branch:

```bash
Expand Down Expand Up @@ -150,6 +157,7 @@ error instead of guessing.
```text
gw list
gw switch [--ignore-prefix] [branch]
gw pr <number>
gw remove|rm [--force] [--remote] [--ignore-prefix] <branch>
gw clone [--branch-prefix <prefix>] <project-name> <repo-url>
gw init [--branch-prefix <prefix>]
Expand All @@ -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 <number>` requires `gh`, reads the PR head branch and owner, creates or
reuses a fork remote, checks out local branch `pr_<number>`, and switches to
the `pr_<number>` 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
Expand Down Expand Up @@ -279,19 +290,22 @@ 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.

`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
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,6 +19,7 @@ program

registerListCommand(program);
registerSwitchCommand(program);
registerPrCommand(program);
registerRemoveCommand(program);
registerCloneCommand(program);
registerInitCommand(program);
Expand Down
137 changes: 137 additions & 0 deletions src/commands/pr.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<string> {
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('<number>')
.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);
})
);
}
25 changes: 25 additions & 0 deletions src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,31 @@ export async function listRemotes(repoPath: string): Promise<string[]> {
.filter(Boolean);
}

export async function getRemoteUrl(
repoPath: string,
remoteName: string
): Promise<string> {
const stdout = await expectGitSuccess(
['config', '--get', `remote.${remoteName}.url`],
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<void> {
await expectGitSuccess(['remote', 'add', remoteName, remoteUrl], repoPath);
}

export async function getPreferredRemote(repoPath: string): Promise<string> {
const remotes = await listRemotes(repoPath);

Expand Down
Loading