Skip to content

feat: optional branch name and --base fork point - #12

Merged
pkudinov merged 1 commit into
mainfrom
feat/wt-new-base-and-auto-branch
Jul 25, 2026
Merged

feat: optional branch name and --base fork point#12
pkudinov merged 1 commit into
mainfrom
feat/wt-new-base-and-auto-branch

Conversation

@nemanull

Copy link
Copy Markdown
Collaborator

feat(new): optional branch name + --base fork point for wt new

Why

Two gaps in wt new:

  1. No way to choose the fork point. A brand-new branch always forked from whatever HEAD happened to be in the main worktree. If that worktree was parked on an unrelated branch, the new worktree silently inherited it — and you found out after the database, ports, and Docker services were already provisioned.
  2. You always had to invent a name. For a throwaway "let me try something clean off main" worktree, naming the branch is pure ceremony.

What changed

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

  • --base <ref> sets the start point for a newly created branch. Accepts anything a branch can fork from: a branch, tag, remote-tracking ref, or raw commit SHA.
  • [branch] is now optional. When omitted, wt new auto-names a throwaway branch from the base, the local date, and the current user — e.g. main-20260724-nemanull — and forks it off the base. The base defaults to origin/main, then main.
Command Result
wt new feat/login unchanged: checkout local → else track origin/feat/login → else fresh branch off HEAD
wt new feat/login --base main fresh branch forked from main
wt new auto-named main-20260724-nemanull, forked from origin/main
wt new --base v1.0.0 auto-named v1.0.0-20260724-nemanull, forked from the tag

Branch resolution precedence (unchanged, now documented)

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 case uses --base. When the branch already exists, --base is inert and says so:

Branch 'feat/login' already exists; --base main ignored.

That line appears only when the flag was actually passed, so an auto-named branch never reports ignoring a flag the user never typed.

Auto-named branches are always fresh

Because wt invents the name, the auto-name path skips the origin lookup entirely. A same-named branch on origin — a name pushed from another machine, or two developers sharing a generic container username on the same day — is never adopted in place of the fresh branch that was asked for. It also removes a network round trip from the bare wt new hot path, and with it the fatal: 'origin' does not appear to be a git repository wall in repos with no remote.

Other auto-name details:

  • Local collisions get a numeric suffix: main-20260724-nemanull-2.
  • The base is reduced to a name git will accept: origin/release/2.0release-2.0, HEAD~2HEAD-2.
  • The user token comes from the OS login name, falling back to a slugified git config user.name, then the literal wt.
  • When neither origin/main nor main resolves (a master repo, say), the error is actionable rather than borrowed from the audit code path: no default base found (looked for 'origin/main', then 'main'); pass --base <ref> or name a branch.

Ordering: cheapest checks first

Work is now ordered so each failure costs as little as possible:

  1. Slot selection — pure bookkeeping over the registry. wt new x --slot 99 fails here, before any git command runs. Previously this ran after branch resolution, so an unusable slot still cost an ls-remote round trip and could leave a newly written remote-tracking ref behind.
  2. Branch and base resolution — the only network step. A bad --base fails with base ref 'typo' not found while there is still no database, Docker service, or worktree to clean up.
  3. Everything else — worktree, database, Docker, env files.

--base is not auto-fetched. It resolves against what you have locally, so git fetch first (or pass --base origin/<branch>) to fork from a fresh remote tip. This is deliberate — an implicit network fetch inside wt new would be a surprising side effect — but see the follow-ups, since wt audit does auto-fetch.

Rollback cleans up branches it invented

The existing rollback tears down the worktree, database, and Docker services on a partial failure, but the branch git worktree add -b created survived. For a branch the user named that is defensible; for an auto-named throwaway it is litter, and the collision suffix then steps over it on every retry (-2, -3, …). Rollback now deletes an auto-named branch, using git branch -d so a branch that somehow carries unmerged commits is refused rather than destroyed. User-named branches are untouched.

Hardening (included here because --base is a new input)

src/core/git.ts builds shell command strings, always interpolating refs inside double quotes — and git accepts ref names containing characters sh treats specially in that position. --base and the branch argument are both user input, so refs are now rejected if they contain ", a backtick, $, \, or a control character: exactly the set that has meaning inside double quotes. Enforced at all three entry points where a ref reaches git — resolveWorktreeBranch, generateAutoBranchName, and assertRefExists — because the auto-name path reaches git before resolveWorktreeBranch does.

$ wt new --base 'main"; touch /tmp/pwn; echo "'
Failed to create worktree: base ref 'main"; touch /tmp/pwn; echo "' contains unsupported characters

assertRefExists also runs via execFileSync with an argv array, matching src/core/audit.ts, so an unresolvable ref is never handed to a shell at all.

Everything git itself allows still works, including non-ASCII: feat/ünïcode, fix/don't-panic, release/2.0, v1.2.3, HEAD~2, origin/main^{commit}, raw SHAs. Note this closes the branch-argument vector too, which predates this PR.

JSON output

wt new --json gains two fields (additive; existing fields unchanged):

{ "startPoint": "v1.0.0", "autoNamed": false }

startPoint is null when the branch forked from HEAD.

Testing

Unit — 199 passing (pnpm test), lint and tsc clean. New coverage: base recorded as start point, base inert on an existing branch, the skip-origin path for invented names, git worktree add … -b <name> <base> construction, auto-name shape/slugging/collision suffix, assertRefExists success and failure, the ref rule in both directions (shell metacharacters refused, legal names accepted), deleteBranch using -d, rollback deleting an auto-named branch but keeping a user-named one, and the no-default-base error. The generateAutoBranchName tests pin os.userInfo() so generated names do not depend on who runs the suite.

Live — exercised end to end against a real git repo (bare origin + clone, tag, origin-only branch, local-only branch) and a real Postgres in Docker, verifying git state and Postgres/registry side effects after each run:

  • bare wt newmain-20260724-nemanull at origin/main's tip
  • wt new --base v1.0.0 → forked at the tag (worktree content is v1, not v2)
  • wt new feat/login --base v1.0.0 --json → correct fork point, startPoint/autoNamed present
  • wt new local-only --base v1.0.0 → warns, ignores base, exits 0
  • wt new feat/remote-only → still fetches and tracks origin/feat/remote-only
  • auto-name colliding with a same-named origin branch → forks fresh from the base, no ls-remote, no fetch
  • wt new feat/x --slot 99 → fails with zero git commands run and no remote-tracking ref written
  • wt new feat/typo --base no-such-ref → exit 1, and verified nothing leaked: no database created, registry unchanged, no worktree directory left behind
  • forced mid-setup failure on an auto-named branch → worktree removed and branch deleted; same failure on a user-named branch → branch kept
  • injection attempts via both the branch argument and --base (quote-escape, $(…), backticks), through both the named and auto-name paths → all refused, no injected command ran
  • --base with a remote-tracking ref, a raw SHA, and HEAD~1; --slot N alongside an auto-named branch; master-only repo; repo with no remote; unicode and apostrophe branch names; wt open regression

@nemanull nemanull self-assigned this Jul 24, 2026
@nemanull nemanull added the enhancement New feature or request label Jul 24, 2026
@pkudinov
pkudinov requested a review from Copilot July 25, 2026 08:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pkudinov
pkudinov merged commit 1190c97 into main Jul 25, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants