diff --git a/README.md b/README.md index a10307e..b300138 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,37 @@ 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. 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 +remote-tracking ref, then the corresponding local branch. It reports an unknown +count if a tip is missing or shallow history affects either tip. +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 81a5990..69cced1 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,12 +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 \ - '--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 ae2e290..263787d 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 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 50b0bc1..69fdc01 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -135,15 +135,19 @@ _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 + _git_gtr_complete_targets "$cur" + fi + ;; + go|run|mv|rename) if [ "$cword" -eq 3 ]; then # Complete with branch names and special ID '1' for main repo _git_gtr_complete_targets "$cur" elif [[ "$cur" == -* ]]; then case "$cmd" in - rm) - COMPREPLY=($(compgen -W "--delete-branch --force --yes" -- "$cur")) - ;; mv|rename) COMPREPLY=($(compgen -W "--force --yes" -- "$cur")) ;; diff --git a/lib/commands/help.sh b/lib/commands/help.sh index f00da2e..a49a9f3 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -220,15 +220,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 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 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 } @@ -631,6 +639,7 @@ CORE COMMANDS (daily workflow): rm [...] [options] Remove worktree(s) by branch name + --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 776086e..6a3a1c8 100644 --- a/lib/commands/remove.sh +++ b/lib/commands/remove.sh @@ -1,19 +1,22 @@ #!/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 _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 +40,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 +91,99 @@ cmd_remove() { return "$had_failure" } + +# 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 + [ "$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. 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" + 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 [ -z "$base_ref" ] || ! branch_oid=$(git rev-parse --verify "refs/heads/$branch_name^{commit}" 2>/dev/null); then + printf ' Commits not reachable from base: unknown (no verified base or branch tip).\n' + elif ! _remove_preview_history_complete "$base_oid" "$branch_oid"; then + printf ' Commits not reachable from base: unknown (incomplete history).\n' + elif count=$(git rev-list --count "$base_oid..$branch_oid" -- 2>/dev/null); then + printf ' Commits not reachable from %s: %s\n' "$base_ref" "$count" + else + printf ' Commits not reachable from base: unknown (cannot inspect history).\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' +} + +# A shallow boundary reachable from either tip may change the set difference. +_remove_preview_history_complete() { + local base_oid="$1" branch_oid="$2" shallow_file boundary tip + shallow_file=$(git rev-parse --git-path shallow) || return 1 + [ -e "$shallow_file" ] || return 0 + while IFS= read -r boundary; do + for tip in "$base_oid" "$branch_oid"; do + if git merge-base --is-ancestor "$boundary" "$tip" 2>/dev/null; then + return 1 + elif [ "$?" -ne 1 ]; then + return 1 + fi + done + done < "$shallow_file" +} diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index 79558d6..4d94765 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -137,15 +137,19 @@ _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 + _git_gtr_complete_targets "$cur" + fi + ;; + go|run|mv|rename) if [ "$cword" -eq 3 ]; then # Complete with branch names and special ID '1' for main repo _git_gtr_complete_targets "$cur" elif [[ "$cur" == -* ]]; then case "$cmd" in - rm) - COMPREPLY=($(compgen -W "--delete-branch --force --yes" -- "$cur")) - ;; mv|rename) COMPREPLY=($(compgen -W "--force --yes" -- "$cur")) ;; @@ -262,6 +266,7 @@ MIDDLE3 FOOTER } +# Emit Zsh completions with positional worktree targets and option descriptions. generate_zsh() { cat <<'HEADER' #compdef _git-gtr git-gtr gtr @@ -391,13 +396,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) @@ -432,12 +453,6 @@ MIDDLE1 printf " _arguments '--ai[AI tool to use]:tool:(%s)'\n" "$AI_TOOLS" cat <<'MIDDLE2' ;; - rm) - _arguments \ - '--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 +523,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,6 +619,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 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 new file mode 100644 index 0000000..454ba79 --- /dev/null +++ b/tests/cmd_remove_preview.bats @@ -0,0 +1,228 @@ +#!/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" + 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) + 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 reports unknown when a shallow clone has both tips but hides commits" { + git branch topic main + git checkout -q topic + git commit --allow-empty -qm first + git commit --allow-empty -qm second + git checkout -q main + [ "$(git rev-list --count main..topic)" -eq 2 ] + + local shallow_repo="$TEST_REPO/shallow" + git clone -q --depth 1 --no-single-branch "file://$TEST_REPO" "$shallow_repo" + cd "$shallow_repo" + git config gtr.worktrees.dir "$TEST_WORKTREES_DIR" + git branch --no-track topic origin/topic + git worktree add -q "$TEST_WORKTREES_DIR/topic" topic + [ "$(git rev-list --count origin/main..topic)" -eq 1 ] + + run preview topic -n --delete-branch + [ "$status" -eq 0 ] + [[ "$output" == *"Commits not reachable from base: unknown (incomplete history)."* ]] + [[ "$output" != *"refs/remotes/origin/main: 1"* ]] +} + +@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" ] +} + +@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" == *"status.showUntrackedFiles=no can allow removal to delete untracked files even without --force"* ]] + [[ "$output" != *"Blocked without --force"* ]] + [ "$(cat "$TEST_WT_PATH/untracked")" = keep ] +} diff --git a/tests/completion.bats b/tests/completion.bats index fa15e68..9077d91 100644 --- a/tests/completion.bats +++ b/tests/completion.bats @@ -263,6 +263,61 @@ assert_literal_branch_completions() { [[ "$output" == *"interactive completion checks passed"* ]] } +@test "Bash rm completes branch names literally after a preview flag" { + setup_bash_completion_repo + run_bash_completion rm -n 'literal' + [ "$output" = "$DOLLAR_BRANCH"$'\n'"$BACKTICK_BRANCH" ] +} + +@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[*]}" = "1 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 ] +} + @test "bash Readline accepts cd targets without standalone completion loaded" { command -v python3 >/dev/null || skip "python3 is required for the Readline PTY test" setup_bash_completion_repo