From 297e44f5b4fd42e2e6c1c7bb34f168f7459b9d97 Mon Sep 17 00:00:00 2001 From: nemanull Date: Fri, 24 Jul 2026 13:46:23 -0700 Subject: [PATCH] feat: optional branch name and --base fork point --- README.md | 21 ++++- __tests__/git.spec.ts | 181 ++++++++++++++++++++++++++++++++++++++- skills/wt/SKILL.md | 11 ++- src/cli.ts | 18 +++- src/commands/new.spec.ts | 128 ++++++++++++++++++++++++++- src/commands/new.ts | 83 ++++++++++++++++-- src/core/git.ts | 161 +++++++++++++++++++++++++++++++++- 7 files changed, 582 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9824555..6e34b22 100644 --- a/README.md +++ b/README.md @@ -163,17 +163,32 @@ Then use `/wt init`, `/wt new feat/foo`, `/wt doctor`, etc. inside Claude Code. ## Commands -### `wt new [--slot N] [--no-install] [--json]` +### `wt new [branch] [--base ] [--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/` 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/` 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 ` 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/` 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 [--no-install] [--json]` Opens an existing worktree or creates one on the fly. Prints the worktree path to stdout for easy shell integration: diff --git a/__tests__/git.spec.ts b/__tests__/git.spec.ts index 9aba88e..aa71930 100644 --- a/__tests__/git.spec.ts +++ b/__tests__/git.spec.ts @@ -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('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, @@ -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', () => { @@ -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(); + } + }); }); }); diff --git a/skills/wt/SKILL.md b/skills/wt/SKILL.md index 8b9e4f2..72d9794 100644 --- a/skills/wt/SKILL.md +++ b/skills/wt/SKILL.md @@ -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 ]` — Create a new worktree Run: ```bash -wt new $1 +wt new $@ ``` +Branch handling: +- `wt new ` — checks out `` if it exists locally, else fetches/tracks `origin/`, else creates a fresh local branch off `HEAD`. +- `wt new --base ` — when the branch is new, forks it from `` (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 ` (or bare `wt new`) — no name given, so it auto-creates a throwaway branch like `main-20260723-nemanull` forked from `` (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 @@ -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 — Create a worktree with isolated DB, Docker services, and ports + /wt new [branch] [--base ] — Create a worktree (auto-names a scratch branch off --base when no branch is given) /wt open — 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 diff --git a/src/cli.ts b/src/cli.ts index 3dd35ab..855b8b9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,15 +24,29 @@ program program .command('new') .description('Create a new worktree with isolated environment') - .argument('', 'Branch name to create or checkout') + .argument('[branch]', 'Branch to create or checkout; auto-generated from --base when omitted') .option('--slot ', 'Force a specific slot number') + .option('--base ', '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, }); }); diff --git a/src/commands/new.spec.ts b/src/commands/new.spec.ts index 77b45a4..22f2660 100644 --- a/src/commands/new.spec.ts +++ b/src/commands/new.spec.ts @@ -35,7 +35,14 @@ jest.mock('../core/git', () => ({ createWorktree: jest.fn(), getBranchName: jest.fn(), removeWorktree: jest.fn(), + deleteBranch: jest.fn(), resolveWorktreeBranch: jest.fn(), + generateAutoBranchName: jest.fn(), + assertRefExists: jest.fn(), +})); + +jest.mock('../core/audit', () => ({ + resolveBaseRef: jest.fn(), })); jest.mock('./setup', () => ({ @@ -63,8 +70,12 @@ import { createWorktree, getBranchName, removeWorktree, + deleteBranch, resolveWorktreeBranch, + generateAutoBranchName, + assertRefExists, } from '../core/git'; +import { resolveBaseRef } from '../core/audit'; import { loadConfig } from './setup'; import { createNewWorktree, newCommand } from './new'; import type { Allocation, Registry, WtConfig } from '../types'; @@ -93,8 +104,13 @@ const mockGetMainWorktreePath = getMainWorktreePath as jest.MockedFunction; const mockGetBranchName = getBranchName as jest.MockedFunction; const mockRemoveWorktree = removeWorktree as jest.MockedFunction; +const mockDeleteBranch = deleteBranch as jest.MockedFunction; const mockResolveWorktreeBranch = resolveWorktreeBranch as jest.MockedFunction; +const mockGenerateAutoBranchName = + generateAutoBranchName as jest.MockedFunction; +const mockAssertRefExists = assertRefExists as jest.MockedFunction; +const mockResolveBaseRef = resolveBaseRef as jest.MockedFunction; const mockLoadConfig = loadConfig as jest.MockedFunction; describe('new command branch selection', () => { @@ -167,7 +183,10 @@ describe('new command branch selection', () => { const result = await createNewWorktree('feat/auth', { install: false }); expect(result.branchSelection).toEqual(originSelection()); - expect(mockResolveWorktreeBranch).toHaveBeenCalledWith('feat/auth', expect.any(Function)); + expect(mockResolveWorktreeBranch).toHaveBeenCalledWith('feat/auth', expect.any(Function), { + base: undefined, + skipOriginLookup: false, + }); expect(mockCreateWorktree).toHaveBeenCalledWith( path.join(tmpDir, config.baseWorktreePath), originSelection(), @@ -215,6 +234,8 @@ describe('new command branch selection', () => { branchName: string; branchSource: string; branchSourceLabel: string; + startPoint: string | null; + autoNamed: boolean; }; }; @@ -223,6 +244,77 @@ describe('new command branch selection', () => { expect(output.data.branchName).toBe('feat/auth'); expect(output.data.branchSource).toBe('origin'); expect(output.data.branchSourceLabel).toBe('origin/feat/auth'); + expect(output.data.startPoint).toBe('origin/feat/auth'); + expect(output.data.autoNamed).toBe(false); + }); + + it('forks a new branch from --base and validates the ref up front', async () => { + mockResolveWorktreeBranch.mockReturnValue({ + branchName: 'feat/auth', + source: 'local-new', + sourceLabel: 'fresh local branch', + startPoint: 'main', + }); + + await createNewWorktree('feat/auth', { install: false, base: 'main' }); + + expect(mockResolveWorktreeBranch).toHaveBeenCalledWith( + 'feat/auth', + expect.any(Function), + { base: 'main', skipOriginLookup: false }, + ); + expect(mockAssertRefExists).toHaveBeenCalledWith('main'); + expect(mockGenerateAutoBranchName).not.toHaveBeenCalled(); + }); + + it('ignores --base with a warning when the branch already exists', async () => { + mockResolveWorktreeBranch.mockReturnValue({ + branchName: 'feat/auth', + source: 'local-existing', + sourceLabel: 'existing local branch', + }); + + await createNewWorktree('feat/auth', { install: false, base: 'main' }); + + expect(mockAssertRefExists).not.toHaveBeenCalled(); + expect(stderrOutput(stderrSpy)).toContain( + "Branch 'feat/auth' already exists; --base main ignored.", + ); + }); + + it('auto-names a throwaway branch forked off the default base when no name is given', async () => { + mockResolveBaseRef.mockReturnValue('origin/main'); + mockGenerateAutoBranchName.mockReturnValue('main-20260723-nemanull'); + mockResolveWorktreeBranch.mockReturnValue({ + branchName: 'main-20260723-nemanull', + source: 'local-new', + sourceLabel: 'fresh local branch', + startPoint: 'origin/main', + }); + + const result = await createNewWorktree(undefined, { install: false }); + + expect(mockResolveBaseRef).toHaveBeenCalledWith(tmpDir); + expect(mockGenerateAutoBranchName).toHaveBeenCalledWith('origin/main'); + // An invented name must never adopt a same-named branch that exists on origin. + expect(mockResolveWorktreeBranch).toHaveBeenCalledWith( + 'main-20260723-nemanull', + expect.any(Function), + { base: 'origin/main', skipOriginLookup: true }, + ); + expect(mockAssertRefExists).toHaveBeenCalledWith('origin/main'); + expect(result.autoNamed).toBe(true); + }); + + it('explains how to recover when no default base resolves', async () => { + mockResolveBaseRef.mockImplementation(() => { + throw new Error('None of [origin/main, main] resolve in /repo; cannot audit.'); + }); + + await expect(createNewWorktree(undefined, { install: false })).rejects.toThrow( + 'pass --base or name a branch', + ); + expect(mockCreateWorktree).not.toHaveBeenCalled(); }); it('includes branch source details in human summary output', async () => { @@ -455,6 +547,40 @@ describe('new command rollback on failure', () => { expect(mockWriteRegistry).not.toHaveBeenCalled(); }); + it('deletes an auto-named branch during rollback so retries do not pile up', async () => { + mockResolveBaseRef.mockReturnValue('origin/main'); + mockGenerateAutoBranchName.mockReturnValue('main-20260723-nemanull'); + mockResolveWorktreeBranch.mockReturnValue({ + branchName: 'main-20260723-nemanull', + source: 'local-new', + sourceLabel: 'fresh local branch', + startPoint: 'origin/main', + }); + mockCopyAndPatchAllEnvFiles.mockImplementation(() => { + throw new Error('env patch exploded'); + }); + + await expect(createNewWorktree(undefined, { install: false, quiet: true })).rejects.toThrow( + 'env patch exploded', + ); + + expect(mockRemoveWorktree).toHaveBeenCalledWith(worktreeDir, expect.any(Function)); + expect(mockDeleteBranch).toHaveBeenCalledWith('main-20260723-nemanull', expect.any(Function)); + }); + + it('keeps a user-named branch during rollback', async () => { + mockCopyAndPatchAllEnvFiles.mockImplementation(() => { + throw new Error('env patch exploded'); + }); + + await expect( + createNewWorktree('feat/auth', { install: false, quiet: true }), + ).rejects.toThrow('env patch exploded'); + + expect(mockRemoveWorktree).toHaveBeenCalledWith(worktreeDir, expect.any(Function)); + expect(mockDeleteBranch).not.toHaveBeenCalled(); + }); + it('does not drop a pre-existing database during rollback', async () => { mockDatabaseExists.mockResolvedValue(true); const boom = new Error('env patch exploded'); diff --git a/src/commands/new.ts b/src/commands/new.ts index 6cdb945..2fc655f 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -16,9 +16,13 @@ import { createWorktree, getBranchName, removeWorktree, + deleteBranch, resolveWorktreeBranch, + generateAutoBranchName, + assertRefExists, type WorktreeBranchSelection, } from '../core/git'; +import { resolveBaseRef } from '../core/audit'; import { extractErrorMessage, formatJson, formatSetupSummary, success, error } from '../output'; import { loadConfig } from './setup'; import type { Allocation, PortDrift } from '../types'; @@ -29,6 +33,7 @@ interface NewOptions { readonly json: boolean; readonly install: boolean; readonly slot?: string; + readonly base?: string; } export interface CreateWorktreeResult { @@ -36,6 +41,7 @@ export interface CreateWorktreeResult { readonly allocation: Allocation; readonly branchSelection: WorktreeBranchSelection; readonly portDrifts: readonly PortDrift[]; + readonly autoNamed: boolean; } /** Read DATABASE_URL from the main worktree's .env file */ @@ -49,10 +55,26 @@ function readDatabaseUrl(mainRoot: string): string { return match[1]; } +/** + * Fork point for an auto-named branch when the user did not pass `--base`. + * Re-phrases the audit helper's failure, which talks about auditing, into + * advice that makes sense for `wt new`. + */ +function resolveDefaultBase(mainRoot: string): string { + try { + return resolveBaseRef(mainRoot); + } catch { + throw new Error( + "no default base found (looked for 'origin/main', then 'main'); " + + 'pass --base or name a branch', + ); + } +} + /** Core worktree creation logic — returns the result for programmatic use */ export async function createNewWorktree( - branchName: string, - options: { install: boolean; slot?: string; quiet?: boolean }, + branchNameArg: string | undefined, + options: { install: boolean; slot?: string; quiet?: boolean; base?: string }, ): Promise { const log = options.quiet ? () => {} @@ -63,7 +85,9 @@ export async function createNewWorktree( const config = loadConfig(mainRoot); let registry = readRegistry(mainRoot); - // Determine slot — port availability no longer affects slot choice. + // Determine slot first — it is pure bookkeeping over the registry, so an + // unusable --slot fails before any git lookup hits the network or writes a + // remote-tracking ref. Port availability no longer affects slot choice. let slot: number; if (options.slot !== undefined) { slot = parseInt(options.slot, 10); @@ -84,16 +108,43 @@ export async function createNewWorktree( slot = available; } - log(`Creating worktree for '${branchName}' in slot ${slot}...`); + // Resolve the branch name and its fork point before allocating anything. When + // no name is given we auto-name a throwaway branch (e.g. main-20260723-nemanull) + // and fork it off the base ref; validating the base up front means a bad + // --base fails cleanly before any database, Docker, or worktree resources exist. + let base = options.base; + let branchName: string; + let autoNamed = false; + if (branchNameArg !== undefined) { + branchName = branchNameArg; + } else { + base = base ?? resolveDefaultBase(mainRoot); + branchName = generateAutoBranchName(base); + autoNamed = true; + } const basePath = path.join(mainRoot, config.baseWorktreePath); const branchSelection = resolveWorktreeBranch( branchName, (command) => log(`Running: ${command}`), + // A name wt invented is meant to be a fresh branch off the base, so an + // unrelated origin branch that happens to match must not be adopted. + { base, skipOriginLookup: autoNamed }, ); if (branchSelection.originCheckError) { warn(`Failed to check origin for '${branchName}': ${branchSelection.originCheckError}`); } + if (base !== undefined) { + if (branchSelection.source === 'local-new') { + // The base is only used when we create a fresh branch; fail early on a typo. + assertRefExists(base); + } else if (options.base !== undefined) { + // Only mention the flag if the user actually passed one. + warn(`Branch '${branchName}' already exists; --base ${options.base} ignored.`); + } + } + + log(`Creating worktree for '${branchName}' in slot ${slot}...`); log(describeBranchSelection(branchSelection)); const dbName = calculateDbName(slot, config.baseDatabaseName); @@ -209,6 +260,18 @@ export async function createNewWorktree( try { removeWorktree(worktreePath!, (command) => log(`Rollback: ${command}`)); log(`Rollback: removed worktree at ${worktreePath!}.`); + + // An auto-named branch exists only because wt invented it, so leaving it + // behind is pure litter — and the next run's collision suffix would have + // to step over it. A branch the user named is theirs to keep. + if (autoNamed && branchSelection.source === 'local-new') { + try { + deleteBranch(branchName, (command) => log(`Rollback: ${command}`)); + log(`Rollback: deleted auto-named branch '${branchName}'.`); + } catch (branchErr) { + warn(`Rollback failed to delete branch '${branchName}': ${extractErrorMessage(branchErr)}`); + } + } } catch (rollbackErr) { warn(`Rollback failed to remove worktree at ${worktreePath!}: ${extractErrorMessage(rollbackErr)}`); } @@ -226,16 +289,16 @@ export async function createNewWorktree( } log(`Ready — slot ${slot}, branch '${actualBranch}'.`); - return { slot, allocation, branchSelection, portDrifts }; + return { slot, allocation, branchSelection, portDrifts, autoNamed }; } /** Create a new worktree with full environment isolation */ export async function newCommand( - branchName: string, + branchName: string | undefined, options: NewOptions, ): Promise { try { - const { slot, allocation, branchSelection, portDrifts } = await createNewWorktree(branchName, { + const { slot, allocation, branchSelection, portDrifts, autoNamed } = await createNewWorktree(branchName, { ...options, quiet: options.json, }); @@ -248,6 +311,8 @@ export async function newCommand( ...allocation, branchSource: branchSelection.source, branchSourceLabel: branchSelection.sourceLabel, + startPoint: branchSelection.startPoint ?? null, + autoNamed, portDrifts, }), ), @@ -277,6 +342,8 @@ function describeBranchSelection(branchSelection: WorktreeBranchSelection): stri case 'local-existing': return `Using existing local branch '${branchSelection.branchName}'.`; case 'local-new': - return `Using branch '${branchSelection.branchName}' as a fresh local branch.`; + return branchSelection.startPoint + ? `Using branch '${branchSelection.branchName}' as a fresh local branch from ${branchSelection.startPoint}.` + : `Using branch '${branchSelection.branchName}' as a fresh local branch.`; } } diff --git a/src/core/git.ts b/src/core/git.ts index 0436465..e049105 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -1,8 +1,24 @@ -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; +import * as os from 'node:os'; import * as path from 'node:path'; type CommandLogger = (command: string) => void; +/** + * The helpers below build shell command strings, always interpolating a ref + * inside double quotes. Within double quotes `sh` only treats `"`, backtick, + * `$` and `\` specially — and git happily accepts all four in a ref name — so + * refuse exactly those (plus control characters) instead of quoting around + * them. Everything else, including non-ASCII branch names, passes untouched. + */ +const UNSAFE_REF_PATTERN = /["`$\\\p{Cc}]/u; + +function assertSafeRef(ref: string, label: string): void { + if (ref.length === 0 || UNSAFE_REF_PATTERN.test(ref)) { + throw new Error(`${label} '${ref}' contains unsupported characters`); + } +} + export interface PrunableWorktree { readonly path: string; readonly reason: string; @@ -61,12 +77,28 @@ export function createWorktree( /** * Resolve which branch ref should back a new worktree. - * Prefers a fresh local branch that tracks origin when the remote branch exists. + * + * Precedence: an existing local branch is checked out as-is, else a matching + * `origin` branch is fetched and tracked, else a fresh local branch is created. + * Only that last "fresh local branch" case honours `opts.base` as the start + * point to fork from; when the branch already exists locally or on origin the + * base is inert (callers should surface that to the user). + * + * `opts.skipOriginLookup` forces the fresh-branch case: set it for a name wt + * invented itself, where adopting an unrelated remote branch that happens to + * share the name would be the opposite of what the caller asked for — and + * where probing origin is a pointless round trip. */ export function resolveWorktreeBranch( branchName: string, logCommand?: CommandLogger, + opts?: { base?: string; skipOriginLookup?: boolean }, ): WorktreeBranchSelection { + assertSafeRef(branchName, 'branch'); + if (opts?.base !== undefined) { + assertSafeRef(opts.base, 'base ref'); + } + if (branchExistsLocally(branchName)) { return { branchName, @@ -75,6 +107,15 @@ export function resolveWorktreeBranch( }; } + if (opts?.skipOriginLookup) { + return { + branchName, + source: 'local-new', + sourceLabel: 'fresh local branch', + startPoint: opts.base, + }; + } + try { if (branchExistsOnOrigin(branchName, logCommand)) { fetchOriginBranch(branchName, logCommand); @@ -90,6 +131,7 @@ export function resolveWorktreeBranch( branchName, source: 'local-new', sourceLabel: 'fresh local branch', + startPoint: opts?.base, originCheckError: extractCommandErrorMessage(err), }; } @@ -98,6 +140,7 @@ export function resolveWorktreeBranch( branchName, source: 'local-new', sourceLabel: 'fresh local branch', + startPoint: opts?.base, }; } @@ -111,6 +154,17 @@ export function removeWorktree( execSync(command, { stdio: 'pipe' }); } +/** + * Delete a local branch. Uses `-d`, so a branch carrying commits the base ref + * does not already have is refused rather than destroyed. + */ +export function deleteBranch(branchName: string, logCommand?: CommandLogger): void { + assertSafeRef(branchName, 'branch'); + const command = `git branch -d "${branchName}"`; + logCommand?.(command); + execSync(command, { stdio: 'pipe' }); +} + /** Get the current branch name for a worktree path */ export function getBranchName(worktreePath: string): string { return execSync('git rev-parse --abbrev-ref HEAD', { @@ -195,6 +249,105 @@ function branchExistsLocally(branchName: string): boolean { } } +/** + * Generate a throwaway branch name for `wt new` when no name is given, e.g. + * `main-20260723-nemanull`. The base, local date, and current user keep it + * greppable and unique; collisions with existing local branches get a numeric + * suffix (`-2`, `-3`, ...). + */ +export function generateAutoBranchName(base: string): string { + assertSafeRef(base, 'base ref'); + const stem = `${slugifyBase(base)}-${currentDateStamp()}-${currentUserSlug()}`; + let candidate = stem; + let suffix = 2; + while (branchExistsLocally(candidate)) { + candidate = `${stem}-${suffix}`; + suffix += 1; + } + return candidate; +} + +/** + * Throw a clean error if a git ref (branch, tag, or commit) does not resolve. + * Runs without a shell so an unresolvable ref can never be executed as one. + */ +export function assertRefExists(ref: string): void { + assertSafeRef(ref, 'base ref'); + try { + execFileSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { + stdio: 'pipe', + }); + } catch { + throw new Error(`base ref '${ref}' not found`); + } +} + +/** + * Reduce a base ref to a branch-name-safe stem — `origin/release/2.0` becomes + * `release-2.0`, `HEAD~2` becomes `HEAD-2` — so the generated name is always a + * name git will accept. Revision syntax that slugs away entirely falls back to + * a literal so the name never starts with a separator. + */ +function slugifyBase(base: string): string { + const slug = stripDiacritics(base.replace(/^origin\//, '')) + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^[-.]+|[-.]+$/g, ''); + return slug || 'base'; +} + +/** Local-time YYYYMMDD stamp (not UTC — matches the user's own calendar day). */ +function currentDateStamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +/** + * A branch-safe token identifying the current user. Prefers the OS login name + * (already a single safe token), falling back to a slugified `git config + * user.name`, and finally the literal `wt`. + */ +function currentUserSlug(): string { + try { + const slug = slugifyToken(os.userInfo().username); + if (slug) { + return slug; + } + } catch { + // os.userInfo() throws on some uid-less environments; fall through. + } + + try { + const gitName = execSync('git config user.name', { + encoding: 'utf-8', + stdio: 'pipe', + }).trim(); + const slug = slugifyToken(gitName); + if (slug) { + return slug; + } + } catch { + // No git identity configured; fall through to the default. + } + + return 'wt'; +} + +/** NFKD-normalize and drop combining marks, so `ü` reduces to `u`. */ +function stripDiacritics(raw: string): string { + return raw.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); +} + +/** Lowercase, strip diacritics, and reduce to a `[a-z0-9-]` branch-safe slug. */ +function slugifyToken(raw: string): string { + return stripDiacritics(raw) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + function buildWorktreeAddCommand( worktreePath: string, branch: WorktreeBranchSelection, @@ -205,7 +358,9 @@ function buildWorktreeAddCommand( case 'local-existing': return `git worktree add "${worktreePath}" "${branch.branchName}"`; case 'local-new': - return `git worktree add "${worktreePath}" -b "${branch.branchName}"`; + return branch.startPoint + ? `git worktree add "${worktreePath}" -b "${branch.branchName}" "${branch.startPoint}"` + : `git worktree add "${worktreePath}" -b "${branch.branchName}"`; } }