Files
Maximilian Roos 14580de79c feat(worktree): accept a worktree path wherever a branch is accepted (#3607)
Follow-up to the [`wt remove <path>` discussion on
#3480](https://github.com/max-sixty/worktrunk/pull/3480#issuecomment-5039137116),
widened from that one command to the whole surface. #3480 has since
landed and is merged in here — its duplicate-checkout warning composes
with this: the warning names the shadowed worktrees, and a path is how
you then address one.

## Audit

Verified against the built binary. wt had three answers to "what does
this token mean?":

| Route | `@` `-` `^` | worktree path | `pr:N` |
|---|---|---|---|
| `wt switch` (`resolve_switch_target`) | yes | only if absolute or ≥2
components, and not `--create` | yes |
| `wt remove` (`resolve_worktree_arg`) | yes | any token | no |
| everything else (raw `worktree_for_branch`) | **no** | **no** | no |

Same token, same cwd, two answers:

```console
$ wt remove inner     # ✓ Removed innerbranch worktree & branch
$ wt switch inner     # ✗ No branch named inner
```

And outside switch/remove the shortcuts didn't work at all — `wt step
diff --branch @` was `✗ Branch @ has no worktree`, while `wt config
state marker set --branch @` silently wrote state under the literal key
`@`. Separately, wt prints paths as `~/…` but wouldn't accept that form
back.

## Change

One canonicalizer in the lib, `Repository::resolve_worktree`, absorbing
the path fallback that lived in the bin crate's `resolve_worktree_arg`
(now deleted). Resolution order is documented once, on that function:
`@`, then `-`/`^`, then a branch with a worktree, then a path naming a
registered worktree, then the branch alone.

**Branch-first, everywhere.** A directory never shadows a branch that
shares its name; a path answers only what a branch cannot — a detached
worktree, or one of two checkouts of the same branch (#3480's case). The
`looks_like_path` shape gate is gone, so a single-component path
resolves like any other.

Two shapes cover what callers need: `require_worktree` for commands that
need a worktree to operate in, `require_selected_branch` for arguments
that key by branch. The merge/rebase target validators fall through to
the same path lookup, so a target can be named by the worktree it's
checked out in.

Routed through it: `switch` (including `--base`), `remove`, `step commit
--branch`, `step diff --branch` and its target, `step copy-ignored
--from`/`--to`, `step promote`, `step relocate`, `config state --branch`
(9 sites), and `merge` / `step rebase` / `step squash` / `step push`
targets.

`resolve_input_path` — already documented as the one resolution point
for user-supplied paths — now expands a leading `~`, so the tilde form
worktrunk prints is a form it reads back. `~user` stays literal; wt
doesn't reimplement that shell feature.

## Documentation

A path is an alias, not a second addressing scheme, so it is stated once
rather than on every argument: one paragraph in `wt switch`'s help and
one sentence on the addressing line in `worktrunk.md`. Argument
descriptions still read as branches. The two exceptions are the
arguments whose descriptions are already catalogues of accepted forms —
`wt switch`'s (`Branch, worktree path, shortcut, or PR/MR URL`) and `wt
remove`'s, which has named the path since before this branch. The
Worktree Model section of `CLAUDE.md` records which way to document it,
so the next argument doesn't grow its own copy.

## Two silent no-ops fixed along the way

- `wt step relocate <unmatched>` matched arguments against branch names
by string equality, so a typo filtered everything out and the empty
result rendered as `○ All worktrees are at expected paths` — a success
message for work that never happened. Every way an argument can fail to
land on a relocatable worktree now errors, including the detached and
prunable cases the new path route makes reachable.
- A selector matching nothing was reported as a branch without a
worktree, hinting `wt switch <token>` — which creates a worktree only
when the branch exists, so for a mistyped path it would just fail again.
`WorktreeSelectorNotFound` now says `No branch or worktree named X`; a
branch that genuinely exists without a checkout keeps the create hint.

## Testing

Full gate green: 4596 tests, lints, docs sync, `--features
shell-integration-tests` clippy. `codecov/patch` is 99.25% of diff hit
against a 97.93% target. New coverage:

- Unit: branch-and-path equivalence, branch-beats-same-named-directory,
detached-by-path (and its `require_selected_branch` refusal), shortcuts
never treated as paths, branch-only fallthrough, and the two distinct
not-found errors. Plus `expand_tilde` round-tripping
`format_path_for_display`.
- Integration: `switch` by relative/single-component/absolute/tilde
path, `--base` by path, `step diff --branch` by path and `@` (asserted
equal to the by-branch output), `config state --branch` set via `@` and
read via the worktree path, and both new relocate errors.

`wt remove`'s resolution is unchanged — it already had this rule; it now
shares the implementation. The 106-test `remove::` suite is untouched
and green.

- Integration: `wt step push <worktree-path>` (the
`require_target_branch` half of the target fallback), and `wt step
relocate` against a prunable worktree.

One diff line is unhit: `expand_tilde`'s fallback when `home_dir()`
returns `None`, which has no deterministic trigger. The `@`-resolution
backstop in `resolve_worktree` is untested for the same reason — no CLI
route reaches it — so it kept its original `match` arm rather than being
re-indented into the diff.

## Left out

`wt config state default-branch set` and `previous-branch set` take a
branch name as a *value to store* rather than a selector, so they still
take it literally.

> _This was written by Claude Code on behalf of Maximilian Roos_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:10:46 -07:00

287 lines
8.5 KiB
Rust

//! Integration tests for `wt step diff`
use crate::common::{TestRepo, make_snapshot_cmd, repo, setup_snapshot_settings};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use std::path::Path;
/// Helper: create a feature worktree with a commit ahead of main
fn setup_feature_with_commit(repo: &mut TestRepo) -> std::path::PathBuf {
let feature_path = repo.add_worktree("feature");
fs::write(feature_path.join("feature.txt"), "feature content").unwrap();
repo.run_git_in(&feature_path, &["add", "feature.txt"]);
repo.run_git_in(&feature_path, &["commit", "-m", "Add feature file"]);
feature_path
}
/// No changes: worktree identical to merge base
#[rstest]
fn test_step_diff_no_changes(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff"],
Some(&feature_path),
));
}
/// Committed changes show full diff by default
#[rstest]
fn test_step_diff_committed_changes(mut repo: TestRepo) {
let feature_path = setup_feature_with_commit(&mut repo);
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff"],
Some(&feature_path),
));
}
/// Untracked files appear in diff
#[rstest]
fn test_step_diff_untracked_files(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(feature_path.join("untracked.txt"), "untracked content").unwrap();
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff"],
Some(&feature_path),
));
}
/// All change types: committed + staged + unstaged + untracked
#[rstest]
fn test_step_diff_all_change_types(mut repo: TestRepo) {
let feature_path = setup_feature_with_commit(&mut repo);
// Staged change
fs::write(feature_path.join("staged.txt"), "staged content").unwrap();
repo.run_git_in(&feature_path, &["add", "staged.txt"]);
// Unstaged change (modify a tracked file)
fs::write(feature_path.join("feature.txt"), "modified content").unwrap();
// Untracked file
fs::write(feature_path.join("new.txt"), "new content").unwrap();
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff"],
Some(&feature_path),
));
}
/// Extra args are forwarded to git diff
#[rstest]
fn test_step_diff_extra_args(mut repo: TestRepo) {
let feature_path = setup_feature_with_commit(&mut repo);
fs::write(feature_path.join("untracked.txt"), "untracked content").unwrap();
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff", "--", "--stat"],
Some(&feature_path),
));
}
/// Real index is unchanged after running diff
#[rstest]
fn test_step_diff_index_unchanged(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(feature_path.join("untracked.txt"), "content").unwrap();
// Get index state before
let index_before = git_status(&repo, &feature_path);
// Run step diff
let mut cmd = repo.wt_command();
cmd.args(["step", "diff"]).current_dir(&feature_path);
let output = cmd.output().unwrap();
assert!(output.status.success(), "step diff failed");
// Get index state after
let index_after = git_status(&repo, &feature_path);
assert_eq!(
index_before, index_after,
"Real git index was modified by step diff"
);
}
/// Explicit target branch
#[rstest]
fn test_step_diff_explicit_target(mut repo: TestRepo) {
let feature_path = setup_feature_with_commit(&mut repo);
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff", "main"],
Some(&feature_path),
));
}
/// `--branch` diffs another worktree's branch from a different worktree
#[rstest]
fn test_step_diff_branch_arg(mut repo: TestRepo) {
setup_feature_with_commit(&mut repo);
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
// Run from the main worktree (repo root, cwd = None), targeting `feature`.
// Output should match `test_step_diff_committed_changes`, which runs the
// same diff from inside the feature worktree.
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff", "--branch", "feature"],
None,
));
}
/// `--branch` for a branch without a worktree errors
#[rstest]
fn test_step_diff_branch_no_worktree(repo: TestRepo) {
repo.run_git(&["branch", "lonely"]);
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff", "--branch", "lonely"],
None,
));
}
/// `--branch=` (empty value) is rejected at the parse boundary with a clear
/// usage error, not surfaced as a garbled `Branch has no worktree`.
#[rstest]
fn test_step_diff_branch_empty(repo: TestRepo) {
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff", "--branch="],
None,
));
}
/// A branch checked out in two worktrees (as `git worktree add --force`
/// produces) resolves to the first, warning once and naming every path so the
/// otherwise-silent choice is visible.
#[rstest]
fn test_step_diff_duplicate_branch_warns(mut repo: TestRepo) {
repo.add_worktree("feature");
let dup_path = repo.root_path().parent().unwrap().join("repo.feature-dup");
repo.run_git(&[
"worktree",
"add",
"--force",
dup_path.to_str().unwrap(),
"feature",
]);
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff", "--branch=feature"],
None,
));
}
fn git_status(repo: &TestRepo, dir: &Path) -> String {
let output = repo
.git_command()
.args(["status", "--porcelain"])
.current_dir(dir)
.run()
.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"
);
}