Skip to content
Open
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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <old> <new> [--force] [--yes]`

Expand Down
24 changes: 17 additions & 7 deletions completions/_git-gtr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]' \
Expand Down
1 change: 1 addition & 0 deletions completions/git-gtr.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 8 additions & 4 deletions completions/gtr.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
;;
Expand Down
9 changes: 9 additions & 0 deletions lib/commands/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -631,6 +639,7 @@ CORE COMMANDS (daily workflow):

rm <branch> [<branch>...] [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
Expand Down
110 changes: 107 additions & 3 deletions lib/commands/remove.sh
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
#!/usr/bin/env bash

# Remove command
# Remove explicit worktree targets, or preview them before hook dispatch.
# Usage: cmd_remove <target>... [--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 <id|branch> [<id|branch>...] [--delete-branch] [--force] [--yes]"
require_args 1 "Usage: git gtr rm <id|branch> [<id|branch>...] [--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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 <<EOF
$status_output
EOF
printf ' Dirty (tracked): %s; untracked: %s; locked: %s\n' "$dirty" "$untracked" "$locked"
if [ "$locked" = yes ]; then
printf ' Blocked: locked worktree; the existing single --force cannot remove it.\n'
elif [ "$dirty" = yes ] && [ "$force" -eq 0 ]; then
printf ' Blocked without --force: modified tracked files.\n'
fi
if [ "$untracked" = yes ] && [ "$force" -eq 0 ]; then
printf ' Warning: status.showUntrackedFiles=no can allow removal to delete untracked files even without --force.\n'
fi
printf ' Warning: --force can discard uncommitted changes and untracked files.\n'
printf ' Hooks: preRemove/postRemove not executed; trust state unchanged.\n'
if [ "$delete_branch" -eq 1 ]; then
if [ -n "$branch_name" ]; then
_remove_preview_branch "$repo_root" "$branch_name"
else
printf ' Branch deletion: none (detached HEAD).\n'
fi
fi
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="" branch_oid count
remote=$(resolve_default_remote)
default_branch=$(resolve_default_branch "$repo_root" "$remote")
# Follow new's default selection, but never treat its last-resort name as
# evidence that a ref exists. Fully qualified refs avoid tag ambiguity.
for candidate in "refs/remotes/$remote/$default_branch" "refs/heads/$default_branch"; do
if base_oid=$(git rev-parse --verify "$candidate^{commit}" 2>/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"
}
39 changes: 28 additions & 11 deletions scripts/generate-completions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
;;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]' \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
Loading