From ada92e95cb414436873d6d2c9aa154e430eaaf56 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sat, 25 Jul 2026 14:36:30 -0700 Subject: [PATCH 1/4] feat(worktree): accept a worktree path wherever a branch is accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wt had three answers to "what does this token mean?". `wt switch` took shortcuts, `pr:`/`mr:`, and a path — but only an absolute one or one with a separator. `wt remove` took shortcuts and any path. Everything else took a bare branch name, so `wt step diff --branch @` failed and `wt config state marker --branch @` silently keyed state under `@`. Fold the two path routes into one canonicalizer, `Repository::resolve_worktree`, and route every worktree-naming argument through it: branch first, then the worktree's own path, so a directory never shadows a branch that shares its name and a path names what a branch cannot — a detached worktree, or one of two checkouts of the same branch. `require_worktree` and `require_selected_branch` cover the two shapes callers need; the merge and rebase target validators fall through to the same path lookup, so a target can be named by the worktree it is checked out in. `resolve_input_path` now expands a leading `~`, making the tilde form worktrunk prints its own paths in a form it also reads back. Two silent no-ops go with it: `wt step relocate ` reported "all worktrees are at expected paths" for work that never happened, and a selector matching nothing was reported as a branch without a worktree, hinting at a `wt switch` that would only fail again. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- docs/content/merge.md | 2 +- docs/content/step.md | 20 +-- docs/content/switch.md | 10 +- .../skills/worktrunk/reference/merge.md | 2 +- .../skills/worktrunk/reference/step.md | 20 +-- .../skills/worktrunk/reference/switch.md | 10 +- skills/worktrunk/reference/merge.md | 2 +- skills/worktrunk/reference/step.md | 20 +-- skills/worktrunk/reference/switch.md | 10 +- src/cli/config.rs | 18 +- src/cli/mod.rs | 12 +- src/cli/step.rs | 20 +-- src/commands/config/state.rs | 18 +- src/commands/context.rs | 22 ++- src/commands/mod.rs | 3 +- src/commands/relocate.rs | 44 +++-- src/commands/remove.rs | 4 +- src/commands/step/commit.rs | 2 +- src/commands/step/copy_ignored.rs | 15 +- src/commands/step/diff.rs | 10 +- src/commands/step/promote.rs | 12 +- src/commands/worktree/mod.rs | 5 +- src/commands/worktree/resolve.rs | 57 +----- src/commands/worktree/switch.rs | 44 ++--- src/git/error.rs | 26 +++ src/git/repository/config.rs | 28 +++ src/git/repository/mod.rs | 6 +- src/git/repository/tests.rs | 143 +++++++++++++++ src/git/repository/worktrees.rs | 168 +++++++++++++----- src/path.rs | 49 ++++- tests/integration_tests/config_state.rs | 40 +++++ tests/integration_tests/remove.rs | 4 +- tests/integration_tests/step_diff.rs | 59 ++++++ tests/integration_tests/step_relocate.rs | 41 +++++ tests/integration_tests/switch.rs | 84 +++++++++ ...ntegration_tests__help__help_md_merge.snap | 3 +- ...egration_tests__help__help_merge_long.snap | 3 +- ...gration_tests__help__help_merge_short.snap | 4 +- ...n_tests__help__help_step_copy_ignored.snap | 5 +- ...ration_tests__help__help_step_promote.snap | 4 +- ...gration_tests__help__help_switch_long.snap | 12 +- ...ration_tests__help__help_switch_short.snap | 6 +- ...__step_commit_branch_flag_nonexistent.snap | 6 +- 44 files changed, 823 insertions(+), 252 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2af8f3a2fb..7531e8becd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Load relevant skills before starting; reload when scope changes mid-session. Pro ## Worktree Model -- Worktrees are **addressed by branch name**, not filesystem path. +- Worktrees are **addressed by branch name**; a worktree's own path is an accepted alias for it, resolved branch-first by `Repository::resolve_worktree` — the one canonicalizer every worktree-naming argument goes through. Paths never become a second addressing scheme: they name what a branch cannot (a detached worktree, one of two checkouts of a branch), and a new argument routes through the canonicalizer rather than growing its own rule. - Each worktree maps to **exactly one branch**. - **Never retarget an existing worktree** to a different branch; create/switch/remove instead. (Sole exception: `wt step promote`, experimental, exchanges branches between two worktrees.) diff --git a/docs/content/merge.md b/docs/content/merge.md index b2fd99104d..7c4530b22a 100644 --- a/docs/content/merge.md +++ b/docs/content/merge.md @@ -110,7 +110,7 @@ Usage: wt merge [OPTIONS] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. diff --git a/docs/content/step.md b/docs/content/step.md index 4af08d1cf5..bd6fa092c5 100644 --- a/docs/content/step.md +++ b/docs/content/step.md @@ -140,7 +140,7 @@ Usage: wt step commit [OPTIONS] Options: -b, --branch <BRANCH> - Branch to operate on (defaults to current worktree) + Branch or worktree path (defaults to current worktree) --stage <STAGE> What to stage before committing [default: all] @@ -233,7 +233,7 @@ Usage: wt step squash [OPTIONS] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -322,7 +322,7 @@ Usage: wt step rebase [OPTIONS] Arguments: [TARGET] - Target branch, tag, or commit + Target branch, tag, commit, or worktree path Defaults to default branch. @@ -385,7 +385,7 @@ Usage: wt step push [OPTIONS] < Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -467,7 +467,7 @@ Usage: wt step diff [OPTIONS] < Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -476,7 +476,7 @@ Usage: wt step diff [OPTIONS] < Options: -b, --branch <BRANCH> - Branch to operate on (defaults to current worktree) + Branch or worktree path (defaults to current worktree) -h, --help Print help (see a summary with '-h') @@ -608,12 +608,12 @@ Usage: wt step copy-ignored [OPTIONS]< Options: --from <FROM> - Source worktree branch + Source worktree branch or path Defaults to main worktree. --to <TO> - Destination worktree branch + Destination worktree branch or path Defaults to current worktree. @@ -876,7 +876,7 @@ Usage: wt step promote [OPTIONS]Arguments: [BRANCH] - Branch to promote to main worktree + Branch or worktree path to promote to main worktree Defaults to current branch, or default branch from main worktree. @@ -1055,7 +1055,7 @@ Usage: wt step relocate [OPTIONS]Arguments: [BRANCHES]... - Worktrees to relocate (defaults to all mismatched) + Worktrees to relocate, by branch or path (defaults to all mismatched) Options: --dry-run diff --git a/docs/content/switch.md b/docs/content/switch.md index e1deedd5f0..773306a578 100644 --- a/docs/content/switch.md +++ b/docs/content/switch.md @@ -40,6 +40,12 @@ If the branch already has a worktree, `wt switch` changes directories to it. Oth {{ terminal(cmd="wt switch feature # Existing branch → creates worktree|||wt switch --create feature # New branch and worktree|||wt switch --create fix --base release # New branch from release|||wt switch --create temp --no-hooks # Skip hooks") }} +## Naming a worktree + +A worktree answers to its branch name and to its own path, so `wt switch feature` and `wt switch ../repo.feature` reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. + +Every argument that names a worktree resolves this way — `wt remove`, `wt merge`, `wt step diff --branch`, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against `-C`, and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. + ## Shortcuts | Shortcut | Meaning | @@ -149,7 +155,7 @@ Usage: wt switch [OPTIONS] Arguments: [BRANCH] - Branch name, shortcut, or PR/MR URL + Branch, worktree path, shortcut, or PR/MR URL Opens interactive picker if omitted. Shortcuts: ^ (default branch), - (previous), @ (current), pr:{N} (GitHub PR), mr:{N} (GitLab MR) @@ -165,7 +171,7 @@ Usage: wt switch [OPTIONS] -b, --base <BASE> - Base branch + Base branch or worktree path Defaults to default branch. Supports the same shortcuts as the branch argument: ^, @, -, pr:{N}, mr:{N}. diff --git a/plugins/worktrunk/skills/worktrunk/reference/merge.md b/plugins/worktrunk/skills/worktrunk/reference/merge.md index 7781639f43..9f8cf0c611 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/merge.md +++ b/plugins/worktrunk/skills/worktrunk/reference/merge.md @@ -101,7 +101,7 @@ Usage: wt merge [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. diff --git a/plugins/worktrunk/skills/worktrunk/reference/step.md b/plugins/worktrunk/skills/worktrunk/reference/step.md index 631953640e..b0d1a0bf24 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/step.md +++ b/plugins/worktrunk/skills/worktrunk/reference/step.md @@ -135,7 +135,7 @@ Usage: wt step commit [OPTIONS] Options: -b, --branch - Branch to operate on (defaults to current worktree) + Branch or worktree path (defaults to current worktree) --stage What to stage before committing [default: all] @@ -232,7 +232,7 @@ Usage: wt step squash [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -325,7 +325,7 @@ Usage: wt step rebase [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch, tag, or commit + Target branch, tag, commit, or worktree path Defaults to default branch. @@ -392,7 +392,7 @@ Usage: wt step push [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -486,7 +486,7 @@ Usage: wt step diff [OPTIONS] [TARGET] [-- ...] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -495,7 +495,7 @@ Arguments: Options: -b, --branch - Branch to operate on (defaults to current worktree) + Branch or worktree path (defaults to current worktree) -h, --help Print help (see a summary with '-h') @@ -627,12 +627,12 @@ Usage: wt step copy-ignored [OPTIONS] Options: --from - Source worktree branch + Source worktree branch or path Defaults to main worktree. --to - Destination worktree branch + Destination worktree branch or path Defaults to current worktree. @@ -914,7 +914,7 @@ Usage: wt step promote [OPTIONS] [BRANCH] Arguments: [BRANCH] - Branch to promote to main worktree + Branch or worktree path to promote to main worktree Defaults to current branch, or default branch from main worktree. @@ -1108,7 +1108,7 @@ Usage: wt step relocate [OPTIONS] [BRANCHES]... Arguments: [BRANCHES]... - Worktrees to relocate (defaults to all mismatched) + Worktrees to relocate, by branch or path (defaults to all mismatched) Options: --dry-run diff --git a/plugins/worktrunk/skills/worktrunk/reference/switch.md b/plugins/worktrunk/skills/worktrunk/reference/switch.md index c9520a63bd..94604ed400 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/switch.md +++ b/plugins/worktrunk/skills/worktrunk/reference/switch.md @@ -36,6 +36,12 @@ $ wt switch --create fix --base release # New branch from release $ wt switch --create temp --no-hooks # Skip hooks ``` +## Naming a worktree + +A worktree answers to its branch name and to its own path, so `wt switch feature` and `wt switch ../repo.feature` reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. + +Every argument that names a worktree resolves this way — `wt remove`, `wt merge`, `wt step diff --branch`, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against `-C`, and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. + ## Shortcuts | Shortcut | Meaning | @@ -145,7 +151,7 @@ Usage: wt switch [OPTIONS] [BRANCH] [-- ...] Arguments: [BRANCH] - Branch name, shortcut, or PR/MR URL + Branch, worktree path, shortcut, or PR/MR URL Opens interactive picker if omitted. Shortcuts: ^ (default branch), - (previous), @ (current), pr:{N} (GitHub PR), mr:{N} (GitLab MR) @@ -161,7 +167,7 @@ Options: Create a new branch -b, --base - Base branch + Base branch or worktree path Defaults to default branch. Supports the same shortcuts as the branch argument: ^, @, -, pr:{N}, mr:{N}. diff --git a/skills/worktrunk/reference/merge.md b/skills/worktrunk/reference/merge.md index 7781639f43..9f8cf0c611 100644 --- a/skills/worktrunk/reference/merge.md +++ b/skills/worktrunk/reference/merge.md @@ -101,7 +101,7 @@ Usage: wt merge [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. diff --git a/skills/worktrunk/reference/step.md b/skills/worktrunk/reference/step.md index 631953640e..b0d1a0bf24 100644 --- a/skills/worktrunk/reference/step.md +++ b/skills/worktrunk/reference/step.md @@ -135,7 +135,7 @@ Usage: wt step commit [OPTIONS] Options: -b, --branch - Branch to operate on (defaults to current worktree) + Branch or worktree path (defaults to current worktree) --stage What to stage before committing [default: all] @@ -232,7 +232,7 @@ Usage: wt step squash [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -325,7 +325,7 @@ Usage: wt step rebase [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch, tag, or commit + Target branch, tag, commit, or worktree path Defaults to default branch. @@ -392,7 +392,7 @@ Usage: wt step push [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -486,7 +486,7 @@ Usage: wt step diff [OPTIONS] [TARGET] [-- ...] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. @@ -495,7 +495,7 @@ Arguments: Options: -b, --branch - Branch to operate on (defaults to current worktree) + Branch or worktree path (defaults to current worktree) -h, --help Print help (see a summary with '-h') @@ -627,12 +627,12 @@ Usage: wt step copy-ignored [OPTIONS] Options: --from - Source worktree branch + Source worktree branch or path Defaults to main worktree. --to - Destination worktree branch + Destination worktree branch or path Defaults to current worktree. @@ -914,7 +914,7 @@ Usage: wt step promote [OPTIONS] [BRANCH] Arguments: [BRANCH] - Branch to promote to main worktree + Branch or worktree path to promote to main worktree Defaults to current branch, or default branch from main worktree. @@ -1108,7 +1108,7 @@ Usage: wt step relocate [OPTIONS] [BRANCHES]... Arguments: [BRANCHES]... - Worktrees to relocate (defaults to all mismatched) + Worktrees to relocate, by branch or path (defaults to all mismatched) Options: --dry-run diff --git a/skills/worktrunk/reference/switch.md b/skills/worktrunk/reference/switch.md index c9520a63bd..94604ed400 100644 --- a/skills/worktrunk/reference/switch.md +++ b/skills/worktrunk/reference/switch.md @@ -36,6 +36,12 @@ $ wt switch --create fix --base release # New branch from release $ wt switch --create temp --no-hooks # Skip hooks ``` +## Naming a worktree + +A worktree answers to its branch name and to its own path, so `wt switch feature` and `wt switch ../repo.feature` reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. + +Every argument that names a worktree resolves this way — `wt remove`, `wt merge`, `wt step diff --branch`, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against `-C`, and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. + ## Shortcuts | Shortcut | Meaning | @@ -145,7 +151,7 @@ Usage: wt switch [OPTIONS] [BRANCH] [-- ...] Arguments: [BRANCH] - Branch name, shortcut, or PR/MR URL + Branch, worktree path, shortcut, or PR/MR URL Opens interactive picker if omitted. Shortcuts: ^ (default branch), - (previous), @ (current), pr:{N} (GitHub PR), mr:{N} (GitLab MR) @@ -161,7 +167,7 @@ Options: Create a new branch -b, --base - Base branch + Base branch or worktree path Defaults to default branch. Supports the same shortcuts as the branch argument: ^, @, -, pr:{N}, mr:{N}. diff --git a/src/cli/config.rs b/src/cli/config.rs index a881e640a0..c05dcab37e 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -1173,7 +1173,7 @@ $ wt config state ci-status clear && wt config state ci-status get ```"# )] Get { - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1196,7 +1196,7 @@ Clear all CI status cache: $ wt config state ci-status clear --all ```"#)] Clear { - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch, conflicts_with = "all")] branch: Option, @@ -1222,7 +1222,7 @@ Get marker for a specific branch: $ wt config state marker get --branch=feature ```"#)] Get { - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1243,7 +1243,7 @@ $ wt config state marker set "✅ ready" --branch=feature /// Marker text (shown in `wt list` output) value: String, - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1266,7 +1266,7 @@ Clear all markers: $ wt config state marker clear --all ```"#)] Clear { - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch, conflicts_with = "all")] branch: Option, @@ -1465,7 +1465,7 @@ $ wt config state vars get env --branch=feature /// Key name key: String, - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1483,7 +1483,7 @@ List keys for a specific branch: $ wt config state vars list --branch=feature ```"#)] List { - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, @@ -1514,7 +1514,7 @@ $ wt config state vars set env=production --branch=main #[arg(value_name = "KEY=VALUE", value_parser = super::parse_vars_assignment)] assignment: (String, String), - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1545,7 +1545,7 @@ $ wt config state vars clear env --branch=feature #[arg(long)] all: bool, - /// Target branch (defaults to current) + /// Branch or worktree path (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3357c96983..0876b2d7fa 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -342,7 +342,7 @@ impl HookFlags { #[derive(Args)] pub(crate) struct SwitchArgs { - /// Branch name, shortcut, or PR/MR URL + /// Branch, worktree path, shortcut, or PR/MR URL /// /// Opens interactive picker if omitted. /// Shortcuts: `^` (default branch), `-` (previous), `@` (current), `pr:{N}` (GitHub PR), `mr:{N}` (GitLab MR) @@ -365,7 +365,7 @@ pub(crate) struct SwitchArgs { #[arg(short = 'c', long, requires = "branch")] pub(crate) create: bool, - /// Base branch + /// Base branch or worktree path /// /// Defaults to default branch. Supports the same shortcuts as the branch /// argument: `^`, `@`, `-`, `pr:{N}`, `mr:{N}`. @@ -526,7 +526,7 @@ pub(crate) struct RemoveArgs { #[derive(Args)] pub(crate) struct MergeArgs { - /// Target branch + /// Target branch or worktree path /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] @@ -642,6 +642,12 @@ $ wt switch --create fix --base release # New branch from release $ wt switch --create temp --no-hooks # Skip hooks ``` +## Naming a worktree + +A worktree answers to its branch name and to its own path, so `wt switch feature` and `wt switch ../repo.feature` reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. + +Every argument that names a worktree resolves this way — `wt remove`, `wt merge`, `wt step diff --branch`, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against `-C`, and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. + ## Shortcuts | Shortcut | Meaning | diff --git a/src/cli/step.rs b/src/cli/step.rs index 6be3b7dc4e..80d8283cc6 100644 --- a/src/cli/step.rs +++ b/src/cli/step.rs @@ -2,7 +2,7 @@ use clap::{Args, Subcommand}; #[derive(Args)] pub struct CommitArgs { - /// Branch to operate on (defaults to current worktree) + /// Branch or worktree path (defaults to current worktree) #[arg(short, long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] pub(crate) branch: Option, @@ -30,7 +30,7 @@ pub struct CommitArgs { #[derive(Args)] pub struct SquashArgs { - /// Target branch + /// Target branch or worktree path /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] @@ -183,7 +183,7 @@ A conflicting commit leaves the rebase open rather than undoing it. The worktree "# )] Rebase { - /// Target branch, tag, or commit + /// Target branch, tag, commit, or worktree path /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] @@ -218,7 +218,7 @@ A worktree that is still registered but whose directory is gone is refused as we "# )] Push { - /// Target branch + /// Target branch or worktree path /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] @@ -285,13 +285,13 @@ $ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state defau "# )] Diff { - /// Target branch + /// Target branch or worktree path /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] target: Option, - /// Branch to operate on (defaults to current worktree) + /// Branch or worktree path (defaults to current worktree) #[arg(short, long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, @@ -397,13 +397,13 @@ The `.worktreeinclude` pattern is shared with [Claude Code on desktop](https://c - worktrunk runs as a configurable hook in the worktree lifecycle "#)] CopyIgnored { - /// Source worktree branch + /// Source worktree branch or path /// /// Defaults to main worktree. #[arg(long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] from: Option, - /// Destination worktree branch + /// Destination worktree branch or path /// /// Defaults to current worktree. #[arg(long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] @@ -587,7 +587,7 @@ The swap uses `rename()` for each entry — fast regardless of entry size, since "# )] Promote { - /// Branch to promote to main worktree + /// Branch or worktree path to promote to main worktree /// /// Defaults to current branch, or default branch from main worktree. #[arg(add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] @@ -713,7 +713,7 @@ refuses), unless `--commit` is passed. - **Detached HEAD** — no branch to compute expected path "#)] Relocate { - /// Worktrees to relocate (defaults to all mismatched) + /// Worktrees to relocate, by branch or path (defaults to all mismatched) #[arg(add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] branches: Vec, diff --git a/src/commands/config/state.rs b/src/commands/config/state.rs index 4183aa68b3..b1584c79e5 100644 --- a/src/commands/config/state.rs +++ b/src/commands/config/state.rs @@ -723,7 +723,7 @@ pub fn handle_state_get( }, "marker" => { let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "get marker")?, None => repo.require_current_branch("get marker for current branch")?, }; if format == SwitchFormat::Json { @@ -756,7 +756,7 @@ pub fn handle_state_get( } "ci-status" => { let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "get ci-status")?, None => repo.require_current_branch("get ci-status for current branch")?, }; @@ -859,7 +859,7 @@ pub fn handle_state_set(key: &str, value: String, branch: Option) -> any } "marker" => { let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "set marker")?, None => repo.require_current_branch("set marker for current branch")?, }; @@ -917,7 +917,7 @@ pub fn handle_state_clear(key: &str, branch: Option, all: bool) -> anyho } else { // Clear CI status for specific branch let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "clear ci-status")?, None => repo.require_current_branch("clear ci-status for current branch")?, }; if CachedCiStatus::clear_one(&repo, &branch_name)? { @@ -949,7 +949,7 @@ pub fn handle_state_clear(key: &str, branch: Option, all: bool) -> anyho } } else { let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "clear marker")?, None => repo.require_current_branch("clear marker for current branch")?, }; @@ -1638,7 +1638,7 @@ pub fn handle_vars_get(key: &str, branch: Option) -> anyhow::Result<()> validate_vars_key(key)?; let repo = Repository::current()?; let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "get variable")?, None => repo.require_current_branch("get variable for current branch")?, }; @@ -1654,7 +1654,7 @@ pub fn handle_vars_set(key: &str, value: &str, branch: Option) -> anyhow validate_vars_key(key)?; let repo = Repository::current()?; let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "set variable")?, None => repo.require_current_branch("set variable for current branch")?, }; @@ -1672,7 +1672,7 @@ pub fn handle_vars_set(key: &str, value: &str, branch: Option) -> anyhow pub fn handle_vars_list(branch: Option, format: SwitchFormat) -> anyhow::Result<()> { let repo = Repository::current()?; let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "list variables")?, None => repo.require_current_branch("list variables for current branch")?, }; @@ -1705,7 +1705,7 @@ pub fn handle_vars_clear( ) -> anyhow::Result<()> { let repo = Repository::current()?; let branch_name = match branch { - Some(b) => b, + Some(b) => repo.require_selected_branch(&b, "clear variable")?, None => repo.require_current_branch("clear variable for current branch")?, }; diff --git a/src/commands/context.rs b/src/commands/context.rs index 72ee2e860e..fd3953ca0e 100644 --- a/src/commands/context.rs +++ b/src/commands/context.rs @@ -46,22 +46,30 @@ impl CommandEnv { }) } - /// Load the command environment for a named worktree (by branch name). + /// Load the command environment for a named worktree. /// - /// Resolves the worktree path from the branch name rather than the current + /// Resolves the worktree from the selector rather than the current /// directory, and roots `repo` at that worktree — so a command run with /// `--branch ` (e.g. `wt step commit --branch `) and its hooks /// (`pre-commit` / `post-commit`) operate on, and resolve `.config/wt.toml` /// from, ``'s worktree rather than the cwd. See the `commands::hooks` /// module docs for the hook config-resolution rule. - pub fn for_branch(config: UserConfig, branch: &str) -> anyhow::Result { - let worktree_path = Repository::current()? - .worktree_for_branch(branch)? - .ok_or_else(|| anyhow::anyhow!("no worktree for branch '{branch}'"))?; + /// + /// `branch` carries the resolved branch, not the selector, so a worktree + /// named by path expands `{{ branch }}` to the branch checked out there. + pub fn for_selector(config: UserConfig, selector: &str) -> anyhow::Result { + let repo = Repository::current()?; + let worktree_path = repo.require_worktree(selector)?; + // Re-read the branch off the resolved worktree rather than the + // selector, which may have been the path. The lookup is against the + // cached worktree list `require_worktree` just walked. + let branch = repo + .worktree_at_path(&worktree_path)? + .and_then(|(_, branch)| branch); Ok(Self { repo: Repository::at(&worktree_path)?, - branch: Some(branch.to_string()), + branch, config, worktree_path, }) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 63f3d96b10..e369e55e39 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -64,8 +64,7 @@ pub(crate) use step::{ step_relocate, step_show_squash_prompt, step_tether, }; pub(crate) use worktree::{ - handle_switch_command, is_worktree_at_expected_path, resolve_worktree_arg, - worktree_display_name, + handle_switch_command, is_worktree_at_expected_path, worktree_display_name, }; // Re-export Shell from the canonical location diff --git a/src/commands/relocate.rs b/src/commands/relocate.rs index c845744624..ab0b38b55f 100644 --- a/src/commands/relocate.rs +++ b/src/commands/relocate.rs @@ -23,7 +23,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use anyhow::Context; +use anyhow::{Context, bail}; use color_print::cformat; use worktrunk::config::UserConfig; use worktrunk::git::{ErrorExt, Repository, WorktreeInfo, format_unresolved_conflicts}; @@ -153,18 +153,42 @@ pub fn gather_candidates( .cloned() .collect(); - // Filter to requested branches if any were specified + // Filter to the requested worktrees, if any. Each argument is a selector, so + // it resolves the same way everywhere else. Every way an argument can fail to + // land on a relocatable worktree is an error: dropping it instead leaves an + // empty candidate list, which renders as "all worktrees are at expected + // paths" — a success message for work that never happened. let worktrees: Vec<_> = if filter_branches.is_empty() { worktrees } else { - worktrees - .into_iter() - .filter(|wt| { - wt.branch - .as_ref() - .is_some_and(|b| filter_branches.iter().any(|arg| arg == b)) - }) - .collect() + let mut selected: Vec = Vec::new(); + for arg in filter_branches { + let path = repo.require_worktree(arg)?; + let Some(wt) = worktrees.iter().find(|wt| paths_match(&path, &wt.path)) else { + // Resolved, but pruned out above: its directory is gone, so + // there is nothing to move. + bail!( + "{}", + cformat!( + "Cannot relocate worktree @ {} — its directory is gone; run wt step prune to clear the entry", + format_path_for_display(&path) + ) + ); + }; + if wt.branch.is_none() { + bail!( + "{}", + cformat!( + "Cannot relocate detached worktree @ {} — the worktree-path template needs a branch name", + format_path_for_display(&path) + ) + ); + } + if !selected.iter().any(|s| paths_match(&s.path, &wt.path)) { + selected.push(wt.clone()); + } + } + selected }; // Find mismatched worktrees diff --git a/src/commands/remove.rs b/src/commands/remove.rs index 3ee69bf0de..7bcbbd16f5 100644 --- a/src/commands/remove.rs +++ b/src/commands/remove.rs @@ -17,7 +17,7 @@ use super::hook_plan::{ApprovedHookPlan, HookPlanBuilder}; use super::hooks::HookAnnouncer; use super::repository_ext::RepositoryCliExt; use super::worktree::RemoveResult; -use super::{RemoveTarget, flag_pair, resolve_worktree_arg}; +use super::{RemoveTarget, flag_pair}; /// Validated removal plans, categorized for ordered execution. /// @@ -92,7 +92,7 @@ fn validate_remove_targets( }; for branch_name in &branches { - let resolved = match resolve_worktree_arg(repo, branch_name) { + let resolved = match repo.resolve_worktree(branch_name) { Ok(r) => r, Err(e) => { plans.record_error(e); diff --git a/src/commands/step/commit.rs b/src/commands/step/commit.rs index 2491737f22..7e7d132d26 100644 --- a/src/commands/step/commit.rs +++ b/src/commands/step/commit.rs @@ -40,7 +40,7 @@ pub fn step_commit( let _ = crate::output::prompt_commit_generation(&mut config); let env = match branch { - Some(ref b) => CommandEnv::for_branch(config, b)?, + Some(ref b) => CommandEnv::for_selector(config, b)?, None => CommandEnv::for_action(config)?, }; let ctx = env.context(yes); diff --git a/src/commands/step/copy_ignored.rs b/src/commands/step/copy_ignored.rs index 9551b26b8c..419dcf1332 100644 --- a/src/commands/step/copy_ignored.rs +++ b/src/commands/step/copy_ignored.rs @@ -44,14 +44,7 @@ pub fn step_copy_ignored( // Resolve source and destination worktree paths let (source_path, source_context) = match from { - Some(branch) => { - let path = repo.worktree_for_branch(branch)?.ok_or_else(|| { - worktrunk::git::GitError::WorktreeNotFound { - branch: branch.to_string(), - } - })?; - (path, branch.to_string()) - } + Some(branch) => (repo.require_worktree(branch)?, branch.to_string()), None => { // Default source is the primary worktree (main worktree for normal repos, // default branch worktree for bare repos). @@ -69,11 +62,7 @@ pub fn step_copy_ignored( }; let dest_path = match to { - Some(branch) => repo.worktree_for_branch(branch)?.ok_or_else(|| { - worktrunk::git::GitError::WorktreeNotFound { - branch: branch.to_string(), - } - })?, + Some(branch) => repo.require_worktree(branch)?, None => repo.current_worktree().root()?, }; diff --git a/src/commands/step/diff.rs b/src/commands/step/diff.rs index 340d606a93..0d6cec13cb 100644 --- a/src/commands/step/diff.rs +++ b/src/commands/step/diff.rs @@ -20,15 +20,7 @@ pub fn step_diff( extra_args: &[String], ) -> anyhow::Result<()> { let repo = match branch { - Some(b) => { - let worktree_path = - Repository::current()? - .worktree_for_branch(b)? - .ok_or_else(|| worktrunk::git::GitError::WorktreeNotFound { - branch: b.to_string(), - })?; - Repository::at(&worktree_path)? - } + Some(b) => Repository::at(&Repository::current()?.require_worktree(b)?)?, None => Repository::current()?, }; let wt = repo.current_worktree(); diff --git a/src/commands/step/promote.rs b/src/commands/step/promote.rs index de3f6e27cf..1c63302b31 100644 --- a/src/commands/step/promote.rs +++ b/src/commands/step/promote.rs @@ -169,15 +169,19 @@ pub enum PromoteResult { AlreadyInMain(String), } -/// Resolve the branch to promote when no explicit argument was passed. +/// Resolve the branch to promote. /// -/// From the main worktree, restore the default branch. From a linked worktree, -/// promote the current branch. +/// An explicit argument goes through the worktree selector, so the branch can be +/// named by its worktree's path as well as by name. With no argument: from the +/// main worktree, restore the default branch; from a linked worktree, promote +/// the current branch. fn resolve_target_branch(branch: Option<&str>, repo: &Repository) -> anyhow::Result { use worktrunk::git::GitError; if let Some(b) = branch { - return Ok(b.to_string()); + // Promoting swaps two branches between worktrees, so a detached + // worktree has nothing to swap in. + return repo.require_selected_branch(b, "promote"); } let current_wt = repo.current_worktree(); diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index f98853f3b3..e809013bb0 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -91,10 +91,7 @@ mod types; // Re-export public types and functions pub use finish::{FinishAfterMergeArgs, finish_after_merge}; pub use push::{PushKind, PushOutcome, PushResult, handle_no_ff_merge, handle_push}; -pub use resolve::{ - compute_worktree_path, is_worktree_at_expected_path, resolve_worktree_arg, - worktree_display_name, -}; +pub use resolve::{compute_worktree_path, is_worktree_at_expected_path, worktree_display_name}; pub(crate) use switch::SwitchPipeline; pub use switch::handle_switch_command; pub use types::{MergeOperations, RemoveResult, SwitchBranchInfo, SwitchResult}; diff --git a/src/commands/worktree/resolve.rs b/src/commands/worktree/resolve.rs index da82feac7d..2374bd4ced 100644 --- a/src/commands/worktree/resolve.rs +++ b/src/commands/worktree/resolve.rs @@ -1,6 +1,9 @@ -//! Worktree resolution and path computation. +//! Worktree path computation and display. //! -//! Functions for resolving worktree arguments and computing expected paths. +//! Where a worktree *belongs* — the `worktree-path` template, the expected-path +//! check that names a worktree in output, and the bare-repo template prompt. +//! Turning what the user typed into a worktree is the opposite direction and +//! lives in [`Repository::resolve_worktree`](worktrunk::git::Repository::resolve_worktree). use std::io::IsTerminal; use std::path::PathBuf; @@ -8,7 +11,7 @@ use std::path::PathBuf; use color_print::cformat; use normalize_path::NormalizePath; use worktrunk::config::UserConfig; -use worktrunk::git::{Repository, ResolvedWorktree, resolve_input_path}; +use worktrunk::git::Repository; use worktrunk::path::{format_path_for_display, paths_match}; use worktrunk::styling::{ eprintln, format_toml, hint_message, info_message, success_message, warning_message, @@ -16,54 +19,6 @@ use worktrunk::styling::{ use crate::output::prompt::{PromptResponse, prompt_yes_no_preview}; -/// Resolve a worktree argument using branch-first lookup. -/// -/// Resolution order: -/// 1. Special symbols ("@", "-", "^") are handled specially -/// 2. Resolve argument as branch name -/// 3. If branch has a worktree, return it -/// 4. Fall back to path-based lookup (supports detached worktrees) -/// 5. Otherwise, return branch-only (no worktree) -/// -/// If branch lookup fails to find a worktree, the argument is tried as a -/// filesystem path — absolute, or relative to the directory wt was pointed at -/// (`-C`, else the process cwd). This supports removing detached HEAD worktrees -/// which have no branch name. -pub fn resolve_worktree_arg(repo: &Repository, name: &str) -> anyhow::Result { - // Special symbols - delegate to Repository for consistent error handling - match name { - "@" | "-" | "^" => { - return repo.resolve_worktree(name); - } - _ => {} - } - - // Resolve as branch name - let branch = repo.resolve_worktree_name(name)?; - - // Branch-first: check if branch has worktree anywhere - if let Some(path) = repo.worktree_for_branch(&branch)? { - return Ok(ResolvedWorktree::Worktree { - path, - branch: Some(branch), - }); - } - - // No worktree for branch - fall back to path-based lookup (supports detached - // worktrees). A relative path resolves against `-C`, like git's own path - // arguments — see `resolve_input_path`. - let abs_path = resolve_input_path(name); - if let Some((path, wt_branch)) = repo.worktree_at_path(&abs_path)? { - return Ok(ResolvedWorktree::Worktree { - path, - branch: wt_branch, - }); - } - - // No worktree for branch and no worktree at the path - Ok(ResolvedWorktree::BranchOnly { branch }) -} - /// Compute the expected worktree path for a branch name. /// /// For the default branch, returns the repo root (main worktree location). diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index c419fb8c3b..bf686ac465 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -22,7 +22,7 @@ use worktrunk::git::remote_ref::{ }; use worktrunk::git::{ GitError, GitRemoteUrl, RefContext, RefType, Repository, SwitchSuggestionCtx, - current_or_recover, resolve_input_path, + current_or_recover, }; use worktrunk::shell_exec::{ShellEscapeMode, directive_shell_escape_mode, shell_escape_for}; use worktrunk::styling::{ @@ -507,6 +507,13 @@ fn resolve_base_ref( if remotes.len() == 1 { return Ok((format!("{}/{}", remotes[0], resolved), None)); } + // Neither a ref nor a branch on a remote: the base may be named by the + // path of the worktree it is checked out in, as targets elsewhere are. + if resolved == base + && let Some((_, Some(branch))) = repo.worktree_at_input_path(base)? + { + return Ok((branch, None)); + } } Ok((resolved, None)) @@ -851,26 +858,21 @@ fn plan_switch( None => {} } - // Phase 2b: Path-based fallback for detached worktrees. - // If the argument looks like a path (not a branch name), try to find a worktree there. - if !create { - let candidate = Path::new(branch); - // Absolute, or relative with directory separators (e.g. "../repo.feature"); - // a single-component name is ambiguous with a branch name (already tried in - // Phase 2), so it stays branch-only. A relative path resolves against `-C`, - // like git's own path arguments — see `resolve_input_path`. - let looks_like_path = candidate.is_absolute() || candidate.components().count() > 1; - let abs_path = looks_like_path.then(|| resolve_input_path(candidate)); - if let Some(abs_path) = abs_path - && let Some((path, wt_branch)) = repo.worktree_at_path(&abs_path)? - { - let canonical = canonicalize(&path).unwrap_or_else(|_| path.clone()); - return Ok(SwitchPlan::Existing { - path: canonical, - branch: wt_branch, - new_previous, - }); - } + // Phase 2b: the argument as the worktree's own path — the way to name a + // detached worktree, which has no branch. Not under `--create`, where the + // argument is the name of a branch that does not exist yet, and not when + // Phase 1 rewrote the argument (a shortcut, `pr:`/`mr:`, a stripped remote + // prefix), which is exactly when the literal token would be a nonsense path. + if !create + && target.branch == branch + && let Some((path, wt_branch)) = repo.worktree_at_input_path(branch)? + { + let canonical = canonicalize(&path).unwrap_or_else(|_| path.clone()); + return Ok(SwitchPlan::Existing { + path: canonical, + branch: wt_branch, + new_previous, + }); } // Phase 3: Compute expected path (only needed for create) diff --git a/src/git/error.rs b/src/git/error.rs index 670047b27b..8810d80284 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -592,6 +592,16 @@ pub enum GitError { WorktreeNotFound { branch: String, }, + /// A worktree selector matched neither a branch nor a worktree path. + /// + /// Distinct from [`GitError::WorktreeNotFound`], which means the branch + /// exists and simply has no checkout — there, suggesting `wt switch` to + /// create one is right. Here wt cannot tell whether the user meant a branch + /// or a path, and `wt switch ` would only fail + /// again, so the message asks for neither. + WorktreeSelectorNotFound { + selector: String, + }, /// --create flag used with pr:/mr: syntax (conflict - branch already exists) RefCreateConflict { ref_type: RefType, @@ -839,6 +849,10 @@ impl GitError { cformat!("Branch {branch} has no worktree") } + GitError::WorktreeSelectorNotFound { selector } => { + cformat!("No branch or worktree named {selector}") + } + GitError::RefCreateConflict { ref_type, number, @@ -1377,6 +1391,18 @@ impl GitError { ) } + GitError::WorktreeSelectorNotFound { .. } => { + let title = self.title(); + write!( + f, + "{}\n{}", + error_message(&title), + hint_message(cformat!( + "To see branches and worktree paths, run wt list --branches" + )) + ) + } + GitError::RefCreateConflict { ref_type, number, .. } => { diff --git a/src/git/repository/config.rs b/src/git/repository/config.rs index 91e42aa1f5..7cfb091025 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -453,6 +453,28 @@ impl Repository { } } + /// The branch checked out at `target`, when `target` is a worktree path. + /// + /// The path arm of a merge or rebase target: `wt merge ../repo.main` names + /// the same branch as `wt merge main`. Refs win, so this runs only once the + /// caller's own ref lookup has failed, and only for a target the user typed + /// — a default branch that resolved from the cache is not a path. + /// + /// `resolved` is `target` after shortcut expansion; an expansion means the + /// literal token was a symbol rather than a path. + fn target_branch_at_path( + &self, + target: Option<&str>, + resolved: &str, + ) -> anyhow::Result> { + let Some(target) = target.filter(|t| *t == resolved) else { + return Ok(None); + }; + Ok(self + .worktree_at_input_path(target)? + .and_then(|(_, branch)| branch)) + } + /// Resolve and validate a target that must be a branch. /// /// Use this for commands that update a branch ref (merge, push). @@ -466,6 +488,9 @@ impl Repository { pub fn require_target_branch(&self, target: Option<&str>) -> anyhow::Result { let branch = self.resolve_target_branch(target)?; if !self.branch(&branch).exists()? { + if let Some(from_path) = self.target_branch_at_path(target, &branch)? { + return Ok(from_path); + } if target.is_none() { if self.is_unborn_branch(&branch) { return Err(GitError::UnbornDefaultBranch { branch }.into()); @@ -495,6 +520,9 @@ impl Repository { pub fn require_target_ref(&self, target: Option<&str>) -> anyhow::Result { let reference = self.resolve_target_branch(target)?; if !self.ref_exists(&reference)? { + if let Some(from_path) = self.target_branch_at_path(target, &reference)? { + return Ok(from_path); + } if target.is_none() { if self.is_unborn_branch(&reference) { return Err(GitError::UnbornDefaultBranch { branch: reference }.into()); diff --git a/src/git/repository/mod.rs b/src/git/repository/mod.rs index 63a8006d34..5846c10ff2 100644 --- a/src/git/repository/mod.rs +++ b/src/git/repository/mod.rs @@ -487,6 +487,9 @@ fn base_path() -> &'static PathBuf { /// as does every path when `-C` was not given — the process cwd already resolves /// those, and joining `.` onto them would surface as a stray `./` in output. /// +/// A leading `~` expands first (see [`crate::path::expand_tilde`]), so the tilde form wt +/// prints its own paths in is a form wt also accepts. +/// /// This is the one resolution point for those paths, so they cannot drift /// apart: worktree path arguments (`wt switch ../repo.feature`), `--config`, /// `WORKTRUNK_CONFIG_PATH`, `WORKTRUNK_SYSTEM_CONFIG_PATH`, and the trace file @@ -501,9 +504,10 @@ fn base_path() -> &'static PathBuf { /// expanded by the generated shell wrapper, and the XDG spec already requires /// the second to be absolute. pub fn resolve_input_path(path: impl AsRef) -> PathBuf { + let path = crate::path::expand_tilde(path.as_ref()); match BASE_PATH.get() { Some(base) => base.join(path), - None => path.as_ref().to_path_buf(), + None => path.into_owned(), } } diff --git a/src/git/repository/tests.rs b/src/git/repository/tests.rs index a4f00df29f..2968ac245a 100644 --- a/src/git/repository/tests.rs +++ b/src/git/repository/tests.rs @@ -1160,3 +1160,146 @@ fn prewarm_still_caches_preload_when_worktree_config_disabled() { "prewarm should preload normal repos (no extensions.worktreeConfig)" ); } + +/// A worktree answers to its branch and to its own path, and the branch wins. +/// +/// Both spellings reaching the same worktree is the point of routing every +/// worktree argument through one canonicalizer; branch-first is what keeps a +/// directory from shadowing a branch that shares its name. +#[test] +fn resolve_worktree_accepts_branch_and_path() { + use crate::git::ResolvedWorktree; + use crate::testing::TestRepo; + use dunce::canonicalize; + + let mut test = TestRepo::with_initial_commit(); + let worktree_path = test.add_worktree("feature"); + + // A relative path resolves against `-C`, which only a spawned `wt` has — + // `switch::switch_by_relative_worktree_path` covers that spelling. + for selector in ["feature", worktree_path.to_str().unwrap()] { + let resolved = test.repo.resolve_worktree(selector).unwrap(); + let ResolvedWorktree::Worktree { path, branch } = resolved else { + panic!("{selector} should resolve to a worktree"); + }; + assert_eq!(canonicalize(&path).unwrap(), worktree_path); + assert_eq!(branch.as_deref(), Some("feature")); + } +} + +/// A directory whose name matches a branch does not shadow it: `wt switch docs` +/// means the branch even when `docs/` is also a worktree. +#[test] +fn resolve_worktree_prefers_branch_over_same_named_directory() { + use crate::git::ResolvedWorktree; + use crate::testing::TestRepo; + use dunce::canonicalize; + + let mut test = TestRepo::with_initial_commit(); + let branch_worktree = test.add_worktree("docs"); + // A second worktree literally at `/docs`, on a different branch. + let nested = test.root_path().join("docs"); + test.add_worktree_at_path("docs-nested", &nested); + + let ResolvedWorktree::Worktree { path, branch } = test.repo.resolve_worktree("docs").unwrap() + else { + panic!("docs should resolve to a worktree"); + }; + assert_eq!(branch.as_deref(), Some("docs")); + assert_eq!(canonicalize(&path).unwrap(), branch_worktree); +} + +/// A detached worktree has no branch to be named by, so its path is the only +/// selector that reaches it. +#[test] +fn resolve_worktree_reaches_detached_worktree_by_path() { + use crate::git::ResolvedWorktree; + use crate::testing::TestRepo; + use dunce::canonicalize; + + let mut test = TestRepo::with_initial_commit(); + let worktree_path = test.add_worktree("feature"); + test.detach_head_in_worktree("feature"); + + let ResolvedWorktree::Worktree { path, branch } = test + .repo + .resolve_worktree(worktree_path.to_str().unwrap()) + .unwrap() + else { + panic!("a detached worktree should resolve by path"); + }; + assert_eq!(canonicalize(&path).unwrap(), worktree_path); + assert_eq!(branch, None); + + // And it is the one case `require_selected_branch` refuses. + let err = test + .repo + .require_selected_branch(worktree_path.to_str().unwrap(), "promote") + .unwrap_err(); + assert!( + err.to_string().contains("detached"), + "expected a detached-HEAD error, got: {err}" + ); +} + +/// `-` and `^` expand to a branch, so the literal token never reaches the path +/// lookup — a directory named `-` cannot hijack `wt switch -`. +#[test] +fn resolve_worktree_does_not_treat_shortcuts_as_paths() { + use crate::git::ResolvedWorktree; + use crate::testing::TestRepo; + + let mut test = TestRepo::with_initial_commit(); + let default_branch = test.repo.default_branch().unwrap(); + // A worktree literally at `/^`, which `^` must not resolve to. + let decoy = test.root_path().join("^"); + test.add_worktree_at_path("decoy", &decoy); + + let resolved = test.repo.resolve_worktree("^").unwrap(); + let branch = match resolved { + ResolvedWorktree::Worktree { branch, .. } => branch, + ResolvedWorktree::BranchOnly { branch } => Some(branch), + }; + assert_eq!(branch.as_deref(), Some(default_branch.as_str())); +} + +/// A name matching neither a branch nor a worktree path is branch-only, which +/// is what lets `wt remove` still delete a worktree-less branch. +#[test] +fn resolve_worktree_falls_through_to_branch_only() { + use crate::git::ResolvedWorktree; + use crate::testing::TestRepo; + + let test = TestRepo::with_initial_commit(); + + let resolved = test.repo.resolve_worktree("../nowhere").unwrap(); + let ResolvedWorktree::BranchOnly { branch } = resolved else { + panic!("an unmatched selector should resolve to branch-only"); + }; + assert_eq!(branch, "../nowhere"); + + // A selector matching nothing names neither, so the error claims neither — + // suggesting `wt switch ../nowhere` to create a worktree would only fail. + let err = test.repo.require_worktree("../nowhere").unwrap_err(); + assert!( + err.to_string().contains("No branch or worktree named"), + "expected an unmatched-selector error, got: {err}" + ); +} + +/// A branch that exists without a checkout is the one case where offering to +/// create a worktree is right, so it keeps the distinct message and hint. +#[test] +fn require_worktree_distinguishes_a_branch_with_no_checkout() { + use crate::testing::TestRepo; + + let test = TestRepo::with_initial_commit(); + test.run_git(&["branch", "worktreeless"]); + + let err = test.repo.require_worktree("worktreeless").unwrap_err(); + let rendered = format!("{err}"); + assert!( + rendered.contains("has no worktree"), + "expected the branch-without-worktree error, got: {rendered}" + ); +} diff --git a/src/git/repository/worktrees.rs b/src/git/repository/worktrees.rs index 664823db1a..6466a70a52 100644 --- a/src/git/repository/worktrees.rs +++ b/src/git/repository/worktrees.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use color_print::cformat; use dunce::canonicalize; -use super::{GitError, Repository, ResolvedWorktree, WorktreeInfo}; +use super::{GitError, Repository, ResolvedWorktree, WorktreeInfo, resolve_input_path}; use crate::path::{format_path_for_display, paths_match}; impl Repository { @@ -227,57 +227,141 @@ impl Repository { } } - /// Resolve a worktree by name, returning its path and branch (if known). + /// Resolve a worktree selector — the one place a token the user typed + /// becomes a worktree. /// - /// Unlike `resolve_worktree_name` which returns a branch name, this returns - /// the worktree path directly. This is useful for commands like `wt remove` - /// that operate on worktrees, not branches. + /// Every argument that names a worktree routes through here, so they all + /// accept the same vocabulary: the shortcuts, a branch name, and the path + /// of the worktree itself. wt addresses worktrees by branch (see the + /// "Worktree Model" section of `CLAUDE.md`), so the branch is tried first + /// and a path only answers what a branch name cannot — a detached worktree, + /// or one of several checkouts of the same branch. /// - /// # Arguments - /// * `name` - The worktree name to resolve: - /// - "@" for current worktree (works even in detached HEAD) - /// - "-" for previous branch's worktree - /// - "^" for the default branch's worktree - /// - any other string is treated as a branch name + /// Resolution order: + /// 1. `@` — the current worktree, matched by path so detached HEAD resolves + /// 2. `-` / `^` — the previous / default branch, then as a branch below + /// 3. a branch with a worktree + /// 4. a path naming a registered worktree — absolute, `~`-relative, or + /// relative to `-C` (see [`resolve_input_path`]) + /// 5. otherwise the branch alone, which may or may not exist /// /// # Returns - /// - `Worktree { path, branch }` if a worktree exists - /// - `BranchOnly { branch }` if only the branch exists (no worktree) - /// - `Err` if neither worktree nor branch exists + /// - `Worktree { path, branch }` — `branch` is `None` for a detached worktree + /// - `BranchOnly { branch }` when nothing is checked out under that name pub fn resolve_worktree(&self, name: &str) -> anyhow::Result { - match name { - "@" => { - // Current worktree by path - works even in detached HEAD - // If worktree_root fails (e.g., in bare repo directory), give a clear error - let path = self - .current_worktree() - .root() - .map_err(|_| GitError::NotInWorktree { - action: Some("resolve @".into()), - })?; - // root() returns canonicalized path, so canonicalize worktree paths - // for comparison to handle symlinks (e.g., macOS /var -> /private/var) - let worktrees = self.list_worktrees()?; - let branch = worktrees - .iter() - .find(|wt| canonicalize(&wt.path).map(|p| p == path).unwrap_or(false)) - .and_then(|wt| wt.branch.clone()); - Ok(ResolvedWorktree::Worktree { path, branch }) + if name == "@" { + // Current worktree by path - works even in detached HEAD + // If worktree_root fails (e.g., in bare repo directory), give a clear error + let path = self + .current_worktree() + .root() + .map_err(|_| GitError::NotInWorktree { + action: Some("resolve @".into()), + })?; + // root() returns canonicalized path, so canonicalize worktree paths + // for comparison to handle symlinks (e.g., macOS /var -> /private/var) + let worktrees = self.list_worktrees()?; + let branch = worktrees + .iter() + .find(|wt| canonicalize(&wt.path).map(|p| p == path).unwrap_or(false)) + .and_then(|wt| wt.branch.clone()); + return Ok(ResolvedWorktree::Worktree { path, branch }); + } + + let branch = self.resolve_worktree_name(name)?; + if let Some(path) = self.worktree_for_branch(&branch)? { + return Ok(ResolvedWorktree::Worktree { + path, + branch: Some(branch), + }); + } + + // A shortcut named a branch, not a directory: `resolve_worktree_name` + // returns a non-shortcut token unchanged, so an unequal result is + // exactly the case where the literal token would be a nonsense path. + if branch == name + && let Some((path, wt_branch)) = self.worktree_at_input_path(name)? + { + return Ok(ResolvedWorktree::Worktree { + path, + branch: wt_branch, + }); + } + + Ok(ResolvedWorktree::BranchOnly { branch }) + } + + /// The branch a selector names, erroring only when it names a detached + /// worktree. + /// + /// [`resolve_worktree`](Self::resolve_worktree) for the arguments that want + /// a branch rather than a worktree — `wt step promote`, and the `--branch` + /// of the `wt config state` commands, which key state by branch name. A + /// branch with no worktree is a fine answer to those; a worktree named by + /// path that has no branch is not. + pub fn require_selected_branch(&self, name: &str, action: &str) -> anyhow::Result { + match self.resolve_worktree(name)? { + ResolvedWorktree::Worktree { + branch: Some(branch), + .. } - _ => { - // Resolve to branch name first, then find its worktree - let branch = self.resolve_worktree_name(name)?; - match self.worktree_for_branch(&branch)? { - Some(path) => Ok(ResolvedWorktree::Worktree { - path, - branch: Some(branch), - }), - None => Ok(ResolvedWorktree::BranchOnly { branch }), - } + | ResolvedWorktree::BranchOnly { branch } => Ok(branch), + ResolvedWorktree::Worktree { path, branch: None } => Err(GitError::DetachedHead { + action: Some(cformat!( + "{action} — {} is detached", + format_path_for_display(&path) + )), } + .into()), } } + /// The path of the worktree a selector names, erroring when it names none. + /// + /// [`resolve_worktree`](Self::resolve_worktree) for the commands that need a + /// worktree to operate in rather than a branch to reason about — `wt step + /// diff --branch`, `copy-ignored --from`/`--to`, `promote`. A branch with no + /// checkout and a name matching nothing at all are the same answer to them. + /// A selector matching nothing at all is reported as such, rather than as a + /// branch without a worktree: `wt switch ` creates a worktree + /// only when the branch exists, so offering it for a mistyped path would + /// just fail again. + pub fn require_worktree(&self, name: &str) -> anyhow::Result { + match self.resolve_worktree(name)? { + ResolvedWorktree::Worktree { path, .. } => Ok(path), + ResolvedWorktree::BranchOnly { branch } => Err(self.no_worktree_error(branch)), + } + } + + /// The error for a selector that resolved to a branch with no checkout. + fn no_worktree_error(&self, branch: String) -> anyhow::Error { + match self.branch(&branch).exists_locally() { + Ok(true) => GitError::WorktreeNotFound { branch }.into(), + // A ref lookup that fails says nothing about the branch, so fall + // back to the message that claims less. + _ => GitError::WorktreeSelectorNotFound { selector: branch }.into(), + } + } + + /// The worktree a user-supplied token names by path, if it names one. + /// + /// The path half of [`resolve_worktree`](Self::resolve_worktree), split out + /// for arguments that want a branch rather than a worktree — a merge target, + /// a state key. Resolving the token is the whole point: it goes through + /// [`resolve_input_path`] here so no call site + /// has to remember that a relative path answers to `-C` and a leading `~` to + /// the home directory. + /// + /// Branch-first is the rule everywhere, so callers reach for this only after + /// a branch or ref lookup has already come up empty. `branch` is `None` for + /// a detached worktree. + pub fn worktree_at_input_path( + &self, + name: &str, + ) -> anyhow::Result)>> { + self.worktree_at_path(&resolve_input_path(name)) + } + /// Find the "home" path - where to cd when leaving a worktree. /// /// Returns the primary worktree if it exists, otherwise the repo root. diff --git a/src/path.rs b/src/path.rs index 0afae10ab2..4300cec979 100644 --- a/src/path.rs +++ b/src/path.rs @@ -136,6 +136,27 @@ pub fn format_path_for_display(path: &Path) -> String { } } +/// Expand a leading `~` to the user's home directory. +/// +/// The inverse of [`format_path_for_display`], which renders home-relative paths +/// in tilde form. wt prints those paths in its own status lines and hints, so a +/// user pasting one back — quoted, where the shell leaves `~` alone — names the +/// same directory wt named. Paths wt derives itself are already absolute and +/// pass through untouched. +/// +/// Only a bare `~` or a leading `~/` expands. `~user` is a shell feature that +/// resolves another account's home directory, which wt does not reimplement, so +/// it stays a literal relative path — as does a `~` anywhere but the front. +pub fn expand_tilde(path: &Path) -> Cow<'_, Path> { + let Ok(rest) = path.strip_prefix("~") else { + return Cow::Borrowed(path); + }; + let Some(home) = home_dir() else { + return Cow::Borrowed(path); + }; + Cow::Owned(home.join(rest)) +} + /// Canonicalize a path, resolving parent symlinks even if the path doesn't exist. /// /// For existing paths, uses standard canonicalization. @@ -218,10 +239,36 @@ mod tests { use std::path::PathBuf; use super::{ - canonicalize_with_parents, format_path_for_display, home_dir, paths_match, + canonicalize_with_parents, expand_tilde, format_path_for_display, home_dir, paths_match, sanitize_for_filename, to_posix_path, }; + /// The tilde form `format_path_for_display` prints is a form wt reads back, + /// so a path from wt's own output can be pasted into a wt command. + #[test] + fn expand_tilde_round_trips_displayed_paths() { + let Some(home) = home_dir() else { + // Skip if HOME/USERPROFILE is not set in the environment + return; + }; + + let path = home.join("workspace").join("repo.feature"); + let displayed = format_path_for_display(&path); + assert_eq!(expand_tilde(&PathBuf::from(displayed)), path); + + assert_eq!(expand_tilde(&PathBuf::from("~")), home); + } + + /// Only a leading `~` component expands: `~user` is a shell feature wt does + /// not reimplement, and a tilde mid-path is an ordinary directory name. + #[test] + fn expand_tilde_leaves_other_tildes_alone() { + for literal in ["~user/repo", "sub/~/repo", "repo~", "../repo.feature"] { + let path = PathBuf::from(literal); + assert_eq!(expand_tilde(&path), path, "{literal} should not expand"); + } + } + #[test] fn shortens_path_under_home() { let Some(home) = home_dir() else { diff --git a/tests/integration_tests/config_state.rs b/tests/integration_tests/config_state.rs index 714a24eee9..80792a01d4 100644 --- a/tests/integration_tests/config_state.rs +++ b/tests/integration_tests/config_state.rs @@ -3178,3 +3178,43 @@ fn test_format_rejected_on_write_action_writes_verbose_diagnostic(repo: TestRepo "diagnostic should be written for post-dispatch clap errors" ); } + +/// `--branch` takes a selector, so `@` means the current branch rather than a +/// state key literally named `@` — the state commands share one vocabulary with +/// the rest of wt instead of storing whatever token was typed. +#[rstest] +fn state_branch_flag_resolves_selectors(mut repo: TestRepo) { + let worktree = repo.add_worktree("feature"); + + let set = repo + .wt_command() + .current_dir(&worktree) + .args(["config", "state", "marker", "set", "wip", "--branch", "@"]) + .output() + .unwrap(); + assert!( + set.status.success(), + "setting a marker via @ should succeed: {}", + String::from_utf8_lossy(&set.stderr) + ); + + // Read it back from elsewhere, naming the same branch by its worktree path. + let get = repo + .wt_command() + .args([ + "config", + "state", + "marker", + "get", + "--branch", + worktree.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&get.stdout).trim(), + "wip", + "the marker set via @ should read back via the worktree's path: {}", + String::from_utf8_lossy(&get.stderr) + ); +} diff --git a/tests/integration_tests/remove.rs b/tests/integration_tests/remove.rs index 4e412a3cd6..983227256f 100644 --- a/tests/integration_tests/remove.rs +++ b/tests/integration_tests/remove.rs @@ -2545,8 +2545,8 @@ fn test_remove_detached_worktree_by_path(mut repo: TestRepo) { } /// Verify that detached worktrees can be removed by relative path. -/// This tests `resolve_worktree_arg`'s path resolution, which here runs from a -/// cwd inside the repo. +/// This tests `Repository::resolve_worktree`'s path resolution, which here runs +/// from a cwd inside the repo. #[rstest] fn test_remove_detached_worktree_by_relative_path(mut repo: TestRepo) { repo.add_worktree("feature-detached"); diff --git a/tests/integration_tests/step_diff.rs b/tests/integration_tests/step_diff.rs index b54dc82a5e..4bb1ee4972 100644 --- a/tests/integration_tests/step_diff.rs +++ b/tests/integration_tests/step_diff.rs @@ -199,3 +199,62 @@ fn git_status(repo: &TestRepo, dir: &Path) -> String { .unwrap(); String::from_utf8_lossy(&output.stdout).to_string() } + +/// `--branch` and the target both take a selector: a worktree's path names the +/// worktree to diff, and names the branch to diff against. +#[rstest] +fn step_diff_accepts_worktree_paths(mut repo: TestRepo) { + let feature_path = setup_feature_with_commit(&mut repo); + let other_path = repo.add_worktree("other"); + + let by_branch = repo + .wt_command() + .args(["step", "diff", "--branch", "feature"]) + .output() + .unwrap(); + let by_path = repo + .wt_command() + .args(["step", "diff", "--branch", feature_path.to_str().unwrap()]) + .output() + .unwrap(); + assert!(by_branch.status.success() && by_path.status.success()); + assert_eq!( + by_branch.stdout, by_path.stdout, + "the branch and its worktree's path should name the same worktree" + ); + + // And the positional target, which is a ref rather than a worktree. + let target_by_path = repo + .wt_command() + .current_dir(&feature_path) + .args(["step", "diff", other_path.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + target_by_path.status.success(), + "a worktree path should name the branch checked out there: {}", + String::from_utf8_lossy(&target_by_path.stderr) + ); +} + +/// `@` resolves to the current branch, the same as everywhere else in wt. +#[rstest] +fn step_diff_branch_flag_accepts_shortcut(mut repo: TestRepo) { + let feature_path = setup_feature_with_commit(&mut repo); + + let output = repo + .wt_command() + .current_dir(&feature_path) + .args(["step", "diff", "--branch", "@"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "--branch @ should resolve to the current worktree: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("feature.txt"), + "the diff should be the current worktree's" + ); +} diff --git a/tests/integration_tests/step_relocate.rs b/tests/integration_tests/step_relocate.rs index a3d0df57a2..575874648c 100644 --- a/tests/integration_tests/step_relocate.rs +++ b/tests/integration_tests/step_relocate.rs @@ -1213,3 +1213,44 @@ fn test_relocate_preserves_subdir(repo: TestRepo) { "CD file should contain relocated subdirectory path {expected_str}, got: {cd_content}" ); } + +/// An argument naming no worktree is an error. Matching it against branch names +/// alone left a typo filtering everything out, and the empty result rendered as +/// "All worktrees are at expected paths" — a success message for a no-op. +#[rstest] +fn step_relocate_rejects_unknown_worktree(repo: TestRepo) { + let output = repo + .wt_command() + .args(["step", "relocate", "--dry-run", "no-such-worktree"]) + .output() + .unwrap(); + + assert!(!output.status.success(), "an unknown argument should fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("No branch or worktree named"), + "expected an unmatched-selector error, got: {stderr}" + ); +} + +/// A detached worktree can be named by path but has no expected path to move +/// to — the `worktree-path` template is written over the branch name. Naming +/// one is an error rather than an empty filter reported as success. +#[rstest] +fn step_relocate_rejects_detached_worktree(mut repo: TestRepo) { + repo.add_worktree("feature-detached"); + repo.detach_head_in_worktree("feature-detached"); + + let output = repo + .wt_command() + .args(["step", "relocate", "--dry-run", "../repo.feature-detached"]) + .output() + .unwrap(); + + assert!(!output.status.success(), "a detached worktree should fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("detached"), + "expected a detached-worktree error, got: {stderr}" + ); +} diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 5b54f265e7..7d1ce5525c 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -7214,3 +7214,87 @@ cd = false // Without any cd flags, config should be respected (no cd directive) snapshot_switch("switch_no_cd_config_default", &repo, &["no-cd-config-test"]); } + +/// A worktree's own path names it, including the single-component spelling — +/// `wt switch solo` where `solo/` is a worktree but no branch is called that. +/// +/// Path resolution runs through `-C`, so the assertion is on the directory the +/// switch resolved to rather than on the process cwd. +#[rstest] +fn switch_by_relative_worktree_path(mut repo: TestRepo) { + let nested = repo.root_path().join("solo"); + let worktree_path = repo.add_worktree_at_path("solo-branch", &nested); + + for spelling in ["solo", "./solo", worktree_path.to_str().unwrap()] { + let output = repo + .wt_command() + .args(["switch", spelling, "--no-cd"]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "switch {spelling} should resolve the worktree: {stderr}" + ); + assert!( + stderr.contains("solo-branch"), + "switch {spelling} should name the branch checked out there: {stderr}" + ); + } +} + +/// The tilde form worktrunk prints paths in is a form it also accepts, so a +/// path copied out of wt's output works when the shell can't expand it. +#[cfg(unix)] +#[rstest] +fn switch_by_tilde_worktree_path(mut repo: TestRepo, temp_home: TempDir) { + let worktree_path = repo.add_worktree("feature"); + // Re-home the process at the worktree's parent so the worktree is under + // `~`, which is what makes the tilde spelling reachable at all. + let home = worktree_path.parent().unwrap().to_path_buf(); + drop(temp_home); + + let mut cmd = repo.wt_command(); + set_temp_home_env(&mut cmd, &home); + let output = cmd + .args([ + "switch", + &format!("~/{}", worktree_path.file_name().unwrap().to_str().unwrap()), + "--no-cd", + ]) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && stderr.contains("feature"), + "a tilde-form worktree path should resolve: {stderr}" + ); +} + +/// `--base` names a branch, so a worktree's path stands for the branch checked +/// out there — the same rule as a merge or rebase target. +#[rstest] +fn switch_base_accepts_worktree_path(mut repo: TestRepo) { + let base_path = repo.add_worktree("base-branch"); + + let output = repo + .wt_command() + .args([ + "switch", + "--create", + "derived", + "--base", + base_path.to_str().unwrap(), + "--no-cd", + ]) + .output() + .unwrap(); + + let raw = String::from_utf8_lossy(&output.stderr); + let stderr = raw.ansi_strip(); + assert!( + output.status.success() && stderr.contains("from base-branch"), + "--base should resolve the worktree path to its branch: {stderr}" + ); +} diff --git a/tests/snapshots/integration__integration_tests__help__help_md_merge.snap b/tests/snapshots/integration__integration_tests__help__help_md_merge.snap index a1006fb2d9..3f843b111e 100644 --- a/tests/snapshots/integration__integration_tests__help__help_md_merge.snap +++ b/tests/snapshots/integration__integration_tests__help__help_md_merge.snap @@ -8,6 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -41,7 +42,7 @@ Usage: wt merge [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. diff --git a/tests/snapshots/integration__integration_tests__help__help_merge_long.snap b/tests/snapshots/integration__integration_tests__help__help_merge_long.snap index cb9351b966..829ceefeda 100644 --- a/tests/snapshots/integration__integration_tests__help__help_merge_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_merge_long.snap @@ -8,6 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -41,7 +42,7 @@ Usage: wt merge [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch + Target branch or worktree path Defaults to default branch. diff --git a/tests/snapshots/integration__integration_tests__help__help_merge_short.snap b/tests/snapshots/integration__integration_tests__help__help_merge_short.snap index 88cf959c6e..cbb01576cc 100644 --- a/tests/snapshots/integration__integration_tests__help__help_merge_short.snap +++ b/tests/snapshots/integration__integration_tests__help__help_merge_short.snap @@ -8,6 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -24,6 +25,7 @@ info: WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" @@ -37,7 +39,7 @@ wt merge - Merge current branch into the target branch Usage: wt merge [OPTIONS] [TARGET] Arguments: - [TARGET] Target branch + [TARGET] Target branch or worktree path Options: --no-squash Skip commit squashing diff --git a/tests/snapshots/integration__integration_tests__help__help_step_copy_ignored.snap b/tests/snapshots/integration__integration_tests__help__help_step_copy_ignored.snap index 6a05aa5f70..0b7eb73fb1 100644 --- a/tests/snapshots/integration__integration_tests__help__help_step_copy_ignored.snap +++ b/tests/snapshots/integration__integration_tests__help__help_step_copy_ignored.snap @@ -9,6 +9,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -42,12 +43,12 @@ Usage: wt step copy-ignored [OPTIONS] Options: --from  - Source worktree branch + Source worktree branch or path Defaults to main worktree. --to  - Destination worktree branch + Destination worktree branch or path Defaults to current worktree. diff --git a/tests/snapshots/integration__integration_tests__help__help_step_promote.snap b/tests/snapshots/integration__integration_tests__help__help_step_promote.snap index 6cfbfcc018..d84de400ca 100644 --- a/tests/snapshots/integration__integration_tests__help__help_step_promote.snap +++ b/tests/snapshots/integration__integration_tests__help__help_step_promote.snap @@ -9,6 +9,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -25,6 +26,7 @@ info: WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" @@ -41,7 +43,7 @@ Usage: wt step promote [OPTIONS] [BRANCH] Arguments: [BRANCH] - Branch to promote to main worktree + Branch or worktree path to promote to main worktree Defaults to current branch, or default branch from main worktree. diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap index 16fce5f4d9..16fbbc1863 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -8,6 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -24,6 +25,7 @@ info: WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" @@ -38,7 +40,7 @@ Usage: wt switch [OPTIONS] [BRANCH] [-- Arguments: [BRANCH] - Branch name, shortcut, or PR/MR URL + Branch, worktree path, shortcut, or PR/MR URL Opens interactive picker if omitted. Shortcuts: ^ (default branch), - (previous), @ (current), pr:{N} (GitHub PR), mr:{N} (GitLab MR) @@ -52,7 +54,7 @@ Usage: wt switch [OPTIONS] [BRANCH] [-- Create a new branch -b, --base  - Base branch + Base branch or worktree path Defaults to default branch. Supports the same shortcuts as the branch argument: ^, @, -, pr:{N}, mr:{N}. @@ -153,6 +155,12 @@ If the branch already has a worktree, wt switch changes directories to i   wt switch --create fix --base release # New branch from release   wt switch --create temp --no-hooks # Skip hooks +Naming a worktree + +A worktree answers to its branch name and to its own path, so wt switch feature and wt switch ../repo.feature reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. + +Every argument that names a worktree resolves this way — wt remove, wt merge, wt step diff --branch, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against -C, and a leading ~ against the home directory, so a path worktrunk printed can be pasted back. + Shortcuts Shortcut Meaning diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_short.snap b/tests/snapshots/integration__integration_tests__help__help_switch_short.snap index e16d413548..a6e2b8ce74 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_short.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_short.snap @@ -8,6 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -24,6 +25,7 @@ info: WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" @@ -37,12 +39,12 @@ wt switch - Switch to a worktree; create if needed Usage: wt switch [OPTIONS] [BRANCH] [-- ...] Arguments: - [BRANCH] Branch name, shortcut, or PR/MR URL + [BRANCH] Branch, worktree path, shortcut, or PR/MR URL [EXECUTE_ARGS]... Additional arguments for --execute command (after --) Options: -c, --create Create a new branch - -b, --base  Base branch + -b, --base  Base branch or worktree path -x, --execute  Command to run after switch --clobber Remove stale paths at target --no-cd Skip directory change after switching diff --git a/tests/snapshots/integration__integration_tests__merge__step_commit_branch_flag_nonexistent.snap b/tests/snapshots/integration__integration_tests__merge__step_commit_branch_flag_nonexistent.snap index acd700bfe6..cf4c096b53 100644 --- a/tests/snapshots/integration__integration_tests__merge__step_commit_branch_flag_nonexistent.snap +++ b/tests/snapshots/integration__integration_tests__merge__step_commit_branch_flag_nonexistent.snap @@ -9,8 +9,10 @@ info: - nonexistent env: APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" @@ -37,6 +39,7 @@ info: WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" @@ -48,4 +51,5 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -✗ no worktree for branch 'nonexistent' +✗ No branch or worktree named nonexistent +↳ To see branches and worktree paths, run wt list --branches From 4aa9cc5ac5467c9d2034fc41833ca86e470ce172 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sat, 25 Jul 2026 15:06:50 -0700 Subject: [PATCH 2/4] docs(worktree): state the path alias once, not on every argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass added "or worktree path" to sixteen argument descriptions, which asserts a co-equal addressing scheme sixteen times — the opposite of the model, where a branch names a worktree and its path is the alias for what a branch cannot name. It also pushed several first lines past the 3-6 word guidance in src/commands/CLAUDE.md, and rewrote nine `wt config state` lines that were about a *target* branch. Restore every argument description, and state the alias where the model already lives: one paragraph in `wt switch`'s help, one sentence on the addressing line in worktrunk.md. `wt switch`'s own argument keeps the mention — its description is already the catalogue of accepted forms — as does `wt remove`, which has named the path since before this branch. The Worktree Model section now says which way to document it, so the next argument doesn't grow its own copy. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- README.md | 2 +- docs/content/merge.md | 2 +- docs/content/step.md | 20 +++++++++---------- docs/content/switch.md | 6 ++---- docs/content/worktrunk.md | 2 +- .../skills/worktrunk/reference/README.md | 2 +- .../skills/worktrunk/reference/merge.md | 2 +- .../skills/worktrunk/reference/step.md | 20 +++++++++---------- .../skills/worktrunk/reference/switch.md | 6 ++---- .../skills/worktrunk/reference/worktrunk.md | 2 +- skills/worktrunk/reference/merge.md | 2 +- skills/worktrunk/reference/step.md | 20 +++++++++---------- skills/worktrunk/reference/switch.md | 6 ++---- skills/worktrunk/reference/worktrunk.md | 2 +- src/cli/config.rs | 18 ++++++++--------- src/cli/mod.rs | 8 +++----- src/cli/step.rs | 20 +++++++++---------- ...ntegration_tests__help__help_md_merge.snap | 2 +- ...egration_tests__help__help_merge_long.snap | 2 +- ...gration_tests__help__help_merge_short.snap | 2 +- ...n_tests__help__help_step_copy_ignored.snap | 4 ++-- ...ration_tests__help__help_step_promote.snap | 2 +- ...gration_tests__help__help_switch_long.snap | 6 ++---- ...ration_tests__help__help_switch_short.snap | 2 +- 25 files changed, 76 insertions(+), 86 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 513d87a8a2..6f9a731d60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Load relevant skills before starting; reload when scope changes mid-session. Pro ## Worktree Model -- Worktrees are **addressed by branch name**; a worktree's own path is an accepted alias for it, resolved branch-first by `Repository::resolve_worktree` — the one canonicalizer every worktree-naming argument goes through. Paths never become a second addressing scheme: they name what a branch cannot (a detached worktree, one of two checkouts of a branch), and a new argument routes through the canonicalizer rather than growing its own rule. +- Worktrees are **addressed by branch name**, with a worktree's own path as an alias — resolved branch-first by `Repository::resolve_worktree`, the one canonicalizer every worktree-naming argument routes through. A path is not a second addressing scheme: it names what a branch cannot (a detached worktree, one of two checkouts of a branch). So document arguments as taking a branch, state the path alias once rather than per argument, and give a new argument the canonicalizer rather than its own rule. - Each worktree maps to **exactly one branch**. - **Never retarget an existing worktree** to a different branch; create/switch/remove instead. (Sole exception: `wt step promote`, experimental, exchanges branches between two worktrees.) diff --git a/README.md b/README.md index 710ad98797..ad28398c77 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ worktree requires typing the branch name three times: `git worktree add -b feat ## Worktrunk makes git worktrees as easy as branches -Worktrees are addressed by branch name; paths are computed from a configurable template. +Worktrees are addressed by branch name; paths are computed from a configurable template. Commands that take a branch also accept the path of the worktree it is checked out in. > Start with the core commands diff --git a/docs/content/merge.md b/docs/content/merge.md index 7c4530b22a..b2fd99104d 100644 --- a/docs/content/merge.md +++ b/docs/content/merge.md @@ -110,7 +110,7 @@ Usage: wt merge [OPTIONS] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. diff --git a/docs/content/step.md b/docs/content/step.md index bd6fa092c5..4af08d1cf5 100644 --- a/docs/content/step.md +++ b/docs/content/step.md @@ -140,7 +140,7 @@ Usage: wt step commit [OPTIONS] Options: -b, --branch <BRANCH> - Branch or worktree path (defaults to current worktree) + Branch to operate on (defaults to current worktree) --stage <STAGE> What to stage before committing [default: all] @@ -233,7 +233,7 @@ Usage: wt step squash [OPTIONS] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -322,7 +322,7 @@ Usage: wt step rebase [OPTIONS] Arguments: [TARGET] - Target branch, tag, commit, or worktree path + Target branch, tag, or commit Defaults to default branch. @@ -385,7 +385,7 @@ Usage: wt step push [OPTIONS] < Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -467,7 +467,7 @@ Usage: wt step diff [OPTIONS] < Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -476,7 +476,7 @@ Usage: wt step diff [OPTIONS] < Options: -b, --branch <BRANCH> - Branch or worktree path (defaults to current worktree) + Branch to operate on (defaults to current worktree) -h, --help Print help (see a summary with '-h') @@ -608,12 +608,12 @@ Usage: wt step copy-ignored [OPTIONS]< Options: --from <FROM> - Source worktree branch or path + Source worktree branch Defaults to main worktree. --to <TO> - Destination worktree branch or path + Destination worktree branch Defaults to current worktree. @@ -876,7 +876,7 @@ Usage: wt step promote [OPTIONS]Arguments: [BRANCH] - Branch or worktree path to promote to main worktree + Branch to promote to main worktree Defaults to current branch, or default branch from main worktree. @@ -1055,7 +1055,7 @@ Usage: wt step relocate [OPTIONS]Arguments: [BRANCHES]... - Worktrees to relocate, by branch or path (defaults to all mismatched) + Worktrees to relocate (defaults to all mismatched) Options: --dry-run diff --git a/docs/content/switch.md b/docs/content/switch.md index 773306a578..753154099e 100644 --- a/docs/content/switch.md +++ b/docs/content/switch.md @@ -42,9 +42,7 @@ If the branch already has a worktree, `wt switch` changes directories to it. Oth ## Naming a worktree -A worktree answers to its branch name and to its own path, so `wt switch feature` and `wt switch ../repo.feature` reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. - -Every argument that names a worktree resolves this way — `wt remove`, `wt merge`, `wt step diff --branch`, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against `-C`, and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. +Worktrees are addressed by branch name, and every argument that takes one also accepts the path of the worktree itself — resolved after the branch, so a directory never shadows a branch sharing its name. A path names what a branch cannot: a detached worktree, or one of two checkouts of the same branch. Relative paths resolve against `-C` and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. ## Shortcuts @@ -171,7 +169,7 @@ Usage: wt switch [OPTIONS] -b, --base <BASE> - Base branch or worktree path + Base branch Defaults to default branch. Supports the same shortcuts as the branch argument: ^, @, -, pr:{N}, mr:{N}. diff --git a/docs/content/worktrunk.md b/docs/content/worktrunk.md index 8ede454486..2865e7445c 100644 --- a/docs/content/worktrunk.md +++ b/docs/content/worktrunk.md @@ -37,7 +37,7 @@ worktree requires typing the branch name three times: `git worktree add -b feat ## Worktrunk makes git worktrees as easy as branches -Worktrees are addressed by branch name; paths are computed from a configurable template. +Worktrees are addressed by branch name; paths are computed from a configurable template. Commands that take a branch also accept the path of the worktree it is checked out in. > Start with the core commands diff --git a/plugins/worktrunk/skills/worktrunk/reference/README.md b/plugins/worktrunk/skills/worktrunk/reference/README.md index 710ad98797..ad28398c77 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/README.md +++ b/plugins/worktrunk/skills/worktrunk/reference/README.md @@ -40,7 +40,7 @@ worktree requires typing the branch name three times: `git worktree add -b feat ## Worktrunk makes git worktrees as easy as branches -Worktrees are addressed by branch name; paths are computed from a configurable template. +Worktrees are addressed by branch name; paths are computed from a configurable template. Commands that take a branch also accept the path of the worktree it is checked out in. > Start with the core commands diff --git a/plugins/worktrunk/skills/worktrunk/reference/merge.md b/plugins/worktrunk/skills/worktrunk/reference/merge.md index 9f8cf0c611..7781639f43 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/merge.md +++ b/plugins/worktrunk/skills/worktrunk/reference/merge.md @@ -101,7 +101,7 @@ Usage: wt merge [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. diff --git a/plugins/worktrunk/skills/worktrunk/reference/step.md b/plugins/worktrunk/skills/worktrunk/reference/step.md index b0d1a0bf24..631953640e 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/step.md +++ b/plugins/worktrunk/skills/worktrunk/reference/step.md @@ -135,7 +135,7 @@ Usage: wt step commit [OPTIONS] Options: -b, --branch - Branch or worktree path (defaults to current worktree) + Branch to operate on (defaults to current worktree) --stage What to stage before committing [default: all] @@ -232,7 +232,7 @@ Usage: wt step squash [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -325,7 +325,7 @@ Usage: wt step rebase [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch, tag, commit, or worktree path + Target branch, tag, or commit Defaults to default branch. @@ -392,7 +392,7 @@ Usage: wt step push [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -486,7 +486,7 @@ Usage: wt step diff [OPTIONS] [TARGET] [-- ...] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -495,7 +495,7 @@ Arguments: Options: -b, --branch - Branch or worktree path (defaults to current worktree) + Branch to operate on (defaults to current worktree) -h, --help Print help (see a summary with '-h') @@ -627,12 +627,12 @@ Usage: wt step copy-ignored [OPTIONS] Options: --from - Source worktree branch or path + Source worktree branch Defaults to main worktree. --to - Destination worktree branch or path + Destination worktree branch Defaults to current worktree. @@ -914,7 +914,7 @@ Usage: wt step promote [OPTIONS] [BRANCH] Arguments: [BRANCH] - Branch or worktree path to promote to main worktree + Branch to promote to main worktree Defaults to current branch, or default branch from main worktree. @@ -1108,7 +1108,7 @@ Usage: wt step relocate [OPTIONS] [BRANCHES]... Arguments: [BRANCHES]... - Worktrees to relocate, by branch or path (defaults to all mismatched) + Worktrees to relocate (defaults to all mismatched) Options: --dry-run diff --git a/plugins/worktrunk/skills/worktrunk/reference/switch.md b/plugins/worktrunk/skills/worktrunk/reference/switch.md index 94604ed400..8e42ed9498 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/switch.md +++ b/plugins/worktrunk/skills/worktrunk/reference/switch.md @@ -38,9 +38,7 @@ $ wt switch --create temp --no-hooks # Skip hooks ## Naming a worktree -A worktree answers to its branch name and to its own path, so `wt switch feature` and `wt switch ../repo.feature` reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. - -Every argument that names a worktree resolves this way — `wt remove`, `wt merge`, `wt step diff --branch`, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against `-C`, and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. +Worktrees are addressed by branch name, and every argument that takes one also accepts the path of the worktree itself — resolved after the branch, so a directory never shadows a branch sharing its name. A path names what a branch cannot: a detached worktree, or one of two checkouts of the same branch. Relative paths resolve against `-C` and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. ## Shortcuts @@ -167,7 +165,7 @@ Options: Create a new branch -b, --base - Base branch or worktree path + Base branch Defaults to default branch. Supports the same shortcuts as the branch argument: ^, @, -, pr:{N}, mr:{N}. diff --git a/plugins/worktrunk/skills/worktrunk/reference/worktrunk.md b/plugins/worktrunk/skills/worktrunk/reference/worktrunk.md index 6af7745c5a..13c0946b6a 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/worktrunk.md +++ b/plugins/worktrunk/skills/worktrunk/reference/worktrunk.md @@ -22,7 +22,7 @@ worktree requires typing the branch name three times: `git worktree add -b feat ## Worktrunk makes git worktrees as easy as branches -Worktrees are addressed by branch name; paths are computed from a configurable template. +Worktrees are addressed by branch name; paths are computed from a configurable template. Commands that take a branch also accept the path of the worktree it is checked out in. > Start with the core commands diff --git a/skills/worktrunk/reference/merge.md b/skills/worktrunk/reference/merge.md index 9f8cf0c611..7781639f43 100644 --- a/skills/worktrunk/reference/merge.md +++ b/skills/worktrunk/reference/merge.md @@ -101,7 +101,7 @@ Usage: wt merge [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. diff --git a/skills/worktrunk/reference/step.md b/skills/worktrunk/reference/step.md index b0d1a0bf24..631953640e 100644 --- a/skills/worktrunk/reference/step.md +++ b/skills/worktrunk/reference/step.md @@ -135,7 +135,7 @@ Usage: wt step commit [OPTIONS] Options: -b, --branch - Branch or worktree path (defaults to current worktree) + Branch to operate on (defaults to current worktree) --stage What to stage before committing [default: all] @@ -232,7 +232,7 @@ Usage: wt step squash [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -325,7 +325,7 @@ Usage: wt step rebase [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch, tag, commit, or worktree path + Target branch, tag, or commit Defaults to default branch. @@ -392,7 +392,7 @@ Usage: wt step push [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -486,7 +486,7 @@ Usage: wt step diff [OPTIONS] [TARGET] [-- ...] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. @@ -495,7 +495,7 @@ Arguments: Options: -b, --branch - Branch or worktree path (defaults to current worktree) + Branch to operate on (defaults to current worktree) -h, --help Print help (see a summary with '-h') @@ -627,12 +627,12 @@ Usage: wt step copy-ignored [OPTIONS] Options: --from - Source worktree branch or path + Source worktree branch Defaults to main worktree. --to - Destination worktree branch or path + Destination worktree branch Defaults to current worktree. @@ -914,7 +914,7 @@ Usage: wt step promote [OPTIONS] [BRANCH] Arguments: [BRANCH] - Branch or worktree path to promote to main worktree + Branch to promote to main worktree Defaults to current branch, or default branch from main worktree. @@ -1108,7 +1108,7 @@ Usage: wt step relocate [OPTIONS] [BRANCHES]... Arguments: [BRANCHES]... - Worktrees to relocate, by branch or path (defaults to all mismatched) + Worktrees to relocate (defaults to all mismatched) Options: --dry-run diff --git a/skills/worktrunk/reference/switch.md b/skills/worktrunk/reference/switch.md index 94604ed400..8e42ed9498 100644 --- a/skills/worktrunk/reference/switch.md +++ b/skills/worktrunk/reference/switch.md @@ -38,9 +38,7 @@ $ wt switch --create temp --no-hooks # Skip hooks ## Naming a worktree -A worktree answers to its branch name and to its own path, so `wt switch feature` and `wt switch ../repo.feature` reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. - -Every argument that names a worktree resolves this way — `wt remove`, `wt merge`, `wt step diff --branch`, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against `-C`, and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. +Worktrees are addressed by branch name, and every argument that takes one also accepts the path of the worktree itself — resolved after the branch, so a directory never shadows a branch sharing its name. A path names what a branch cannot: a detached worktree, or one of two checkouts of the same branch. Relative paths resolve against `-C` and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. ## Shortcuts @@ -167,7 +165,7 @@ Options: Create a new branch -b, --base - Base branch or worktree path + Base branch Defaults to default branch. Supports the same shortcuts as the branch argument: ^, @, -, pr:{N}, mr:{N}. diff --git a/skills/worktrunk/reference/worktrunk.md b/skills/worktrunk/reference/worktrunk.md index 6af7745c5a..13c0946b6a 100644 --- a/skills/worktrunk/reference/worktrunk.md +++ b/skills/worktrunk/reference/worktrunk.md @@ -22,7 +22,7 @@ worktree requires typing the branch name three times: `git worktree add -b feat ## Worktrunk makes git worktrees as easy as branches -Worktrees are addressed by branch name; paths are computed from a configurable template. +Worktrees are addressed by branch name; paths are computed from a configurable template. Commands that take a branch also accept the path of the worktree it is checked out in. > Start with the core commands diff --git a/src/cli/config.rs b/src/cli/config.rs index b5b406b9ce..8e71919e48 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -1175,7 +1175,7 @@ $ wt config state ci-status clear && wt config state ci-status get ```"# )] Get { - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1198,7 +1198,7 @@ Clear all CI status cache: $ wt config state ci-status clear --all ```"#)] Clear { - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch, conflicts_with = "all")] branch: Option, @@ -1224,7 +1224,7 @@ Get marker for a specific branch: $ wt config state marker get --branch=feature ```"#)] Get { - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1245,7 +1245,7 @@ $ wt config state marker set "✅ ready" --branch=feature /// Marker text (shown in `wt list` output) value: String, - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1268,7 +1268,7 @@ Clear all markers: $ wt config state marker clear --all ```"#)] Clear { - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch, conflicts_with = "all")] branch: Option, @@ -1467,7 +1467,7 @@ $ wt config state vars get env --branch=feature /// Key name key: String, - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1485,7 +1485,7 @@ List keys for a specific branch: $ wt config state vars list --branch=feature ```"#)] List { - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, @@ -1516,7 +1516,7 @@ $ wt config state vars set env=production --branch=main #[arg(value_name = "KEY=VALUE", value_parser = super::parse_vars_assignment)] assignment: (String, String), - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, @@ -1547,7 +1547,7 @@ $ wt config state vars clear env --branch=feature #[arg(long)] all: bool, - /// Branch or worktree path (defaults to current) + /// Target branch (defaults to current) #[arg(long, add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, }, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0876b2d7fa..eacdc9d961 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -365,7 +365,7 @@ pub(crate) struct SwitchArgs { #[arg(short = 'c', long, requires = "branch")] pub(crate) create: bool, - /// Base branch or worktree path + /// Base branch /// /// Defaults to default branch. Supports the same shortcuts as the branch /// argument: `^`, `@`, `-`, `pr:{N}`, `mr:{N}`. @@ -526,7 +526,7 @@ pub(crate) struct RemoveArgs { #[derive(Args)] pub(crate) struct MergeArgs { - /// Target branch or worktree path + /// Target branch /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] @@ -644,9 +644,7 @@ $ wt switch --create temp --no-hooks # Skip hooks ## Naming a worktree -A worktree answers to its branch name and to its own path, so `wt switch feature` and `wt switch ../repo.feature` reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. - -Every argument that names a worktree resolves this way — `wt remove`, `wt merge`, `wt step diff --branch`, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against `-C`, and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. +Worktrees are addressed by branch name, and every argument that takes one also accepts the path of the worktree itself — resolved after the branch, so a directory never shadows a branch sharing its name. A path names what a branch cannot: a detached worktree, or one of two checkouts of the same branch. Relative paths resolve against `-C` and a leading `~` against the home directory, so a path worktrunk printed can be pasted back. ## Shortcuts diff --git a/src/cli/step.rs b/src/cli/step.rs index 80d8283cc6..6be3b7dc4e 100644 --- a/src/cli/step.rs +++ b/src/cli/step.rs @@ -2,7 +2,7 @@ use clap::{Args, Subcommand}; #[derive(Args)] pub struct CommitArgs { - /// Branch or worktree path (defaults to current worktree) + /// Branch to operate on (defaults to current worktree) #[arg(short, long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] pub(crate) branch: Option, @@ -30,7 +30,7 @@ pub struct CommitArgs { #[derive(Args)] pub struct SquashArgs { - /// Target branch or worktree path + /// Target branch /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] @@ -183,7 +183,7 @@ A conflicting commit leaves the rebase open rather than undoing it. The worktree "# )] Rebase { - /// Target branch, tag, commit, or worktree path + /// Target branch, tag, or commit /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] @@ -218,7 +218,7 @@ A worktree that is still registered but whose directory is gone is refused as we "# )] Push { - /// Target branch or worktree path + /// Target branch /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] @@ -285,13 +285,13 @@ $ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state defau "# )] Diff { - /// Target branch or worktree path + /// Target branch /// /// Defaults to default branch. #[arg(add = crate::completion::branch_value_completer(), value_parser = crate::cli::non_empty_branch)] target: Option, - /// Branch or worktree path (defaults to current worktree) + /// Branch to operate on (defaults to current worktree) #[arg(short, long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] branch: Option, @@ -397,13 +397,13 @@ The `.worktreeinclude` pattern is shared with [Claude Code on desktop](https://c - worktrunk runs as a configurable hook in the worktree lifecycle "#)] CopyIgnored { - /// Source worktree branch or path + /// Source worktree branch /// /// Defaults to main worktree. #[arg(long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] from: Option, - /// Destination worktree branch or path + /// Destination worktree branch /// /// Defaults to current worktree. #[arg(long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] @@ -587,7 +587,7 @@ The swap uses `rename()` for each entry — fast regardless of entry size, since "# )] Promote { - /// Branch or worktree path to promote to main worktree + /// Branch to promote to main worktree /// /// Defaults to current branch, or default branch from main worktree. #[arg(add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] @@ -713,7 +713,7 @@ refuses), unless `--commit` is passed. - **Detached HEAD** — no branch to compute expected path "#)] Relocate { - /// Worktrees to relocate, by branch or path (defaults to all mismatched) + /// Worktrees to relocate (defaults to all mismatched) #[arg(add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] branches: Vec, diff --git a/tests/snapshots/integration__integration_tests__help__help_md_merge.snap b/tests/snapshots/integration__integration_tests__help__help_md_merge.snap index 3f843b111e..f1f412dcf9 100644 --- a/tests/snapshots/integration__integration_tests__help__help_md_merge.snap +++ b/tests/snapshots/integration__integration_tests__help__help_md_merge.snap @@ -42,7 +42,7 @@ Usage: wt merge [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. diff --git a/tests/snapshots/integration__integration_tests__help__help_merge_long.snap b/tests/snapshots/integration__integration_tests__help__help_merge_long.snap index 829ceefeda..26ce2bedb5 100644 --- a/tests/snapshots/integration__integration_tests__help__help_merge_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_merge_long.snap @@ -42,7 +42,7 @@ Usage: wt merge [OPTIONS] [TARGET] Arguments: [TARGET] - Target branch or worktree path + Target branch Defaults to default branch. diff --git a/tests/snapshots/integration__integration_tests__help__help_merge_short.snap b/tests/snapshots/integration__integration_tests__help__help_merge_short.snap index cbb01576cc..fd2d7121e7 100644 --- a/tests/snapshots/integration__integration_tests__help__help_merge_short.snap +++ b/tests/snapshots/integration__integration_tests__help__help_merge_short.snap @@ -39,7 +39,7 @@ wt merge - Merge current branch into the target branch Usage: wt merge [OPTIONS] [TARGET] Arguments: - [TARGET] Target branch or worktree path + [TARGET] Target branch Options: --no-squash Skip commit squashing diff --git a/tests/snapshots/integration__integration_tests__help__help_step_copy_ignored.snap b/tests/snapshots/integration__integration_tests__help__help_step_copy_ignored.snap index 0b7eb73fb1..9d870993ae 100644 --- a/tests/snapshots/integration__integration_tests__help__help_step_copy_ignored.snap +++ b/tests/snapshots/integration__integration_tests__help__help_step_copy_ignored.snap @@ -43,12 +43,12 @@ Usage: wt step copy-ignored [OPTIONS] Options: --from  - Source worktree branch or path + Source worktree branch Defaults to main worktree. --to  - Destination worktree branch or path + Destination worktree branch Defaults to current worktree. diff --git a/tests/snapshots/integration__integration_tests__help__help_step_promote.snap b/tests/snapshots/integration__integration_tests__help__help_step_promote.snap index d84de400ca..ed07b95a8a 100644 --- a/tests/snapshots/integration__integration_tests__help__help_step_promote.snap +++ b/tests/snapshots/integration__integration_tests__help__help_step_promote.snap @@ -43,7 +43,7 @@ Usage: wt step promote [OPTIONS] [BRANCH] Arguments: [BRANCH] - Branch or worktree path to promote to main worktree + Branch to promote to main worktree Defaults to current branch, or default branch from main worktree. diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap index 16fbbc1863..67bd36d327 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -54,7 +54,7 @@ Usage: wt switch [OPTIONS] [BRANCH] [-- Create a new branch -b, --base  - Base branch or worktree path + Base branch Defaults to default branch. Supports the same shortcuts as the branch argument: ^, @, -, pr:{N}, mr:{N}. @@ -157,9 +157,7 @@ If the branch already has a worktree, wt switch changes directories to i Naming a worktree -A worktree answers to its branch name and to its own path, so wt switch feature and wt switch ../repo.feature reach the same place. The branch is tried first, so a directory that shares a branch's name never shadows it. A path is what names the worktrees a branch cannot: a detached one, or one of two checkouts of the same branch. - -Every argument that names a worktree resolves this way — wt remove, wt merge, wt step diff --branch, and the rest — as does every argument that names a branch, where a worktree's path stands for the branch checked out there. Relative paths resolve against -C, and a leading ~ against the home directory, so a path worktrunk printed can be pasted back. +Worktrees are addressed by branch name, and every argument that takes one also accepts the path of the worktree itself — resolved after the branch, so a directory never shadows a branch sharing its name. A path names what a branch cannot: a detached worktree, or one of two checkouts of the same branch. Relative paths resolve against -C and a leading ~ against the home directory, so a path worktrunk printed can be pasted back. Shortcuts diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_short.snap b/tests/snapshots/integration__integration_tests__help__help_switch_short.snap index a6e2b8ce74..8437f1fe8a 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_short.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_short.snap @@ -44,7 +44,7 @@ Usage: wt switch [OPTIONS] [BRANCH] [-- Options: -c, --create Create a new branch - -b, --base  Base branch or worktree path + -b, --base  Base branch -x, --execute  Command to run after switch --clobber Remove stale paths at target --no-cd Skip directory change after switching From 7f6c35fdaab55cc0d484809ccf6c7a75e0c984bf Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sat, 25 Jul 2026 15:32:24 -0700 Subject: [PATCH 3/4] test: cover the two reachable gaps codecov/patch found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight lines of the diff were unhit; two of the four causes are real and deterministically reachable: - `wt step relocate ` — the arm that rejects a worktree whose directory is gone. The detached arm beside it had a test; this one was only verified by hand. - `wt step push ` — `require_target_branch`'s path fallback. Its sibling `require_target_ref` was covered through `wt step diff`, so the merge/push half went untested. The other two aren't worth contorting for: `home_dir()` returning `None` (twice, including a test's own skip guard) has no deterministic trigger, and the `@`-resolution `NotInWorktree` arm is unchanged logic that only re-indented when its `match` became an `if`. Co-Authored-By: Claude Opus 5 (1M context) --- tests/integration_tests/push.rs | 28 ++++++++++++++++++++++++ tests/integration_tests/step_relocate.rs | 22 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/tests/integration_tests/push.rs b/tests/integration_tests/push.rs index 3c0440394a..812941d9cf 100644 --- a/tests/integration_tests/push.rs +++ b/tests/integration_tests/push.rs @@ -376,3 +376,31 @@ fn test_push_no_remote(#[from(repo_with_feature_worktree)] repo: TestRepo) { // Try to push without specifying target (should fail - no remote to get default branch) snapshot_push("push_no_remote", &repo, &[], Some(feature_wt)); } + +/// A push target can be named by the worktree it is checked out in, the same as +/// a rebase or diff target — `require_target_branch`'s path fallback, where +/// `require_target_ref`'s is covered by `wt step diff`. +#[rstest] +fn push_target_accepts_worktree_path(mut repo: TestRepo) { + let main_wt = repo.add_main_worktree(); + let feature_wt = + repo.add_worktree_with_commit("feature", "test.txt", "test content", "Add test file"); + + let output = repo + .wt_command() + .current_dir(&feature_wt) + .args(["step", "push", main_wt.to_str().unwrap()]) + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "a worktree path should name the branch checked out there: {stderr}" + ); + assert_eq!( + repo.git_output(&["rev-parse", "main"]), + repo.git_output(&["rev-parse", "feature"]), + "main should have fast-forwarded to feature" + ); +} diff --git a/tests/integration_tests/step_relocate.rs b/tests/integration_tests/step_relocate.rs index 575874648c..29386397e2 100644 --- a/tests/integration_tests/step_relocate.rs +++ b/tests/integration_tests/step_relocate.rs @@ -1254,3 +1254,25 @@ fn step_relocate_rejects_detached_worktree(mut repo: TestRepo) { "expected a detached-worktree error, got: {stderr}" ); } + +/// A worktree whose directory is gone still resolves by branch, but git has +/// marked it prunable and there is nothing left to move. Naming one is an error +/// rather than an empty filter reported as success. +#[rstest] +fn step_relocate_rejects_prunable_worktree(mut repo: TestRepo) { + let worktree_path = repo.add_worktree("feature-gone"); + fs::remove_dir_all(&worktree_path).unwrap(); + + let output = repo + .wt_command() + .args(["step", "relocate", "--dry-run", "feature-gone"]) + .output() + .unwrap(); + + assert!(!output.status.success(), "a prunable worktree should fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("directory is gone"), + "expected a prunable-worktree error, got: {stderr}" + ); +} From 15f1d19a880b68697a60f149bbc9d359d443bd48 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sat, 25 Jul 2026 15:43:04 -0700 Subject: [PATCH 4/4] refactor(worktree): keep the `@` arm out of the diff, drop a test skip guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_worktree` gained its path fallback by turning the `match` into an early-return `if`, which re-indented the untouched `@` arm and so entered it into the diff as new, uncovered lines. It is a backstop no CLI route reaches — `wt remove @`, `wt step diff --branch @`, and `wt config state marker --branch @` in a bare repo all fail earlier — so the honest fix is to leave the arm alone rather than test around it. The `match` returns; the new code lives in the `_` arm. The `expand_tilde` test's `let Some(home) = home_dir() else { return }` guard is likewise unhit, and a skipped assertion is worse than a failed one: every platform the suite runs on sets HOME or USERPROFILE, so it expects instead. That leaves `expand_tilde`'s own no-home fallback as the single unhit line, which has no deterministic trigger in-process. Co-Authored-By: Claude Opus 5 (1M context) --- src/git/repository/worktrees.rs | 77 +++++++++++++++++---------------- src/path.rs | 7 ++- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/git/repository/worktrees.rs b/src/git/repository/worktrees.rs index ec9d0626f7..cb40b1f59f 100644 --- a/src/git/repository/worktrees.rs +++ b/src/git/repository/worktrees.rs @@ -259,46 +259,49 @@ impl Repository { /// - `Worktree { path, branch }` — `branch` is `None` for a detached worktree /// - `BranchOnly { branch }` when nothing is checked out under that name pub fn resolve_worktree(&self, name: &str) -> anyhow::Result { - if name == "@" { - // Current worktree by path - works even in detached HEAD - // If worktree_root fails (e.g., in bare repo directory), give a clear error - let path = self - .current_worktree() - .root() - .map_err(|_| GitError::NotInWorktree { - action: Some("resolve @".into()), - })?; - // root() returns canonicalized path, so canonicalize worktree paths - // for comparison to handle symlinks (e.g., macOS /var -> /private/var) - let worktrees = self.list_worktrees()?; - let branch = worktrees - .iter() - .find(|wt| canonicalize(&wt.path).map(|p| p == path).unwrap_or(false)) - .and_then(|wt| wt.branch.clone()); - return Ok(ResolvedWorktree::Worktree { path, branch }); - } + match name { + "@" => { + // Current worktree by path - works even in detached HEAD + // If worktree_root fails (e.g., in bare repo directory), give a clear error + let path = self + .current_worktree() + .root() + .map_err(|_| GitError::NotInWorktree { + action: Some("resolve @".into()), + })?; + // root() returns canonicalized path, so canonicalize worktree paths + // for comparison to handle symlinks (e.g., macOS /var -> /private/var) + let worktrees = self.list_worktrees()?; + let branch = worktrees + .iter() + .find(|wt| canonicalize(&wt.path).map(|p| p == path).unwrap_or(false)) + .and_then(|wt| wt.branch.clone()); + Ok(ResolvedWorktree::Worktree { path, branch }) + } + _ => { + let branch = self.resolve_worktree_name(name)?; + if let Some(path) = self.worktree_for_branch(&branch)? { + return Ok(ResolvedWorktree::Worktree { + path, + branch: Some(branch), + }); + } - let branch = self.resolve_worktree_name(name)?; - if let Some(path) = self.worktree_for_branch(&branch)? { - return Ok(ResolvedWorktree::Worktree { - path, - branch: Some(branch), - }); - } + // A shortcut named a branch, not a directory: `resolve_worktree_name` + // returns a non-shortcut token unchanged, so an unequal result is + // exactly the case where the literal token would be a nonsense path. + if branch == name + && let Some((path, wt_branch)) = self.worktree_at_input_path(name)? + { + return Ok(ResolvedWorktree::Worktree { + path, + branch: wt_branch, + }); + } - // A shortcut named a branch, not a directory: `resolve_worktree_name` - // returns a non-shortcut token unchanged, so an unequal result is - // exactly the case where the literal token would be a nonsense path. - if branch == name - && let Some((path, wt_branch)) = self.worktree_at_input_path(name)? - { - return Ok(ResolvedWorktree::Worktree { - path, - branch: wt_branch, - }); + Ok(ResolvedWorktree::BranchOnly { branch }) + } } - - Ok(ResolvedWorktree::BranchOnly { branch }) } /// The branch a selector names, erroring only when it names a detached diff --git a/src/path.rs b/src/path.rs index 4300cec979..f8c036f63d 100644 --- a/src/path.rs +++ b/src/path.rs @@ -247,10 +247,9 @@ mod tests { /// so a path from wt's own output can be pasted into a wt command. #[test] fn expand_tilde_round_trips_displayed_paths() { - let Some(home) = home_dir() else { - // Skip if HOME/USERPROFILE is not set in the environment - return; - }; + // No skip guard: every platform the suite runs on sets HOME or + // USERPROFILE, and skipping would leave the assertion silently unrun. + let home = home_dir().expect("HOME or USERPROFILE is set"); let path = home.join("workspace").join("repo.feature"); let displayed = format_path_for_display(&path);