From 2e2edf30ec72f5d96dfb7909fee1fc3117f370b1 Mon Sep 17 00:00:00 2001 From: YingzuoLiu <156630675+YingzuoLiu@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:04:21 +0800 Subject: [PATCH 1/2] feat: preview worktree removal without side effects --- README.md | 25 ++++- completions/_git-gtr | 2 + completions/git-gtr.fish | 1 + completions/gtr.bash | 2 +- lib/commands/help.sh | 9 ++ lib/commands/remove.sh | 82 +++++++++++++- scripts/generate-completions.sh | 5 +- tests/cmd_remove_preview.bats | 189 ++++++++++++++++++++++++++++++++ 8 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 tests/cmd_remove_preview.bats diff --git a/README.md b/README.md index 46440e8..42f1d57 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,30 @@ git gtr rm feature-a feature-b # Remove multiple git gtr rm my-feature --delete-branch --force # Delete branch and force ``` -**Options:** `--delete-branch`, `--force`, `--yes` +**Options:** `--delete-branch`, `--force`, `--yes`, `--dry-run` / `-n` + +```bash +git gtr rm feature/auth -n +git gtr rm feature/auth feature/api --dry-run --delete-branch --force +``` + +`rm --dry-run` (`-n`) reports resolved paths, branches (including detached HEAD), +tracked changes, untracked files and locks without deleting anything, running +preRemove/postRemove hooks, or changing hook trust state. Invalid targets produce +a non-zero exit status while other valid targets are still reported. A preview +is advisory: hooks and other Git removal restrictions are not evaluated. + +Without `--force`, modified/untracked files block removal; `--force` can discard +these files. Ignored files do not block removal and are also removed with the +worktree. Locked worktrees remain blocked even with the existing single `--force`. + +With `--delete-branch`, the preview counts branch commits not reachable from a +verified base: the default remote/branch selected by `new`, preferring its local +remote-tracking ref, then the corresponding local branch, otherwise unknown. +It never fetches. Remote-tracking refs may be stale. Reachability is **not** a +merged/unmerged judgment: squash merges and rebases can leave different commit +identities even when the changes were integrated. Actual branch deletion still +uses the existing `git branch -D` behavior. ### `git gtr mv [--force] [--yes]` diff --git a/completions/_git-gtr b/completions/_git-gtr index 6ac4e88..887d6b9 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -164,6 +164,8 @@ _git-gtr() { ;; rm) _arguments \ + '--dry-run[Preview removal without changes]' \ + '-n[Preview removal without changes]' \ '--delete-branch[Delete branch]' \ '--force[Force removal even if worktree has uncommitted changes or untracked files]' \ '--yes[Non-interactive mode]' diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index 7cca5f3..7d4a131 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -91,6 +91,7 @@ complete -c git -n '__fish_git_gtr_using_command pr' -s e -l editor -d 'Open in complete -c git -n '__fish_git_gtr_using_command pr' -s a -l ai -d 'Start AI tool after creation' # Remove command options +complete -c git -n '__fish_git_gtr_using_command rm' -l dry-run -s n -d 'Preview removal without changes' complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch' complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files' complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode' diff --git a/completions/gtr.bash b/completions/gtr.bash index da365be..5e3d9d1 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -43,7 +43,7 @@ _git_gtr() { elif [[ "$cur" == -* ]]; then case "$cmd" in rm) - COMPREPLY=($(compgen -W "--delete-branch --force --yes" -- "$cur")) + COMPREPLY=($(compgen -W "--delete-branch --force --yes --dry-run -n" -- "$cur")) ;; mv|rename) COMPREPLY=($(compgen -W "--force --yes" -- "$cur")) diff --git a/lib/commands/help.sh b/lib/commands/help.sh index a032691..8b19f25 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -213,15 +213,23 @@ Removes one or more worktrees by branch name. Runs pre-remove and post-remove hooks unless --force is used to override hook failures. Options: + --dry-run, -n Preview paths, changes, locks and branch reachability; run no hooks --delete-branch Also delete the git branch after removing the worktree --force Force removal even if worktree has uncommitted changes --yes Skip confirmation prompts Examples: + git gtr rm my-feature -n --delete-branch # Preview without changes git gtr rm my-feature # Remove worktree git gtr rm my-feature --delete-branch # Remove worktree + branch git gtr rm feat-1 feat-2 feat-3 # Remove multiple git gtr rm my-feature --force --yes # Force, no prompts + +Dry-run uses locally verified default remote/branch refs (same selection as new), +falling back to the selected local branch or reporting unknown. Reachability +counts are not merge verdicts: squash/rebase can mislead; remote refs may be stale. +--force can discard uncommitted/untracked files; locked worktrees remain blocked. +Dry-run never runs hooks or changes trust. Other Git restrictions are not checked. EOF } @@ -622,6 +630,7 @@ CORE COMMANDS (daily workflow): rm [...] [options] Remove worktree(s) by branch name + --dry-run, -n: preview without changes or hooks --delete-branch: also delete the branch --force: force removal (dirty worktree) --yes: skip confirmation diff --git a/lib/commands/remove.sh b/lib/commands/remove.sh index 776086e..cde88c9 100644 --- a/lib/commands/remove.sh +++ b/lib/commands/remove.sh @@ -6,14 +6,16 @@ cmd_remove() { local _spec _spec="--delete-branch --yes ---force" +--force +--dry-run|-n" parse_args "$_spec" "$@" - require_args 1 "Usage: git gtr rm [...] [--delete-branch] [--force] [--yes]" + require_args 1 "Usage: git gtr rm [...] [--delete-branch] [--force] [--yes] [--dry-run|-n]" local delete_branch="${_arg_delete_branch:-0}" local yes_mode="${_arg_yes:-0}" local force="${_arg_force:-0}" + local dry_run="${_arg_dry_run:-0}" local had_failure=0 resolve_repo_context || exit 1 @@ -37,6 +39,11 @@ cmd_remove() { continue fi + if [ "$dry_run" -eq 1 ]; then + _remove_preview "$repo_root" "$worktree_path" "$branch_name" "$delete_branch" "$force" || had_failure=1 + continue + fi + log_step "Removing worktree: $(basename "$worktree_path")" # Run pre-remove hooks (abort on failure unless --force) @@ -83,3 +90,74 @@ cmd_remove() { return "$had_failure" } + +# Read-only advisory preview. Never enter hook dispatch (including trust checks). +_remove_preview() { + local repo_root="$1" worktree_path="$2" branch_name="$3" delete_branch="$4" force="$5" + local state status_output line dirty=no untracked=no locked=no + [ "$branch_name" != "(detached)" ] || branch_name="" + printf '[dry-run] Worktree: %s\n' "$(_tsv_escape_field "$worktree_path")" + printf ' Branch: %s\n' "${branch_name:-detached HEAD}" + if [ ! -d "$worktree_path" ]; then + log_error "Worktree not found at $worktree_path" + return 1 + fi + state=$(worktree_status "$worktree_path") || return 1 + [ "$state" != locked ] || locked=yes + # Avoid refreshing/writing the index. Disable fsmonitor to avoid invoking a + # configured external helper while inspecting the worktree. + if ! status_output=$(GIT_OPTIONAL_LOCKS=0 git -C "$worktree_path" -c core.fsmonitor=false \ + status --porcelain=v1 --untracked-files=all --ignore-submodules=none); then + log_error "Cannot inspect worktree: $worktree_path" + return 1 + fi + while IFS= read -r line; do + case "$line" in + '') ;; + '?? '*) untracked=yes ;; + *) dirty=yes ;; + esac + done </dev/null); then + base_ref="$candidate" + break + fi + done + printf ' Branch deletion requested: %s (git branch -D).\n' "$branch_name" + if [ -n "$base_ref" ] && count=$(git rev-list --count "$base_oid..refs/heads/$branch_name" -- 2>/dev/null); then + printf ' Commits not reachable from %s: %s\n' "$base_ref" "$count" + else + printf ' Commits not reachable from base: unknown (no verified base or branch tip).\n' + fi + printf ' Reachability is not a merged/unmerged judgment; squash/rebase can mislead.\n' + printf ' Uses local refs only; remote-tracking refs may be stale.\n' +} diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index ea192ba..2cf628d 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -133,7 +133,7 @@ _git_gtr() { elif [[ "$cur" == -* ]]; then case "$cmd" in rm) - COMPREPLY=($(compgen -W "--delete-branch --force --yes" -- "$cur")) + COMPREPLY=($(compgen -W "--delete-branch --force --yes --dry-run -n" -- "$cur")) ;; mv|rename) COMPREPLY=($(compgen -W "--force --yes" -- "$cur")) @@ -432,6 +432,8 @@ MIDDLE1 ;; rm) _arguments \ + '--dry-run[Preview removal without changes]' \ + '-n[Preview removal without changes]' \ '--delete-branch[Delete branch]' \ '--force[Force removal even if worktree has uncommitted changes or untracked files]' \ '--yes[Non-interactive mode]' @@ -601,6 +603,7 @@ complete -c git -n '__fish_git_gtr_using_command pr' -s e -l editor -d 'Open in complete -c git -n '__fish_git_gtr_using_command pr' -s a -l ai -d 'Start AI tool after creation' # Remove command options +complete -c git -n '__fish_git_gtr_using_command rm' -l dry-run -s n -d 'Preview removal without changes' complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch' complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files' complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode' diff --git a/tests/cmd_remove_preview.bats b/tests/cmd_remove_preview.bats new file mode 100644 index 0000000..6495ee3 --- /dev/null +++ b/tests/cmd_remove_preview.bats @@ -0,0 +1,189 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_integration_repo + git branch -M main + source_gtr_commands + export XDG_CONFIG_HOME="$TEST_REPO/xdg" +} + +teardown() { + teardown_integration_repo +} + +preview() { + "$PROJECT_ROOT/bin/gtr" rm "$@" +} + +@test "rm preview preserves files refs registry index and trust without executing hooks" { + create_test_worktree topic + git config gtr.hook.preRemove "touch '$TEST_REPO/pre-ran'" + git config gtr.hook.postRemove "touch '$TEST_REPO/post-ran'" + printf '[hooks]\n preRemove = touch team-pre\n postRemove = touch team-post\n' > "$TEST_REPO/.gtrconfig" + cp "$TEST_REPO/.gtrconfig" "$TEST_WT_PATH/.gtrconfig" + mkdir -p "$XDG_CONFIG_HOME/gtr/trusted" + echo marker > "$XDG_CONFIG_HOME/gtr/trusted/existing" + echo keep > "$TEST_WT_PATH/untracked" + local before_refs before_registry before_index + before_refs=$(git show-ref) + before_registry=$(git worktree list --porcelain) + before_index=$(git hash-object "$TEST_REPO/.git/worktrees/topic/index") + run preview topic -n --force --delete-branch --yes + [ "$status" -eq 0 ] + [[ "$output" == *"$TEST_WT_PATH"* ]] + [[ "$output" == *"Branch: topic"* ]] + [ "$(git show-ref)" = "$before_refs" ] + [ "$(git worktree list --porcelain)" = "$before_registry" ] + [ "$(git hash-object "$TEST_REPO/.git/worktrees/topic/index")" = "$before_index" ] + [ "$(cat "$TEST_WT_PATH/untracked")" = keep ] + [ "$(cat "$XDG_CONFIG_HOME/gtr/trusted/existing")" = marker ] + [ "$(find "$XDG_CONFIG_HOME" -type f | wc -l)" -eq 1 ] + [ ! -e "$TEST_REPO/pre-ran" ] + [ ! -e "$TEST_REPO/post-ran" ] + [ ! -e "$TEST_WT_PATH/team-pre" ] + [ ! -e "$TEST_REPO/team-post" ] +} + +@test "rm preview does not create absent trust storage or invoke fsmonitor" { + create_test_worktree topic + printf '[hooks]\n preRemove = exit 1\n' > "$TEST_REPO/.gtrconfig" + git config core.fsmonitor "touch '$TEST_REPO/fsmonitor-ran'" + run preview topic --dry-run + [ "$status" -eq 0 ] + [ ! -e "$XDG_CONFIG_HOME" ] + [ ! -e "$TEST_REPO/fsmonitor-ran" ] +} + +@test "rm preview reports tracked and untracked changes and force risk" { + create_test_worktree topic + echo original > "$TEST_WT_PATH/tracked" + git -C "$TEST_WT_PATH" add tracked + git -C "$TEST_WT_PATH" commit -qm tracked + echo modified > "$TEST_WT_PATH/tracked" + echo keep > "$TEST_WT_PATH/untracked" + run preview topic --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"Dirty (tracked): yes; untracked: yes; locked: no"* ]] + [[ "$output" == *"Blocked without --force"* ]] + [[ "$output" == *"discard uncommitted changes and untracked files"* ]] + run preview topic -n --force + [ "$status" -eq 0 ] + [[ "$output" != *"Blocked without --force"* ]] + [ "$(cat "$TEST_WT_PATH/tracked")" = modified ] +} + +@test "rm preview does not count ignored files as blocking" { + create_test_worktree topic + echo ignored > "$TEST_REPO/.git/info/exclude" + touch "$TEST_WT_PATH/ignored" + run preview topic -n + [ "$status" -eq 0 ] + [[ "$output" == *"Dirty (tracked): no; untracked: no"* ]] + [ -f "$TEST_WT_PATH/ignored" ] +} + +@test "rm preview reports locked worktrees blocked even with force" { + create_test_worktree topic + git worktree lock "$TEST_WT_PATH" + run preview topic -n --force + [ "$status" -eq 0 ] + [[ "$output" == *"locked: yes"* ]] + [[ "$output" == *"single --force cannot remove"* ]] + git worktree list --porcelain | grep '^locked' + git worktree unlock "$TEST_WT_PATH" +} + +@test "rm preview uses verified local default branch when no remote ref exists" { + create_test_worktree topic + git -C "$TEST_WT_PATH" commit --allow-empty -qm extra + run preview topic -n --delete-branch + [ "$status" -eq 0 ] + [[ "$output" == *"Commits not reachable from refs/heads/main: 1"* ]] +} + +@test "rm preview prefers configured remote and branch over local branch" { + create_test_worktree topic + git -C "$TEST_WT_PATH" commit --allow-empty -qm extra + git branch release main + git update-ref refs/remotes/upstream/release topic + git config gtr.defaultRemote upstream + git config gtr.defaultBranch release + run preview topic -n --delete-branch + [ "$status" -eq 0 ] + [[ "$output" == *"Commits not reachable from refs/remotes/upstream/release: 0"* ]] +} + +@test "rm preview follows selected remote HEAD default branch" { + create_test_worktree topic + git update-ref refs/remotes/upstream/develop main + git symbolic-ref refs/remotes/upstream/HEAD refs/remotes/upstream/develop + git config gtr.defaultRemote upstream + run preview topic -n --delete-branch + [ "$status" -eq 0 ] + [[ "$output" == *"Commits not reachable from refs/remotes/upstream/develop: 0"* ]] +} + +@test "rm preview falls back from a missing remote tip to selected local branch" { + create_test_worktree topic + git branch release main + git config gtr.defaultBranch release + run preview topic -n --delete-branch + [ "$status" -eq 0 ] + [[ "$output" == *"Commits not reachable from refs/heads/release: 0"* ]] +} + +@test "rm preview reports unknown for missing base and does not accept a same-name tag" { + create_test_worktree topic + git config gtr.defaultBranch absent + git tag absent + run preview topic -n --delete-branch + [ "$status" -eq 0 ] + [[ "$output" == *"base: unknown"* ]] + [[ "$output" != *"refs/heads/main:"* ]] +} + +@test "rm preview warns on reachability after squash merge without merge verdict" { + create_test_worktree topic + echo change > "$TEST_WT_PATH/file" + git -C "$TEST_WT_PATH" add file + git -C "$TEST_WT_PATH" commit -qm feature + git merge --squash topic + git commit -qm squash + run preview topic -n --delete-branch + [ "$status" -eq 0 ] + [[ "$output" == *"Commits not reachable from refs/heads/main: 1"* ]] + [[ "$output" == *"not a merged/unmerged judgment; squash/rebase can mislead"* ]] +} + +@test "rm preview handles detached HEAD without proposing branch deletion" { + git worktree add --detach "$TEST_WORKTREES_DIR/detached" HEAD + run preview detached -n --delete-branch + [ "$status" -eq 0 ] + [[ "$output" == *"Branch: detached HEAD"* ]] + [[ "$output" == *"Branch deletion: none (detached HEAD)"* ]] + [ -d "$TEST_WORKTREES_DIR/detached" ] +} + +@test "rm preview reports valid targets on either side of invalid targets" { + create_test_worktree first + create_test_worktree last + run env PATH="$PROJECT_ROOT/bin:$PATH" git gtr rm first nonexistent 1 last -n + [ "$status" -ne 0 ] + [[ "$output" == *"Branch: first"* ]] + [[ "$output" == *"Cannot remove main repository"* ]] + [[ "$output" == *"Branch: last"* ]] + [ -d "$TEST_WORKTREES_DIR/first" ] + [ -d "$TEST_WORKTREES_DIR/last" ] +} + +@test "rm preview fails for missing registered directory and continues" { + create_test_worktree missing + rm -rf "$TEST_WT_PATH" + create_test_worktree topic + run preview missing topic -n + [ "$status" -ne 0 ] + [[ "$output" == *"Branch: topic"* ]] + [ -d "$TEST_WT_PATH" ] +} From 9b7676b3a4952e855e67666afd417fe7d74f5f0c Mon Sep 17 00:00:00 2001 From: YingzuoLiu <156630675+YingzuoLiu@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:13:33 +0800 Subject: [PATCH 2/2] fix: address removal preview review findings Clarify Git filter and untracked-file caveats, complete rm options and targets in Bash/Zsh, and strengthen index preservation coverage. --- README.md | 16 +++++++---- completions/_git-gtr | 26 +++++++++++------ completions/git-gtr.fish | 2 +- completions/gtr.bash | 14 +++++++--- lib/commands/help.sh | 4 +-- lib/commands/remove.sh | 17 +++++++++--- scripts/generate-completions.sh | 45 ++++++++++++++++++++---------- tests/cmd_remove_preview.bats | 17 ++++++++++++ tests/completion.bats | 49 +++++++++++++++++++++++++++++++++ 9 files changed, 151 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 42f1d57..51f9f02 100644 --- a/README.md +++ b/README.md @@ -330,11 +330,17 @@ git gtr rm feature/auth feature/api --dry-run --delete-branch --force tracked changes, untracked files and locks without deleting anything, running preRemove/postRemove hooks, or changing hook trust state. Invalid targets produce a non-zero exit status while other valid targets are still reported. A preview -is advisory: hooks and other Git removal restrictions are not evaluated. - -Without `--force`, modified/untracked files block removal; `--force` can discard -these files. Ignored files do not block removal and are also removed with the -worktree. Locked worktrees remain blocked even with the existing single `--force`. +is advisory: hooks and other Git removal restrictions are not evaluated. The status +check disables fsmonitor and optional index writes, but configured clean/process +filters can still run and write their own state. + +Without `--force`, modified tracked files block removal. Protection for untracked +files depends on Git status settings: for example, `status.showUntrackedFiles=no` +can allow actual removal to delete them. The preview explicitly includes untracked +files even when that setting hides them; its report is advisory. `--force` can +discard modified and untracked files. Ignored files do not block removal and are +also removed with the worktree. Locked worktrees remain blocked even with the +existing single `--force`. With `--delete-branch`, the preview counts branch commits not reachable from a verified base: the default remote/branch selected by `new`, preferring its local diff --git a/completions/_git-gtr b/completions/_git-gtr index 887d6b9..61a81ef 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -125,13 +125,29 @@ _git-gtr() { return fi + # Handle rm options and targets at every argument position. + if (( CURRENT >= 4 )) && [[ $words[3] == rm ]]; then + local state + _arguments \ + '--dry-run[Preview without removing worktrees]' \ + '-n[Preview without removing worktrees]' \ + '--delete-branch[Delete branch]' \ + '--force[Force removal even if worktree has uncommitted changes or untracked files]' \ + '--yes[Non-interactive mode]' \ + '*:target:->worktrees' + case "$state" in + worktrees) _describe 'branch names' all_options ;; + esac + return + fi + # Complete the gtr subcommand (new, go, editor, etc.) if (( CURRENT == 3 )); then _describe 'commands' commands # Complete arguments to the subcommand elif (( CURRENT == 4 )); then case "$words[3]" in - go|run|rm|mv|rename|copy) + go|run|mv|rename|copy) _describe 'branch names' all_options ;; editor) @@ -162,14 +178,6 @@ _git-gtr() { ai) _arguments '--ai[AI tool to use]:tool:(aider antigravity auggie claude codex continue copilot cursor gemini none opencode)' ;; - rm) - _arguments \ - '--dry-run[Preview removal without changes]' \ - '-n[Preview removal without changes]' \ - '--delete-branch[Delete branch]' \ - '--force[Force removal even if worktree has uncommitted changes or untracked files]' \ - '--yes[Non-interactive mode]' - ;; mv|rename) _arguments \ '--force[Force move even if locked]' \ diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index 7d4a131..0ffdfcb 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -91,7 +91,7 @@ complete -c git -n '__fish_git_gtr_using_command pr' -s e -l editor -d 'Open in complete -c git -n '__fish_git_gtr_using_command pr' -s a -l ai -d 'Start AI tool after creation' # Remove command options -complete -c git -n '__fish_git_gtr_using_command rm' -l dry-run -s n -d 'Preview removal without changes' +complete -c git -n '__fish_git_gtr_using_command rm' -l dry-run -s n -d 'Preview without removing worktrees' complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch' complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files' complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode' diff --git a/completions/gtr.bash b/completions/gtr.bash index 5e3d9d1..4f6fc19 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -33,7 +33,16 @@ _git_gtr() { # Commands that take branch names or '1' for main repo case "$cmd" in - go|run|rm|mv|rename) + rm) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--delete-branch --force --yes --dry-run -n" -- "$cur")) + else + local branches + branches=$(git branch --format='%(refname:short)' 2>/dev/null || true) + COMPREPLY=($(compgen -W "$branches" -- "$cur")) + fi + ;; + go|run|mv|rename) if [ "$cword" -eq 3 ]; then # Complete with branch names and special ID '1' for main repo local branches all_options @@ -42,9 +51,6 @@ _git_gtr() { COMPREPLY=($(compgen -W "$all_options" -- "$cur")) elif [[ "$cur" == -* ]]; then case "$cmd" in - rm) - COMPREPLY=($(compgen -W "--delete-branch --force --yes --dry-run -n" -- "$cur")) - ;; mv|rename) COMPREPLY=($(compgen -W "--force --yes" -- "$cur")) ;; diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 8b19f25..3e45a14 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -219,7 +219,7 @@ Options: --yes Skip confirmation prompts Examples: - git gtr rm my-feature -n --delete-branch # Preview without changes + git gtr rm my-feature -n --delete-branch # Preview without removal git gtr rm my-feature # Remove worktree git gtr rm my-feature --delete-branch # Remove worktree + branch git gtr rm feat-1 feat-2 feat-3 # Remove multiple @@ -630,7 +630,7 @@ CORE COMMANDS (daily workflow): rm [...] [options] Remove worktree(s) by branch name - --dry-run, -n: preview without changes or hooks + --dry-run, -n: preview without removal or hooks --delete-branch: also delete the branch --force: force removal (dirty worktree) --yes: skip confirmation diff --git a/lib/commands/remove.sh b/lib/commands/remove.sh index cde88c9..fb9d0ee 100644 --- a/lib/commands/remove.sh +++ b/lib/commands/remove.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Remove command +# Remove explicit worktree targets, or preview them before hook dispatch. +# Usage: cmd_remove ... [--delete-branch] [--force] [--yes] [--dry-run|-n] # shellcheck disable=SC2154 # _arg_* _pa_* set by parse_args, _ctx_* set by resolve_* cmd_remove() { local _spec @@ -91,7 +92,8 @@ cmd_remove() { return "$had_failure" } -# Read-only advisory preview. Never enter hook dispatch (including trust checks). +# Print advisory status without removing targets or entering hook/trust dispatch. +# Usage: _remove_preview repo_root worktree_path branch_name delete_branch force _remove_preview() { local repo_root="$1" worktree_path="$2" branch_name="$3" delete_branch="$4" force="$5" local state status_output line dirty=no untracked=no locked=no @@ -105,7 +107,8 @@ _remove_preview() { state=$(worktree_status "$worktree_path") || return 1 [ "$state" != locked ] || locked=yes # Avoid refreshing/writing the index. Disable fsmonitor to avoid invoking a - # configured external helper while inspecting the worktree. + # configured external helper while inspecting the worktree. Configured clean/process + # filters may still run and write their own state during this status check. if ! status_output=$(GIT_OPTIONAL_LOCKS=0 git -C "$worktree_path" -c core.fsmonitor=false \ status --porcelain=v1 --untracked-files=all --ignore-submodules=none); then log_error "Cannot inspect worktree: $worktree_path" @@ -125,7 +128,11 @@ EOF printf ' Blocked: locked worktree; the existing single --force cannot remove it.\n' elif [ "$dirty" = yes ] || [ "$untracked" = yes ]; then if [ "$force" -eq 0 ]; then - printf ' Blocked without --force: modified or untracked files.\n' + if [ "$dirty" = yes ]; then + printf ' Blocked without --force: modified tracked files.\n' + else + printf ' Untracked files may block removal without --force, depending on Git status settings.\n' + fi fi fi printf ' Warning: --force can discard uncommitted changes and untracked files.\n' @@ -140,6 +147,8 @@ EOF printf ' Advisory only: hooks and other Git removal restrictions are not evaluated.\n' } +# Report advisory reachability against a verified local default-base ref; never fetch. +# Usage: _remove_preview_branch repo_root branch_name _remove_preview_branch() { local repo_root="$1" branch_name="$2" remote default_branch candidate base_ref="" base_oid="" count remote=$(resolve_default_remote) diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index 2cf628d..6e43c6e 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -86,6 +86,7 @@ CONFIG_KEYS=$(get_config_keys) # ── Generators ─────────────────────────────────────────────────────────────── +# Emit Bash completions for git gtr commands, options and worktree targets. generate_bash() { cat <<'HEADER' #!/usr/bin/env bash @@ -123,7 +124,16 @@ _git_gtr() { # Commands that take branch names or '1' for main repo case "$cmd" in - go|run|rm|mv|rename) + rm) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--delete-branch --force --yes --dry-run -n" -- "$cur")) + else + local branches + branches=$(git branch --format='%(refname:short)' 2>/dev/null || true) + COMPREPLY=($(compgen -W "$branches" -- "$cur")) + fi + ;; + go|run|mv|rename) if [ "$cword" -eq 3 ]; then # Complete with branch names and special ID '1' for main repo local branches all_options @@ -132,9 +142,6 @@ _git_gtr() { COMPREPLY=($(compgen -W "$all_options" -- "$cur")) elif [[ "$cur" == -* ]]; then case "$cmd" in - rm) - COMPREPLY=($(compgen -W "--delete-branch --force --yes --dry-run -n" -- "$cur")) - ;; mv|rename) COMPREPLY=($(compgen -W "--force --yes" -- "$cur")) ;; @@ -260,6 +267,7 @@ MIDDLE3 FOOTER } +# Emit Zsh completions with positional worktree targets and option descriptions. generate_zsh() { cat <<'HEADER' #compdef _git-gtr git-gtr gtr @@ -389,13 +397,29 @@ _git-gtr() { return fi + # Handle rm options and targets at every argument position. + if (( CURRENT >= 4 )) && [[ $words[3] == rm ]]; then + local state + _arguments \ + '--dry-run[Preview without removing worktrees]' \ + '-n[Preview without removing worktrees]' \ + '--delete-branch[Delete branch]' \ + '--force[Force removal even if worktree has uncommitted changes or untracked files]' \ + '--yes[Non-interactive mode]' \ + '*:target:->worktrees' + case "$state" in + worktrees) _describe 'branch names' all_options ;; + esac + return + fi + # Complete the gtr subcommand (new, go, editor, etc.) if (( CURRENT == 3 )); then _describe 'commands' commands # Complete arguments to the subcommand elif (( CURRENT == 4 )); then case "$words[3]" in - go|run|rm|mv|rename|copy) + go|run|mv|rename|copy) _describe 'branch names' all_options ;; editor) @@ -430,14 +454,6 @@ MIDDLE1 printf " _arguments '--ai[AI tool to use]:tool:(%s)'\n" "$AI_TOOLS" cat <<'MIDDLE2' ;; - rm) - _arguments \ - '--dry-run[Preview removal without changes]' \ - '-n[Preview removal without changes]' \ - '--delete-branch[Delete branch]' \ - '--force[Force removal even if worktree has uncommitted changes or untracked files]' \ - '--yes[Non-interactive mode]' - ;; mv|rename) _arguments \ '--force[Force move even if locked]' \ @@ -508,6 +524,7 @@ _git-gtr "$@" FOOTER } +# Emit Fish completions for git gtr commands, options and worktree targets. generate_fish() { cat <<'HEADER' # AUTO-GENERATED by scripts/generate-completions.sh — DO NOT EDIT MANUALLY @@ -603,7 +620,7 @@ complete -c git -n '__fish_git_gtr_using_command pr' -s e -l editor -d 'Open in complete -c git -n '__fish_git_gtr_using_command pr' -s a -l ai -d 'Start AI tool after creation' # Remove command options -complete -c git -n '__fish_git_gtr_using_command rm' -l dry-run -s n -d 'Preview removal without changes' +complete -c git -n '__fish_git_gtr_using_command rm' -l dry-run -s n -d 'Preview without removing worktrees' complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch' complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files' complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode' diff --git a/tests/cmd_remove_preview.bats b/tests/cmd_remove_preview.bats index 6495ee3..b4c5832 100644 --- a/tests/cmd_remove_preview.bats +++ b/tests/cmd_remove_preview.bats @@ -26,6 +26,11 @@ preview() { mkdir -p "$XDG_CONFIG_HOME/gtr/trusted" echo marker > "$XDG_CONFIG_HOME/gtr/trusted/existing" echo keep > "$TEST_WT_PATH/untracked" + echo original > "$TEST_WT_PATH/tracked" + git -C "$TEST_WT_PATH" add tracked + git -C "$TEST_WT_PATH" commit -qm tracked + # Make cached stat data stale without changing content before hashing the index. + touch -t 200001010000 "$TEST_WT_PATH/tracked" local before_refs before_registry before_index before_refs=$(git show-ref) before_registry=$(git worktree list --porcelain) @@ -187,3 +192,15 @@ preview() { [[ "$output" == *"Branch: topic"* ]] [ -d "$TEST_WT_PATH" ] } + +@test "rm preview reports untracked files even when Git status hides them" { + create_test_worktree topic + git config status.showUntrackedFiles no + echo keep > "$TEST_WT_PATH/untracked" + run preview topic -n + [ "$status" -eq 0 ] + [[ "$output" == *"untracked: yes"* ]] + [[ "$output" == *"depending on Git status settings"* ]] + [[ "$output" != *"Blocked without --force"* ]] + [ "$(cat "$TEST_WT_PATH/untracked")" = keep ] +} diff --git a/tests/completion.bats b/tests/completion.bats index 10ba672..e57fb19 100644 --- a/tests/completion.bats +++ b/tests/completion.bats @@ -108,3 +108,52 @@ setup_homebrew_layout() { [[ "$output" == *"Could not find bash completion asset under:"* ]] [[ "$output" == *"Expected either a source checkout (completions/) or a Homebrew install layout."* ]] } + +@test "Bash rm completion handles preview flags before and after targets" { + run bash -c ' + source "$1/completions/gtr.bash" + _init_completion() { + words=(git gtr rm "${test_args[@]}") + cword=$((${#words[@]} - 1)) + cur=${words[$cword]} + prev=${words[$((cword - 1))]} + } + git() { printf "%s\n" topic; } + test_args=(--dry) + _git_gtr + [ "${COMPREPLY[*]}" = --dry-run ] || exit 1 + test_args=(-n "") + _git_gtr + [ "${COMPREPLY[*]}" = topic ] || exit 1 + test_args=(topic --dry) + _git_gtr + [ "${COMPREPLY[*]}" = --dry-run ] || exit 1 + test_args=(to) + _git_gtr + [ "${COMPREPLY[*]}" = topic ] + ' _ "$PROJECT_ROOT" + [ "$status" -eq 0 ] +} + +@test "Zsh rm completion offers targets after preview options" { + command -v zsh >/dev/null || skip "zsh is not installed" + run zsh -c ' + _arguments() { + [[ " $* " == *" *:target:->worktrees "* ]] || exit 1 + [[ " $* " == *" --dry-run["* ]] || exit 1 + state=worktrees + } + _describe() { [[ "$1" == "branch names" ]] || exit 1; described=yes; } + git() { print topic; } + words=(git gtr rm -n "") + CURRENT=5 + source "$1/completions/_git-gtr" + [[ "$described" == yes ]] || exit 1 + described=no + words=(git gtr rm --dry) + CURRENT=4 + _git-gtr + [[ "$described" == yes ]] + ' _ "$PROJECT_ROOT" + [ "$status" -eq 0 ] +}