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
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,17 +163,32 @@ Then use `/wt init`, `/wt new feat/foo`, `/wt doctor`, etc. inside Claude Code.

## Commands

### `wt new <branch> [--slot N] [--no-install] [--json]`
### `wt new [branch] [--base <ref>] [--slot N] [--no-install] [--json]`

Creates a new git worktree and sets up its isolated environment:

1. Allocates the next available slot (or uses `--slot N`)
2. Checks whether `origin/<branch>` exists; if it does, fetches it and creates a tracking local branch, otherwise creates a fresh local branch
1. Resolves the branch:
- An existing **local** branch is checked out as-is.
- Otherwise, if `origin/<branch>` exists, it is fetched and a tracking local branch is created.
- Otherwise a **fresh local branch** is created. By default it forks from the current `HEAD`; pass `--base <ref>` to fork it from a specific branch, tag, or commit instead (e.g. `wt new feat/login --base main`). `--base` is ignored (with a warning) when the branch already exists.
- If `[branch]` is **omitted**, a throwaway branch is auto-named from the base and today's date — like `main-20260723-nemanull` — and forked from `--base` (defaulting to `origin/main`, then `main`). Use this for a clean scratch environment without inventing a name: `wt new --base main`. Because wt invented the name, it is always a fresh branch: no `origin` lookup, and a same-named remote branch is never adopted.
2. Allocates the next available slot (or uses `--slot N`)
3. Creates a new Postgres database from the main DB as template
4. Copies configured `.env` files, fills missing safe defaults from examples, and patches each with slot-specific values
5. Starts configured Docker services after the slot database exists
6. Runs `postSetup` commands (unless `--no-install`)

```bash
wt new feat/login # create/checkout feat/login (forks from HEAD if new)
wt new feat/login --base main # new branch forked from main
wt new --base main # throwaway branch e.g. main-20260723-nemanull off main
wt new # same, base defaults to origin/main then main
```

`--base` accepts any committish a fresh branch can fork from (a branch, tag, or commit SHA). When it is actually used — that is, when the branch is new — it is resolved before any slot, database, Docker service, or worktree is created, so a typo fails cleanly with nothing to clean up. It is **not** auto-fetched: to fork from the latest remote tip, `git fetch` first (or pass `--base origin/<branch>` once it is present locally).

If a failure happens partway through setup, the worktree, database, and Docker services are rolled back. An auto-named branch is deleted too, since wt invented it; a branch you named is left in place.

### `wt open <slot-or-branch> [--no-install] [--json]`

Opens an existing worktree or creates one on the fly. Prints the worktree path to stdout for easy shell integration:
Expand Down
181 changes: 180 additions & 1 deletion __tests__/git.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { beforeEach, describe, it, expect, jest } from '@jest/globals';
import * as child_process from 'node:child_process';

// Mock execSync to avoid real git calls in unit tests
// Mock the child_process entry points to avoid real git calls in unit tests
jest.mock('node:child_process', () => ({
execSync: jest.fn(),
execFileSync: jest.fn(),
}));

// Pin the OS user so generated branch names do not depend on who runs the suite.
jest.mock('node:os', () => ({
...jest.requireActual<typeof import('node:os')>('node:os'),
userInfo: () => ({ username: 'devuser' }),
}));

const mockExecSync = child_process.execSync as jest.MockedFunction<
typeof child_process.execSync
>;
const mockExecFileSync = child_process.execFileSync as jest.MockedFunction<
typeof child_process.execFileSync
>;

import {
assertRefExists,
createWorktree,
deleteBranch,
generateAutoBranchName,
getMainWorktreePath,
isMainWorktree,
listPrunableWorktrees,
Expand Down Expand Up @@ -154,6 +167,54 @@ describe('git', () => {
originCheckError: 'fatal: Could not resolve host',
});
});

it('records the base as the start point for a fresh local branch', () => {
// local miss, then origin miss (exit 2) -> fresh local branch off the base.
mockExecSync
.mockImplementationOnce(() => {
throw new Error('missing local branch');
})
.mockImplementationOnce(() => {
throw Object.assign(new Error('no remote branch'), { status: 2 });
});

expect(resolveWorktreeBranch('feat/auth', undefined, { base: 'main' })).toEqual({
branchName: 'feat/auth',
source: 'local-new',
sourceLabel: 'fresh local branch',
startPoint: 'main',
});
});

it('skips the origin lookup entirely for a name wt invented', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('missing local branch');
});

expect(
resolveWorktreeBranch('main-20260723-devuser', undefined, {
base: 'origin/main',
skipOriginLookup: true,
}),
).toEqual({
branchName: 'main-20260723-devuser',
source: 'local-new',
sourceLabel: 'fresh local branch',
startPoint: 'origin/main',
});
// Only the local probe ran: no ls-remote, no fetch.
expect(mockExecSync).toHaveBeenCalledTimes(1);
});

it('leaves the base inert when the branch already exists locally', () => {
mockExecSync.mockReturnValueOnce('abc123\n'); // rev-parse succeeds -> exists

expect(resolveWorktreeBranch('feat/auth', undefined, { base: 'main' })).toEqual({
branchName: 'feat/auth',
source: 'local-existing',
sourceLabel: 'existing local branch',
});
});
});

describe('createWorktree', () => {
Expand Down Expand Up @@ -183,5 +244,123 @@ describe('git', () => {
{ stdio: 'pipe' },
);
});

it('forks a fresh local branch from the start point when a base is set', () => {
createWorktree('/Users/dev/project/.worktrees', {
branchName: 'feat/auth',
source: 'local-new',
sourceLabel: 'fresh local branch',
startPoint: 'main',
});

expect(mockExecSync).toHaveBeenCalledWith(
'git worktree add "/Users/dev/project/.worktrees/feat-auth" -b "feat/auth" "main"',
{ stdio: 'pipe' },
);
});
});

describe('generateAutoBranchName', () => {
it('builds a base-date-user name and strips a leading origin/', () => {
// Every branchExistsLocally probe reports "missing" so the first candidate wins.
mockExecSync.mockImplementation(() => {
throw new Error('missing');
});

const name = generateAutoBranchName('origin/main');

expect(name).toMatch(/^main-\d{8}-devuser$/);
expect(name).not.toContain('/');
});

it('reduces a base ref to a name git will accept', () => {
mockExecSync.mockImplementation(() => {
throw new Error('missing');
});

expect(generateAutoBranchName('release/2.0')).toMatch(/^release-2\.0-\d{8}-devuser$/);
// `~` and `@{}` are legal in a revision but not in a branch name.
expect(generateAutoBranchName('HEAD~2')).toMatch(/^HEAD-2-\d{8}-devuser$/);
expect(generateAutoBranchName('main@{upstream}')).toMatch(/^main-upstream-\d{8}-devuser$/);
});

it('appends a numeric suffix when the candidate already exists', () => {
// Every probe reports "missing"...
mockExecSync.mockImplementation(() => {
throw new Error('missing');
});
// ...except the first, so the initial candidate collides and gets a `-2`.
mockExecSync.mockReturnValueOnce('');

expect(generateAutoBranchName('main')).toMatch(/^main-\d{8}-devuser-2$/);
});
});

describe('assertRefExists', () => {
it('does not throw when the ref resolves', () => {
mockExecFileSync.mockReturnValue('abc123\n');
expect(() => assertRefExists('main')).not.toThrow();
// No shell: git receives the ref as an argument, never as command text.
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['rev-parse', '--verify', '--quiet', 'main^{commit}'],
{ stdio: 'pipe' },
);
});

it('throws a clean error when the ref is missing', () => {
mockExecFileSync.mockImplementation(() => {
throw new Error('bad revision');
});
expect(() => assertRefExists('bogus')).toThrow("base ref 'bogus' not found");
});
});

describe('deleteBranch', () => {
it('refuses to force-delete, so unmerged commits survive', () => {
mockExecSync.mockReturnValue('');

deleteBranch('main-20260723-devuser');

expect(mockExecSync).toHaveBeenCalledWith('git branch -d "main-20260723-devuser"', {
stdio: 'pipe',
});
});
});

describe('ref safety', () => {
it.each([
'main"; touch /tmp/pwn; echo "',
'main$(whoami)',
'main`whoami`',
'main\\"; touch /tmp/pwn; echo "',
'main\nwhoami',
'',
])('rejects the shell-unsafe ref %p before running git', (ref) => {
expect(() => assertRefExists(ref)).toThrow('contains unsupported characters');
expect(() => resolveWorktreeBranch(ref)).toThrow('contains unsupported characters');
expect(() => resolveWorktreeBranch('feat/auth', undefined, { base: ref })).toThrow(
'contains unsupported characters',
);
// The auto-name path reaches git before resolveWorktreeBranch does.
expect(() => generateAutoBranchName(ref)).toThrow('contains unsupported characters');
expect(mockExecSync).not.toHaveBeenCalled();
expect(mockExecFileSync).not.toHaveBeenCalled();
});

it('still accepts ordinary branch, tag, and revision syntax', () => {
mockExecFileSync.mockReturnValue('abc123\n');
for (const ref of ['main', 'origin/main', 'feat/auth-2', 'v1.2.3', 'HEAD~2', 'a1b2c3d']) {
expect(() => assertRefExists(ref)).not.toThrow();
}
});

it('does not reject branch names git itself allows', () => {
// Only shell-special characters are refused, so these keep working.
mockExecFileSync.mockReturnValue('abc123\n');
for (const ref of ['feat/\u00fcn\u00efcode', "fix/don't-panic", 'feat/a&b', 'release/1.0,rc']) {
expect(() => assertRefExists(ref)).not.toThrow();
}
});
});
});
11 changes: 8 additions & 3 deletions skills/wt/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,18 @@ Tip: use `cd $(wt open $1)` to jump into the worktree directory.

---

### `new $1` — Create a new worktree
### `new [branch] [--base <ref>]` — Create a new worktree

Run:
```bash
wt new $1
wt new $@
```

Branch handling:
- `wt new <branch>` — checks out `<branch>` if it exists locally, else fetches/tracks `origin/<branch>`, else creates a fresh local branch off `HEAD`.
- `wt new <branch> --base <ref>` — when the branch is new, forks it from `<ref>` (a branch, tag, or commit) instead of `HEAD`. Ignored with a warning if the branch already exists. Not auto-fetched — `git fetch` first to fork from a fresh remote tip.
- `wt new --base <ref>` (or bare `wt new`) — no name given, so it auto-creates a throwaway branch like `main-20260723-nemanull` forked from `<ref>` (default `origin/main`, then `main`). Use this for a clean scratch worktree without naming a branch.

If it fails, check `wt doctor` for diagnostics. Common issues:
- All slots occupied → run `wt audit` to see which worktrees are merged and safe to remove, then `wt prune --merged` (or `wt remove`)
- Database connection failed → check that Postgres is running and `DATABASE_URL` in root `.env` is correct
Expand Down Expand Up @@ -297,7 +302,7 @@ Show a brief help:
```
Available commands:
/wt init — Set up wt in a new repository (discovers env files, generates config)
/wt new <branch> — Create a worktree with isolated DB, Docker services, and ports
/wt new [branch] [--base <ref>] — Create a worktree (auto-names a scratch branch off --base when no branch is given)
/wt open <slot|branch> — Open a worktree by slot or branch (creates if not found)
/wt list — List all worktree allocations
/wt audit — Classify worktrees by merge state; suggest which are safe to remove
Expand Down
18 changes: 16 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,29 @@ program
program
.command('new')
.description('Create a new worktree with isolated environment')
.argument('<branch>', 'Branch name to create or checkout')
.argument('[branch]', 'Branch to create or checkout; auto-generated from --base when omitted')
.option('--slot <n>', 'Force a specific slot number')
.option('--base <ref>', 'Start point for a newly created branch (branch/tag/commit); seeds the auto-name when [branch] is omitted; ignored if the branch already exists')
.option('--no-install', 'Skip post-setup commands')
.option('--json', 'Output as JSON', false)
.action(async (branch: string, opts) => {
.addHelpText(
'after',
[
'',
'Examples:',
' wt new feat/login # create/checkout feat/login (forks from HEAD if new)',
' wt new feat/login --base main # new branch forked from main',
' wt new --base main # throwaway branch e.g. main-20260723-nemanull off main',
' wt new # same, base defaults to origin/main then main',
'',
].join('\n'),
)
.action(async (branch: string | undefined, opts) => {
await newCommand(branch, {
json: opts.json,
install: opts.install,
slot: opts.slot,
base: opts.base,
});
});

Expand Down
Loading
Loading