|
#!/bin/bash |
|
# worktree-clean - Safely remove git worktrees |
|
# |
|
# Usage: |
|
# worktree-clean ~/work/src/myapp-feature |
|
# worktree-clean myapp-feature # Assumes ~/work/src/ prefix |
|
# worktree-clean --merged # Clean all merged worktrees |
|
# |
|
# Safety checks: |
|
# - Uncommitted changes |
|
# - Unpushed commits |
|
# - Unmerged branch (warning, not blocking) |
|
|
|
set -e |
|
|
|
WORKTREE_BASE="$HOME/work/src" |
|
|
|
usage() { |
|
cat <<'EOF' |
|
Usage: worktree-clean [OPTIONS] PATH|NAME |
|
worktree-clean --merged [-n] |
|
|
|
Safely remove a git worktree created by tmx-worktree. |
|
|
|
Arguments: |
|
PATH Full path to worktree (e.g., ~/work/src/myapp-feature) |
|
NAME Short name, assumes ~/work/src/ prefix (e.g., myapp-feature) |
|
|
|
Options: |
|
-f, --force Skip safety checks (uncommitted changes, unpushed commits) |
|
-d, --delete Also delete the branch after removing worktree |
|
-n, --dry-run Show what would be done without doing it |
|
--merged Clean ALL worktrees whose branches are merged to main/master |
|
-h, --help Show this help |
|
|
|
Safety Checks: |
|
- Uncommitted changes (blocks unless --force) |
|
- Unpushed commits (blocks unless --force) |
|
- Unmerged branch (warning only, does not block) |
|
|
|
Examples: |
|
worktree-clean myapp-feature # Remove specific worktree |
|
worktree-clean -d myapp-feature # Remove worktree and delete branch |
|
worktree-clean --merged # Clean all merged worktrees |
|
worktree-clean --merged -n # Preview merged cleanup |
|
EOF |
|
exit "${1:-0}" |
|
} |
|
|
|
die() { |
|
echo "Error: $1" >&2 |
|
exit 1 |
|
} |
|
|
|
warn() { |
|
echo "Warning: $1" >&2 |
|
} |
|
|
|
info() { |
|
echo "$1" |
|
} |
|
|
|
# Check dependencies |
|
command -v git &>/dev/null || die "git is not installed" |
|
|
|
# Defaults |
|
FORCE=false |
|
DELETE_BRANCH=false |
|
DRY_RUN=false |
|
CLEAN_MERGED=false |
|
WORKTREE_PATH="" |
|
|
|
# Parse arguments |
|
while [[ $# -gt 0 ]]; do |
|
case $1 in |
|
-f|--force) FORCE=true; shift ;; |
|
-d|--delete) DELETE_BRANCH=true; shift ;; |
|
-n|--dry-run) DRY_RUN=true; shift ;; |
|
--merged) CLEAN_MERGED=true; shift ;; |
|
-h|--help) usage 0 ;; |
|
-*) die "Unknown option: $1" ;; |
|
*) |
|
if [ -z "$WORKTREE_PATH" ]; then |
|
WORKTREE_PATH="$1" |
|
else |
|
die "Unexpected argument: $1" |
|
fi |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
# Get the main repo for a worktree |
|
get_main_repo() { |
|
local worktree="$1" |
|
git -C "$worktree" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's/\.git$//' |
|
} |
|
|
|
# Get branch name for a worktree |
|
get_worktree_branch() { |
|
local worktree="$1" |
|
git -C "$worktree" rev-parse --abbrev-ref HEAD 2>/dev/null |
|
} |
|
|
|
# Detect main branch for a repo |
|
get_main_branch() { |
|
local repo="$1" |
|
if git -C "$repo" show-ref --verify --quiet refs/heads/main; then |
|
echo "main" |
|
elif git -C "$repo" show-ref --verify --quiet refs/heads/master; then |
|
echo "master" |
|
else |
|
echo "" |
|
fi |
|
} |
|
|
|
# Check if branch is merged into main |
|
is_branch_merged() { |
|
local repo="$1" |
|
local branch="$2" |
|
local main_branch="$3" |
|
|
|
# Check if branch is ancestor of main (i.e., merged) |
|
git -C "$repo" merge-base --is-ancestor "$branch" "$main_branch" 2>/dev/null |
|
} |
|
|
|
# Check for uncommitted changes |
|
has_uncommitted_changes() { |
|
local worktree="$1" |
|
! git -C "$worktree" diff --quiet 2>/dev/null || \ |
|
! git -C "$worktree" diff --cached --quiet 2>/dev/null |
|
} |
|
|
|
# Check for untracked files |
|
has_untracked_files() { |
|
local worktree="$1" |
|
[ -n "$(git -C "$worktree" ls-files --others --exclude-standard 2>/dev/null)" ] |
|
} |
|
|
|
# Check for unpushed commits |
|
has_unpushed_commits() { |
|
local worktree="$1" |
|
local branch |
|
branch=$(get_worktree_branch "$worktree") |
|
|
|
# Check if upstream exists |
|
if git -C "$worktree" rev-parse --verify "@{upstream}" &>/dev/null; then |
|
# Has upstream, check for unpushed |
|
[ -n "$(git -C "$worktree" log '@{upstream}..HEAD' --oneline 2>/dev/null)" ] |
|
else |
|
# No upstream - if there are commits not in origin/main, consider unpushed |
|
local main_branch |
|
main_branch=$(get_main_branch "$worktree") |
|
if [ -n "$main_branch" ]; then |
|
local origin_main="origin/$main_branch" |
|
if git -C "$worktree" show-ref --verify --quiet "refs/remotes/$origin_main"; then |
|
[ -n "$(git -C "$worktree" log "$origin_main..HEAD" --oneline 2>/dev/null)" ] |
|
else |
|
# No remote, can't check |
|
return 1 |
|
fi |
|
else |
|
return 1 |
|
fi |
|
fi |
|
} |
|
|
|
# Clean a single worktree |
|
clean_worktree() { |
|
local worktree="$1" |
|
local force="$2" |
|
local delete_branch="$3" |
|
local dry_run="$4" |
|
|
|
# Resolve path |
|
if [[ "$worktree" != /* ]]; then |
|
worktree="$WORKTREE_BASE/$worktree" |
|
fi |
|
|
|
# Check if path exists |
|
if [ ! -d "$worktree" ]; then |
|
die "Worktree does not exist: $worktree" |
|
fi |
|
|
|
# Check if it's a git worktree |
|
if ! git -C "$worktree" rev-parse --is-inside-work-tree &>/dev/null; then |
|
die "Not a git repository: $worktree" |
|
fi |
|
|
|
local main_repo |
|
main_repo=$(get_main_repo "$worktree") |
|
|
|
# Check if it's actually a worktree (not the main repo) |
|
local git_dir |
|
git_dir=$(git -C "$worktree" rev-parse --git-dir) |
|
if [[ "$git_dir" == ".git" ]]; then |
|
die "This appears to be a main repository, not a worktree: $worktree" |
|
fi |
|
|
|
local branch |
|
branch=$(get_worktree_branch "$worktree") |
|
|
|
local main_branch |
|
main_branch=$(get_main_branch "$main_repo") |
|
|
|
info "Worktree: $worktree" |
|
info "Branch: $branch" |
|
info "Main repo: $main_repo" |
|
echo "" |
|
|
|
# Safety checks |
|
local issues=0 |
|
|
|
if has_uncommitted_changes "$worktree"; then |
|
if $force; then |
|
warn "Uncommitted changes (--force specified, continuing)" |
|
else |
|
echo "✗ Uncommitted changes detected" |
|
issues=$((issues + 1)) |
|
fi |
|
else |
|
echo "✓ No uncommitted changes" |
|
fi |
|
|
|
if has_untracked_files "$worktree"; then |
|
if $force; then |
|
warn "Untracked files (--force specified, continuing)" |
|
else |
|
echo "✗ Untracked files detected" |
|
issues=$((issues + 1)) |
|
fi |
|
else |
|
echo "✓ No untracked files" |
|
fi |
|
|
|
if has_unpushed_commits "$worktree"; then |
|
if $force; then |
|
warn "Unpushed commits (--force specified, continuing)" |
|
else |
|
echo "✗ Unpushed commits detected" |
|
issues=$((issues + 1)) |
|
fi |
|
else |
|
echo "✓ No unpushed commits" |
|
fi |
|
|
|
# Check merge status (warning only) |
|
if [ -n "$main_branch" ] && [ "$branch" != "$main_branch" ]; then |
|
if is_branch_merged "$main_repo" "$branch" "$main_branch"; then |
|
echo "✓ Branch is merged into $main_branch" |
|
else |
|
warn "Branch is NOT merged into $main_branch" |
|
fi |
|
fi |
|
|
|
echo "" |
|
|
|
# Abort if issues found and not forcing |
|
if [ $issues -gt 0 ] && ! $force; then |
|
die "Safety checks failed. Use --force to override." |
|
fi |
|
|
|
# Perform cleanup |
|
if $dry_run; then |
|
echo "[DRY RUN] Would remove worktree: $worktree" |
|
if $delete_branch; then |
|
echo "[DRY RUN] Would delete branch: $branch" |
|
fi |
|
else |
|
info "Removing worktree..." |
|
git -C "$main_repo" worktree remove "$worktree" |
|
echo "✓ Worktree removed" |
|
|
|
if $delete_branch; then |
|
info "Deleting branch..." |
|
git -C "$main_repo" branch -D "$branch" |
|
echo "✓ Branch '$branch' deleted" |
|
fi |
|
fi |
|
|
|
echo "" |
|
echo "Done!" |
|
} |
|
|
|
# Clean all merged worktrees |
|
clean_merged_worktrees() { |
|
local dry_run="$1" |
|
|
|
info "Scanning for merged worktrees in $WORKTREE_BASE..." |
|
echo "" |
|
|
|
local cleaned=0 |
|
local skipped=0 |
|
|
|
# Find all directories that look like worktrees (contain .git file) |
|
for dir in "$WORKTREE_BASE"/*; do |
|
[ -d "$dir" ] || continue |
|
|
|
# Check if it's a git worktree (has .git file, not directory) |
|
if [ -f "$dir/.git" ]; then |
|
local branch |
|
branch=$(get_worktree_branch "$dir") |
|
|
|
local main_repo |
|
main_repo=$(get_main_repo "$dir") |
|
|
|
local main_branch |
|
main_branch=$(get_main_branch "$main_repo") |
|
|
|
if [ -z "$main_branch" ]; then |
|
warn "Skipping $dir: cannot determine main branch" |
|
skipped=$((skipped + 1)) |
|
continue |
|
fi |
|
|
|
if [ "$branch" = "$main_branch" ]; then |
|
continue # Skip main branch worktrees |
|
fi |
|
|
|
if is_branch_merged "$main_repo" "$branch" "$main_branch"; then |
|
# Check for uncommitted/unpushed |
|
if has_uncommitted_changes "$dir" || has_untracked_files "$dir"; then |
|
warn "Skipping $dir: has uncommitted changes" |
|
skipped=$((skipped + 1)) |
|
continue |
|
fi |
|
|
|
if $dry_run; then |
|
echo "[DRY RUN] Would clean: $dir (branch: $branch)" |
|
else |
|
echo "Cleaning: $dir (branch: $branch)" |
|
git -C "$main_repo" worktree remove "$dir" |
|
git -C "$main_repo" branch -d "$branch" 2>/dev/null || true |
|
fi |
|
cleaned=$((cleaned + 1)) |
|
fi |
|
fi |
|
done |
|
|
|
echo "" |
|
if $dry_run; then |
|
echo "Would clean $cleaned worktree(s), skipped $skipped" |
|
else |
|
echo "Cleaned $cleaned worktree(s), skipped $skipped" |
|
fi |
|
} |
|
|
|
# Main logic |
|
if $CLEAN_MERGED; then |
|
clean_merged_worktrees "$DRY_RUN" |
|
else |
|
[ -z "$WORKTREE_PATH" ] && die "Worktree path is required (or use --merged)" |
|
clean_worktree "$WORKTREE_PATH" "$FORCE" "$DELETE_BRANCH" "$DRY_RUN" |
|
fi |