diff --git a/README.md b/README.md index 9f71fcd..8abc385 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ npm install --global git-garbage

+A branch checked out in a linked worktree is removed together with its worktree. It is skipped when that worktree has uncommitted changes, is locked, or is the main checkout. + ## Related * [git-dirty](https://github.com/Kikobeats/git-dirty#git-dirty) – Ensures the working directory is clean and that there are no unpulled changes. diff --git a/garbage.sh b/garbage.sh index eee256d..ee62bad 100755 --- a/garbage.sh +++ b/garbage.sh @@ -10,10 +10,31 @@ if ! git fetch --prune --quiet 2> /dev/null; then exit 1; fi +git worktree prune + current="$(git rev-parse --abbrev-ref HEAD)" +worktree_list="$(git worktree list --porcelain)" +main_worktree="$(printf '%s\n' "$worktree_list" | awk 'NR == 1 { print substr($0, 10) }')" declare -a branches +# Print the path of the worktree that has the branch checked out, if any +worktree_of() { + printf '%s\n' "$worktree_list" | awk -v ref="refs/heads/$1" ' + /^worktree / { path = substr($0, 10) } + $0 == "branch " ref { print path; exit } + ' +} + +is_locked_worktree() { + printf '%s\n' "$worktree_list" | awk -v entry="worktree $1" ' + $0 == entry { inside = 1; next } + inside && /^locked/ { locked = 1; exit } + inside && NF == 0 { exit } + END { exit locked ? 0 : 1 } + ' +} + # Check if a branch was part of remote workflow (pushed at some point) was_pushed() { local branch="$1" @@ -83,19 +104,60 @@ if command -v gh &> /dev/null && gh auth status &> /dev/null; then fi fi -if [[ ${#branches[@]} -eq 0 ]]; then +# branches checked out in another worktree go with their worktree, unless it has work in it +declare -a removable worktrees skipped +for branch in "${branches[@]}"; do + worktree="$(worktree_of "$branch")" + if [[ -z "$worktree" ]]; then + removable+=("$branch") + worktrees+=("") + elif [[ "$worktree" == "$main_worktree" ]]; then + skipped+=("$branch (checked out in main worktree $worktree)") + elif is_locked_worktree "$worktree"; then + skipped+=("$branch (locked worktree $worktree)") + elif [[ -n "$(git -C "$worktree" status --porcelain 2> /dev/null)" ]]; then + skipped+=("$branch (uncommitted changes in worktree $worktree)") + else + removable+=("$branch") + worktrees+=("$worktree") + fi +done + +if [[ ${#skipped[@]} -gt 0 ]]; then + echo + printf ' skipped %s\n' "${skipped[@]}" +fi + +if [[ ${#removable[@]} -eq 0 ]]; then printf "\n Nothing to garbage." exit fi echo -printf ' %s\n' "${branches[@]}" +for i in "${!removable[@]}"; do + if [[ -n "${worktrees[$i]}" ]]; then + printf ' %s (and worktree %s)\n' "${removable[$i]}" "${worktrees[$i]}" + else + printf ' %s\n' "${removable[$i]}" + fi +done echo read -rp " Will be removed. Continue? (y/N) " -n 1 if [[ $REPLY =~ ^[Yy]$ ]]; then echo && echo - message=$(git branch -D "${branches[@]}") - echo "${message//Deleted/ Deleted}" + declare -a deletable + for i in "${!removable[@]}"; do + if [[ -z "${worktrees[$i]}" ]]; then + deletable+=("${removable[$i]}") + elif git worktree remove "${worktrees[$i]}"; then + echo " Removed worktree ${worktrees[$i]}" + deletable+=("${removable[$i]}") + fi + done + if [[ ${#deletable[@]} -gt 0 ]]; then + message=$(git branch -D "${deletable[@]}") + echo "${message//Deleted/ Deleted}" + fi fi diff --git a/package.json b/package.json index db853ad..352c58f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "release": "standard-version -a", "release:github": "github-generate-release", "release:tags": "git push --follow-tags origin HEAD:master", - "test": "exit 0" + "test": "bash test.sh" }, "license": "MIT", "commitlint": { diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..0c8ced2 --- /dev/null +++ b/test.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -uo pipefail + +GARBAGE="${GARBAGE:-$(cd "$(dirname "$0")" && pwd)/garbage.sh}" +SANDBOX="$(mktemp -d)" +trap 'rm -rf "$SANDBOX"' EXIT + +failures=0 + +assert() { + local message="$1" + shift + if "$@"; then + echo " ✔ $message" + else + echo " ✖ $message" >&2 + printf '%s\n' "${output:-}" >&2 + failures=$((failures + 1)) + fi +} + +refute() { + local message="$1" + shift + if "$@"; then + echo " ✖ $message" >&2 + printf '%s\n' "${output:-}" >&2 + failures=$((failures + 1)) + else + echo " ✔ $message" + fi +} + +contains() { + case "$1" in + *"$2"*) return 0 ;; + *) return 1 ;; + esac +} + +without_gh() { + mkdir -p "$SANDBOX/bin" + printf '#!/bin/sh\nexit 1\n' > "$SANDBOX/bin/gh" + chmod +x "$SANDBOX/bin/gh" + PATH="$SANDBOX/bin:$PATH" +} + +git_quiet() { + if ! git -c init.defaultBranch=master -c user.name=test -c user.email=test@test "$@" > /dev/null 2>&1; then + echo " fixture failed: git $*" >&2 + exit 1 + fi +} + +create_repo() { + local name="$1" + git_quiet init --bare "$SANDBOX/$name.git" + git_quiet clone "$SANDBOX/$name.git" "$SANDBOX/$name" + git_quiet -C "$SANDBOX/$name" commit --allow-empty -m init + git_quiet -C "$SANDBOX/$name" push -u origin master +} + +merge_pushed_branch() { + local repo="$1" branch="$2" + git_quiet -C "$repo" switch -c "$branch" + git_quiet -C "$repo" commit --allow-empty -m "$branch" + git_quiet -C "$repo" push -u origin "$branch" + git_quiet -C "$repo" switch master + git_quiet -C "$repo" merge --ff-only "$branch" + git_quiet -C "$repo" push origin master + git_quiet -C "$repo" push origin --delete "$branch" +} + +branch_exists() { + git -C "$1" show-ref --verify --quiet "refs/heads/$2" +} + +run_garbage() { + (cd "$1" && printf 'y' | bash "$GARBAGE" 2>&1) +} + +without_gh + +echo "merged branch checked out in a clean linked worktree" +create_repo clean +merge_pushed_branch "$SANDBOX/clean" feature +git_quiet -C "$SANDBOX/clean" worktree add "$SANDBOX/clean-wt" feature +output="$(run_garbage "$SANDBOX/clean")" +refute "branch deleted" branch_exists "$SANDBOX/clean" feature +refute "worktree removed" test -d "$SANDBOX/clean-wt" +refute "no git error" contains "$output" "cannot delete branch" + +echo "merged branch checked out in a dirty linked worktree" +create_repo dirty +merge_pushed_branch "$SANDBOX/dirty" feature +git_quiet -C "$SANDBOX/dirty" worktree add "$SANDBOX/dirty-wt" feature +touch "$SANDBOX/dirty-wt/wip.txt" +output="$(run_garbage "$SANDBOX/dirty")" +assert "branch kept" branch_exists "$SANDBOX/dirty" feature +assert "worktree kept" test -f "$SANDBOX/dirty-wt/wip.txt" +assert "skip reason printed" contains "$output" "uncommitted changes" + +echo "merged branch checked out in a locked worktree" +create_repo locked +merge_pushed_branch "$SANDBOX/locked" feature +git_quiet -C "$SANDBOX/locked" worktree add "$SANDBOX/locked-wt" feature +git_quiet -C "$SANDBOX/locked" worktree lock "$SANDBOX/locked-wt" +output="$(run_garbage "$SANDBOX/locked")" +assert "branch kept" branch_exists "$SANDBOX/locked" feature +assert "worktree kept" test -d "$SANDBOX/locked-wt" +assert "skip reason printed" contains "$output" "locked worktree" +refute "not announced for removal" contains "$output" "and worktree" + +echo "run from a linked worktree never touches the main checkout branch" +create_repo linked +merge_pushed_branch "$SANDBOX/linked" feature +git_quiet -C "$SANDBOX/linked" worktree add "$SANDBOX/linked-wt" feature +output="$(run_garbage "$SANDBOX/linked-wt")" +assert "main checkout branch kept" branch_exists "$SANDBOX/linked" master +assert "main checkout kept" test -d "$SANDBOX/linked" + +echo "merged branch without worktree" +create_repo plain +merge_pushed_branch "$SANDBOX/plain" feature +output="$(run_garbage "$SANDBOX/plain")" +refute "branch deleted" branch_exists "$SANDBOX/plain" feature + +echo "stale worktree whose directory is gone" +create_repo stale +merge_pushed_branch "$SANDBOX/stale" feature +git_quiet -C "$SANDBOX/stale" worktree add "$SANDBOX/stale-wt" feature +rm -rf "$SANDBOX/stale-wt" +output="$(run_garbage "$SANDBOX/stale")" +refute "branch deleted" branch_exists "$SANDBOX/stale" feature + +if [[ $failures -gt 0 ]]; then + echo && echo "$failures failure(s)" >&2 + exit 1 +fi