feat: optional branch name and --base fork point - #12
Merged
Conversation
pkudinov
approved these changes
Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(new): optional branch name +
--basefork point forwt newWhy
Two gaps in
wt new:HEADhappened 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.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 newauto-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 toorigin/main, thenmain.wt new feat/loginorigin/feat/login→ else fresh branch offHEADwt new feat/login --base mainmainwt newmain-20260724-nemanull, forked fromorigin/mainwt new --base v1.0.0v1.0.0-20260724-nemanull, forked from the tagBranch 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,--baseis inert and says so: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 newhot path, and with it thefatal: 'origin' does not appear to be a git repositorywall in repos with no remote.Other auto-name details:
main-20260724-nemanull-2.origin/release/2.0→release-2.0,HEAD~2→HEAD-2.git config user.name, then the literalwt.origin/mainnormainresolves (amasterrepo, 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:
wt new x --slot 99fails here, before any git command runs. Previously this ran after branch resolution, so an unusable slot still cost anls-remoteround trip and could leave a newly written remote-tracking ref behind.--basefails withbase ref 'typo' not foundwhile there is still no database, Docker service, or worktree to clean up.--baseis not auto-fetched. It resolves against what you have locally, sogit fetchfirst (or pass--base origin/<branch>) to fork from a fresh remote tip. This is deliberate — an implicit network fetch insidewt newwould be a surprising side effect — but see the follow-ups, sincewt auditdoes 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 -bcreated 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, usinggit branch -dso a branch that somehow carries unmerged commits is refused rather than destroyed. User-named branches are untouched.Hardening (included here because
--baseis a new input)src/core/git.tsbuilds shell command strings, always interpolating refs inside double quotes — and git accepts ref names containing charactersshtreats specially in that position.--baseand 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, andassertRefExists— because the auto-name path reaches git beforeresolveWorktreeBranchdoes.assertRefExistsalso runs viaexecFileSyncwith an argv array, matchingsrc/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 --jsongains two fields (additive; existing fields unchanged):{ "startPoint": "v1.0.0", "autoNamed": false }startPointisnullwhen the branch forked fromHEAD.Testing
Unit — 199 passing (
pnpm test), lint andtscclean. 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,assertRefExistssuccess and failure, the ref rule in both directions (shell metacharacters refused, legal names accepted),deleteBranchusing-d, rollback deleting an auto-named branch but keeping a user-named one, and the no-default-base error. ThegenerateAutoBranchNametests pinos.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:
wt new→main-20260724-nemanullatorigin/main's tipwt new --base v1.0.0→ forked at the tag (worktree content isv1, notv2)wt new feat/login --base v1.0.0 --json→ correct fork point,startPoint/autoNamedpresentwt new local-only --base v1.0.0→ warns, ignores base, exits 0wt new feat/remote-only→ still fetches and tracksorigin/feat/remote-onlyls-remote, nofetchwt new feat/x --slot 99→ fails with zero git commands run and no remote-tracking ref writtenwt new feat/typo --base no-such-ref→ exit 1, and verified nothing leaked: no database created, registry unchanged, no worktree directory left behind--base(quote-escape,$(…), backticks), through both the named and auto-name paths → all refused, no injected command ran--basewith a remote-tracking ref, a raw SHA, andHEAD~1;--slot Nalongside an auto-named branch;master-only repo; repo with no remote; unicode and apostrophe branch names;wt openregression