mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
14580de79c
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>
287 lines
8.5 KiB
Rust
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"
|
|
);
|
|
}
|