179 lines
12 KiB
Markdown
179 lines
12 KiB
Markdown
# APP-3709: Auto-Generate Worktree Branch Names — Tech Spec
|
||
|
||
## Problem
|
||
|
||
`generate_worktree_branch_name()` in `new_worktree_modal.rs` uses a global `AtomicU32` counter to produce `worktree-1`, `worktree-2`, etc. These names are forgettable, collide across sessions, and provide no uniqueness guarantee against existing branches in the target repo. The product spec calls for a themed naming function that combines two random words from a 198-word desert/southwest vocabulary and guarantees uniqueness by escalating to more words on collision.
|
||
|
||
## Relevant Code
|
||
|
||
- `warp_util/src/worktree_names.rs` — pure naming module (word list, `generate_name`, `generate_unique_name`, `generate_worktree_branch_name`)
|
||
- `app/src/util/git.rs:57-78` — `list_local_branches_sync()` (synchronous branch listing)
|
||
- `app/src/tab_configs/tab_config.rs:62-91` — `TabConfigPaneNode` struct (`worktree_name_autogenerated`)
|
||
- `app/src/tab_configs/tab_config.rs:139-174` — `render_tab_config()` (threads `worktree_branch_name` through to resolution)
|
||
- `app/src/tab_configs/tab_config.rs:299-324` — `resolve_pane_node()` leaf branch (template variable substitution)
|
||
- `app/src/tab_configs/tab_config.rs:350-403` — `build_worktree_config_toml()` (TOML generation for both manual and autogenerate modes)
|
||
- `app/src/workspace/view.rs:7138-7153` — `maybe_generate_worktree_name()` (scans panes, fetches branches, generates name)
|
||
- `app/src/workspace/view.rs:7163-7230` — `handle_new_worktree_submit()` (writes TOML, opens config)
|
||
- `app/src/workspace/view.rs:4892-4958` — `open_tab_config_with_params()` / `open_tab_config()` (re-open path)
|
||
- `app/src/tab_configs/new_worktree_modal.rs:224-257` — `try_submit()` (emits `None` for autogenerate, `Some(name)` for manual)
|
||
|
||
## Current State
|
||
|
||
**Modal flow**: When the user clicks "Open" with autogenerate checked, `try_submit()` calls `generate_worktree_branch_name()` which returns `worktree-{N}`. The workspace handler then bakes this name directly into the TOML commands (`git worktree add -b worktree-1 ../worktree-1 main`) and writes it to `~/.warp/tab_configs/`.
|
||
|
||
**Saved config re-open flow**: When a saved worktree config is clicked in the menu, `open_tab_config()` → `open_tab_config_with_params()` → `render_tab_config()` → `resolve_pane_tree()` renders the commands. Currently the branch name is hardcoded in the TOML, so re-opening always tries to create the same branch — which fails if it already exists.
|
||
|
||
**Branch listing**: `DiffStateModel::get_all_branches()` in `diff_state.rs` runs `git for-each-ref --sort=-committerdate --format=%(refname:short) refs/heads` asynchronously. The `BranchPicker` uses this. There is no synchronous branch-listing utility.
|
||
|
||
**Rand usage**: The codebase uses `rand::seq::SliceRandom` with `rand::thread_rng()` (e.g. `agent_tips.rs:467`). `rand` is already a workspace dependency in `app/Cargo.toml`.
|
||
|
||
## Proposed Changes
|
||
|
||
### 1. Pure naming module in `warp_util`
|
||
|
||
New file: `warp_util/src/worktree_names.rs`. This module has zero I/O — it takes a set of existing branch names and returns a unique name. Keeping it in `warp_util` isolates it from the `app` crate's compile graph and keeps it trivially testable.
|
||
|
||
`rand` needs to be added as a dependency of `warp_util` (it's already a workspace dep).
|
||
|
||
**Word list**: A `const WORDS: &[&str]` array with the 198 desert/southwest words from the product spec, sorted alphabetically within each category section for readability.
|
||
|
||
**Core function** (pure, deterministic with a seeded RNG):
|
||
```rust
|
||
fn generate_name(
|
||
word_count: usize,
|
||
existing_branches: &HashSet<&str>,
|
||
rng: &mut impl rand::Rng,
|
||
) -> Option<String>
|
||
```
|
||
- Picks `word_count` distinct words at random from `WORDS`.
|
||
- Joins them with `-`.
|
||
- If the result is in `existing_branches`, retries (bounded by `MAX_RETRIES_PER_LEVEL = 2`).
|
||
- Returns `Some(name)` on success, `None` if all retries collided.
|
||
|
||
**Escalating uniqueness wrapper** (pure, deterministic with a seeded RNG):
|
||
```rust
|
||
pub fn generate_unique_name(
|
||
existing_branches: &HashSet<&str>,
|
||
rng: &mut impl rand::Rng,
|
||
) -> String
|
||
```
|
||
- Starts at `word_count = 2`.
|
||
- Calls `generate_name(word_count, existing_branches, rng)`.
|
||
- If `None` (all retries collided), increments `word_count` and retries.
|
||
- Cap at `word_count = 5` (198^5 ≈ 2.9 × 10^11 possibilities) as a safety bound, then fall back to appending a random numeric suffix.
|
||
|
||
**Convenience entry point** (uses thread_rng):
|
||
```rust
|
||
pub fn generate_worktree_branch_name(
|
||
existing_branches: &HashSet<&str>,
|
||
) -> String
|
||
```
|
||
- Calls `generate_unique_name(existing_branches, &mut rand::thread_rng())`.
|
||
- This is the API call sites use. The `rng`-parameterized version exists for deterministic testing.
|
||
|
||
### 2. Synchronous git branch listing in `app/src/util/git.rs`
|
||
|
||
New function alongside the existing async helpers:
|
||
```rust
|
||
#[cfg(feature = "local_fs")]
|
||
pub fn list_local_branches_sync(repo_path: &Path) -> HashSet<String>
|
||
```
|
||
- Runs `git branch --list --format=%(refname:short)` via `std::process::Command`.
|
||
- Returns a `HashSet<String>` of local branch names.
|
||
- On failure (not a git repo, git not found, etc.) returns an empty set.
|
||
|
||
**Why synchronous**: The call sites (`handle_new_worktree_submit`, `maybe_generate_worktree_name` in `open_tab_config`) are synchronous view handlers. `git branch --list` is a local filesystem read (packed-refs + loose refs) that completes in single-digit milliseconds even for repos with thousands of branches. A synchronous `std::process::Command` is the simplest approach and avoids threading async through the view layer.
|
||
|
||
### 3. Template variable substitution in commands
|
||
|
||
The `commands` array on `TabConfigPaneNode` already exists. The change is in how commands are rendered at open time.
|
||
|
||
When `worktree_branch_name` is provided, `render_tab_config` injects it into both the unquoted and quoted Handlebars context maps under the key `autogenerated_branch_name`. The existing `handlebars::render_template(cmd, quoted)` call in `resolve_pane_node` handles substitution — no custom `.replace()` needed. This is consistent with how all other tab config params (`{{branch}}`, `{{repo}}`, etc.) work.
|
||
|
||
The `commands` array in the TOML uses `{{autogenerated_branch_name}}` as a Handlebars template variable. Users can freely edit the commands — reorder them, add their own (e.g. `gt branch create`, `npm install`), or use `{{autogenerated_branch_name}}` in custom commands. The default commands written by the modal work out-of-the-box without any user editing.
|
||
|
||
### 4. Plumbing the generated name through `open_tab_config`
|
||
|
||
Modify `open_tab_config()` in `workspace/view.rs`: before rendering, scan the config's panes for any with `worktree_name_autogenerated = true`. If found:
|
||
|
||
1. Read the pane's `cwd` to determine the repo path.
|
||
2. Call `list_local_branches_sync(repo_path)` to get existing branches.
|
||
3. Call `generate_worktree_branch_name(&existing_branches)` to get a fresh name.
|
||
4. Pass the generated name into `render_tab_config` (new parameter), which injects it into the Handlebars context so `{{autogenerated_branch_name}}` in commands gets substituted.
|
||
|
||
`render_tab_config` gains an optional `worktree_branch_name: Option<&str>` parameter. When `Some`, the name is added to the template context; when `None`, no extra context is injected. This keeps the API change minimal for all non-worktree configs.
|
||
|
||
### 5. TOML generation in `handle_new_worktree_submit`
|
||
|
||
Change `handle_new_worktree_submit()` in `workspace/view.rs` to write the config with template variables in the commands:
|
||
```toml
|
||
[[panes]]
|
||
id = "main"
|
||
type = "terminal"
|
||
cwd = "/path/to/repo"
|
||
worktree_name_autogenerated = true
|
||
commands = [
|
||
"git worktree add -b {{autogenerated_branch_name}} ../{{autogenerated_branch_name}} main",
|
||
"cd ../{{autogenerated_branch_name}}",
|
||
]
|
||
```
|
||
|
||
The `commands` array uses `{{autogenerated_branch_name}}` — the same Handlebars syntax as all other tab config params. Users can edit the TOML to add custom commands (e.g. `gt branch create`, `npm install`) or reorder them.
|
||
|
||
### 6. Update modal and workspace handler call sites
|
||
|
||
In `new_worktree_modal.rs`:
|
||
- Remove `WORKTREE_COUNTER` and the old `generate_worktree_branch_name()`.
|
||
- `try_submit()` emits `worktree_branch_name: None` when autogenerate is on, `Some(name)` when the user typed a name manually. The modal does not generate names — it delegates that to the workspace handler.
|
||
|
||
In `workspace/view.rs`:
|
||
- `handle_new_worktree_submit()` receives `worktree_branch_name: Option<&str>`. When `None`, it calls `list_local_branches_sync` then `generate_worktree_branch_name` to produce a fresh name. This name is used both as the TOML filename hint and as the branch name for the initial open.
|
||
- `maybe_generate_worktree_name()` is a shared helper used by both `open_tab_config()` (re-open path) and `handle_tab_config_params_modal_body_event()` (params modal submit). It scans panes for `worktree_name_autogenerated = true`, fetches branches, and generates a name.
|
||
|
||
### 7. Module registration
|
||
|
||
Add `pub mod worktree_names;` to `warp_util/src/lib.rs`.
|
||
|
||
## End-to-End Flow
|
||
|
||
### Modal flow (new worktree)
|
||
1. User clicks "Open" with autogenerate checked.
|
||
2. `try_submit()` emits `Submit { repo, branch: "main", worktree_branch_name: None }`.
|
||
3. `handle_new_worktree_submit()` receives `None`, calls `list_local_branches_sync(repo_path)` to get existing branches.
|
||
4. Passes the branch set to `generate_worktree_branch_name(&branches)`. Naming function generates a 2-word name; on collision, retries at the same word count then escalates.
|
||
5. Returns e.g. `mesa-coyote`.
|
||
6. Workspace handler writes TOML with `worktree_name_autogenerated = true` and `commands` containing `{{autogenerated_branch_name}}` template variables via `build_worktree_config_toml`.
|
||
7. Parses the TOML back into a `TabConfig` and calls `open_tab_config_with_params` directly with the just-generated name — no second generation pass.
|
||
|
||
### Re-open flow (saved config)
|
||
1. User clicks a saved worktree config in the menu.
|
||
2. `open_tab_config()` scans panes, finds `worktree_name_autogenerated = true`.
|
||
3. Reads `cwd` from the pane to determine the repo path.
|
||
4. Calls `list_local_branches_sync(repo_path)` → `HashSet`.
|
||
5. Calls `generate_worktree_branch_name(&branches)` → e.g. `obsidian-monsoon`.
|
||
6. Passes the name into `render_tab_config(..., Some("obsidian-monsoon"))`.
|
||
7. `render_tab_config` injects `obsidian-monsoon` into the Handlebars context, and `resolve_pane_node` renders `{{autogenerated_branch_name}}` → `obsidian-monsoon` in all commands via normal Handlebars substitution.
|
||
8. New tab opens with the substituted commands.
|
||
|
||
## Risks and Mitigations
|
||
|
||
- **Synchronous git call on main thread**: `git branch --list` is a local filesystem read (packed-refs + loose refs). Completes in single-digit milliseconds even for repos with thousands of branches. If this ever becomes a concern, the function can be made async without changing the API surface.
|
||
- **Word list staleness**: The 198-word list is a compile-time constant. No runtime mechanism to update it. This is intentional — the list is a curated vocabulary, not a growing dictionary.
|
||
|
||
## Testing and Validation
|
||
|
||
- **Unit tests in `warp_util/src/worktree_names.rs`**:
|
||
- `generate_name` with a seeded RNG produces deterministic output.
|
||
- Generated names match `{word}-{word}` format, both words are in `WORDS`, words are distinct.
|
||
- `generate_unique_name` avoids all names in a provided `existing_branches` set.
|
||
- Escalation: pre-fill `existing_branches` with enough 2-word combos to force a 3-word name, verify output has 3 words.
|
||
- All words in `WORDS` are valid git branch name components (no spaces, no `..`, no control chars, no leading `-`).
|
||
- **Unit test for template substitution**: Verify that a pane with `worktree_name_autogenerated = true` and commands containing `{{autogenerated_branch_name}}` produces the expected substituted commands.
|
||
- **Unit test for custom commands**: Verify that user-added commands (with and without `{{autogenerated_branch_name}}`) are preserved and substituted correctly.
|
||
- **`cargo check`**: Verify no compilation errors after all changes.
|
||
|
||
## Follow-ups
|
||
|
||
- Add a test that exercises the synchronous `git branch --list` path against a real temporary git repo.
|
||
- Consider supporting a `worktree_path_template` field on the pane for users who want worktrees in a non-default location (e.g. `~/worktrees/{name}` instead of `../{name}`).
|