Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**, 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.)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion docs/content/switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ 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

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

| Shortcut | Meaning |
Expand Down Expand Up @@ -149,7 +153,7 @@ Usage: <b><span class=c>wt switch</span></b> <span class=c>[OPTIONS]</span> <spa

<b><span class=g>Arguments:</span></b>
<span class=c>[BRANCH]</span>
Branch name, shortcut, or PR/MR URL
Branch, worktree path, shortcut, or PR/MR URL

Opens interactive picker if omitted. Shortcuts: <b>^</b> (default branch), <b>-</b> (previous), <b>@</b>
(current), <b>pr:{N}</b> (GitHub PR), <b>mr:{N}</b> (GitLab MR)
Expand Down
2 changes: 1 addition & 1 deletion docs/content/worktrunk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion plugins/worktrunk/skills/worktrunk/reference/switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ $ wt switch --create fix --base release # New branch from release
$ wt switch --create temp --no-hooks # Skip hooks
```

## Naming a worktree

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

| Shortcut | Meaning |
Expand Down Expand Up @@ -145,7 +149,7 @@ Usage: wt switch [OPTIONS] [BRANCH] [-- <EXECUTE_ARGS>...]

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)
Expand Down
2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/worktrunk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion skills/worktrunk/reference/switch.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills/worktrunk/reference/worktrunk.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -642,6 +642,10 @@ $ wt switch --create fix --base release # New branch from release
$ wt switch --create temp --no-hooks # Skip hooks
```

## Naming a worktree

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

| Shortcut | Meaning |
Expand Down
18 changes: 9 additions & 9 deletions src/commands/config/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")?,
};

Expand Down Expand Up @@ -859,7 +859,7 @@ pub fn handle_state_set(key: &str, value: String, branch: Option<String>) -> 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")?,
};

Expand Down Expand Up @@ -917,7 +917,7 @@ pub fn handle_state_clear(key: &str, branch: Option<String>, 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)? {
Expand Down Expand Up @@ -949,7 +949,7 @@ pub fn handle_state_clear(key: &str, branch: Option<String>, 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")?,
};

Expand Down Expand Up @@ -1638,7 +1638,7 @@ pub fn handle_vars_get(key: &str, branch: Option<String>) -> 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")?,
};

Expand All @@ -1654,7 +1654,7 @@ pub fn handle_vars_set(key: &str, value: &str, branch: Option<String>) -> 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")?,
};

Expand All @@ -1672,7 +1672,7 @@ pub fn handle_vars_set(key: &str, value: &str, branch: Option<String>) -> anyhow
pub fn handle_vars_list(branch: Option<String>, 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")?,
};

Expand Down Expand Up @@ -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")?,
};

Expand Down
22 changes: 15 additions & 7 deletions src/commands/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <b>` (e.g. `wt step commit --branch <b>`) and its hooks
/// (`pre-commit` / `post-commit`) operate on, and resolve `.config/wt.toml`
/// from, `<b>`'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<Self> {
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<Self> {
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,
})
Expand Down
3 changes: 1 addition & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 34 additions & 10 deletions src/commands/relocate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<WorktreeInfo> = 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 <bold>wt step prune</> to clear the entry",
format_path_for_display(&path)
)
);
};
if wt.branch.is_none() {
bail!(
"{}",
cformat!(
"Cannot relocate detached worktree @ {} — the <bold>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
Expand Down
4 changes: 2 additions & 2 deletions src/commands/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/step/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 2 additions & 13 deletions src/commands/step/copy_ignored.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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()?,
};

Expand Down
10 changes: 1 addition & 9 deletions src/commands/step/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading