Skip to content

feat: name a new worktree in plain words - #413

Merged
omartelo merged 1 commit into
mainfrom
feat/session-auto-name
Aug 30, 2026
Merged

omartelo merged 1 commit into
mainfrom
feat/session-auto-name

Conversation

@omartelo

@omartelo omartelo commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

The New worktree dialog's name field only ever took a branch name: anything with a space was refused as Invalid branch name, so naming the work meant inventing the hyphens by hand — and the blank field, the path of least resistance, handed the branch a random calm-glacier.

That name is fine right up until it is the name on a pull request, and by then it cannot be fixed: renaming a branch through the GitHub API closes the PR that was open on it. So this settles the name at the only cheap moment there is — before the worktree exists.

Type fix the auth redirect and the branch is fix-the-auth-redirect, printed under the field before anything is created. A name git already accepts is still used exactly as typed; a blank field still auto-generates one.

How

toBranchName (frontend/src/lib/git/branch-name.ts) — pure, no model, no network:

  • git already accepts it → returned untouched (feat/x stays feat/x).
  • otherwise → lowercase, everything that is not a letter or a number in any script to separators (git takes a non-ASCII branch name, and stripping the accents off a phrase spells it wrong), grown one word at a time to at least 2 words / 10 chars (so a run of very short words still reads as a phrase) and stopped before 5 words / 40 chars (so a pasted sentence does not become the branch), joined with -.
  • nothing usable → "", which is the same blank the dialog auto-generates a name for.

The Invalid branch name state is deleted: there is no longer such a thing to type. The helper line under the field already printed the branch that would be created — it now prints the slug, so nothing is rewritten out of sight.

Where it came from, and what was left out

The growth loop is borrowed from JetBrains/thinkrail's naiveWorkspaceName (packages/server/src/assist). Their pattern has a second half — a cheap-model pass that refines the name from the session's first turn, plus the host flow that renames the workspace once that turn settles. Both are deliberately out:

  • No model pass. It costs a token round trip and a session per name, for a string the user is looking straight at and can edit.
  • No rename after the first turn. lich's worktree dialog has no prompt field — the first turn happens after the session exists — so the derived name could only arrive as a rename, and renaming the branch is exactly the move that kills the PR. thinkrail can afford it because it keeps a display name (Title Case, spaces) apart from the branch slug; lich has one string that is both, and deriving it at typing time means it never has to move.
  • No display/branch split. It only pays for itself if something renames, and nothing does.

The CLI and MCP paths (lich open --worktree <branch> --prompt <task>, open_session) are untouched: a blank worktree there means no worktree, and an agent opening one already names it knowing the task.

Test plan

  • frontend/src/lib/git/branch-name.test.ts — passthrough of names git takes, the slug, both minimums, both maximums, the clamp on a single over-long word, the trailing separator the clamp can land on, a non-ASCII phrase, and the empty results that fall back to auto-generate.
  • Gate green: gofmt -l ., go vet ./..., go test ./..., biome check ., vitest run (1224 tests), tsc -b, vite build.
  • Not covered: the dialog's own render path (the frontend gate is node-only). The change there is one deleted conditional and one placeholder string.

@omartelo
omartelo force-pushed the feat/session-auto-name branch from 01e749d to fb31097 Compare August 30, 2026 20:56
The name field only ever took a branch name: anything with a space in it
was refused, so naming the work meant inventing the hyphens by hand, and
the blank field — the path of least resistance — handed the branch a
random `calm-glacier`. That name is fine until it is the name on a pull
request, and by then it cannot be fixed: renaming a branch through the
GitHub API closes the PR that was open on it.

So the name is settled at the only cheap moment there is, before the
worktree exists. `toBranchName` keeps a name git already accepts exactly
as typed, and turns anything else into a kebab slug of its first few
words — grown to at least a minimum so a run of short words still reads,
stopped before a maximum in words and in characters so a pasted sentence
does not become the branch. The field's helper line already printed the
branch that would be created; it now prints the slug, so nothing is
rewritten out of sight.

The "Invalid branch name" state goes with it: there is no longer such a
thing to type. Nothing usable at all still means a blank, which is the
auto-generated name as before.

The growth loop is borrowed from JetBrains/thinkrail's
`naiveWorkspaceName`. Its other half — a cheap-model pass refining the
name from the session's first turn — is deliberately left out: lich's
dialog has no prompt to read, and the rename it would need is the move
that closes the PR.
@omartelo
omartelo force-pushed the feat/session-auto-name branch from fb31097 to 72c3513 Compare August 30, 2026 20:58
@omartelo
omartelo merged commit 3b96f6f into main Aug 30, 2026
4 checks passed
@omartelo
omartelo deleted the feat/session-auto-name branch August 30, 2026 21:05
omartelo added a commit that referenced this pull request Sep 5, 2026
The New worktree dialog's name field took a branch name or the task in
plain words. It takes an issue too now — "#128", or the URL copied out of
the browser — looked up through gh on the project's own account.

What that buys is the other half: the session opened on that worktree
finds the issue at its prompt. Number, title, link and body, as one
bracketed paste so the body's newlines are text rather than a run of
Enters, and unsent — the Enter stays the user's, as with the pull request
handoff. Typing the context in by hand was the only way an agent learned
what its worktree was for.

Everything the flow needs already existed: toBranchName slugs the title
and prints the exact branch under the field before anything is created
(#413), CreateWorktree makes an ordinary branch off the chosen base,
writeAtPrompt waits out the setup script and the provider's boot (#447),
and bracketedPaste is why the paste is one block. What is new is one gh
call, one pure module, and a row in the gh message table.

Two refusals, both because the field is a branch name first:

- A bare number stays a branch name. "#" is the marker, so there is one
  field with one meaning rather than a mode to be in.
- A pull request's number is refused, naming the screen that does open
  pull requests. GitHub counts issues and pull requests in one sequence
  and `gh issue view` answers for either; taking it would branch off the
  base under its title instead of checking its head out. The URL is the
  only field in the answer that tells the two apart.

And the reference itself never names the branch, resolved or not: git
accepts "#381" as a branch name, and most shells read the "#" as the
start of a comment, so `git checkout #381` is `git checkout`.

Claude-Session: https://claude.ai/code/session_01GSu6sRs6pBuZHmWaJmuZ3o
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant