Claude Cursor Skill

cleanup-git

Imported from alexei-led/cc-thingz/dist/claude/git-flow/skills/cleanup-git.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download alexei-led-cc-thingz-dist_claude_git-flow_skills_cleanup-git-ce56bb4.zip · 4 KB
Part of alexei-led/cc-thingz — 91 skills

Install

skills CLI npx skills add https://github.com/alexei-led/cc-thingz/tree/master/dist/claude/git-flow/skills/cleanup-git
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alexei-led-cc-thingz@llmmart
Git git clone https://github.com/alexei-led/cc-thingz.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alexei-led/cc-thingz collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Cleanup Git

All deletion goes through the cleanup script: the target repo's own scripts/cleanup-git.sh if it ships one, otherwise this skill's scripts/cleanup-git.sh. Do not hand-write branch or worktree deletion commands.

scripts/cleanup-git.sh                  # preview (default)
scripts/cleanup-git.sh --apply          # delete after user approval
scripts/cleanup-git.sh --apply --force  # also delete branches with ahead commits
scripts/cleanup-git.sh --base <ref>     # override base detection

The script fetches and prunes remotes, then picks the base from the remote default branch, then local or remote main, master, trunk, develop, or dev.

What the script decides

A branch or its worktree is a candidate when one of these holds:

  • PR merged: gh pr view <branch> reports MERGED. This catches squash and rebase merges; ahead commits count only past the PR head commit.
  • upstream gone: the tracking branch was deleted.
  • merged: the branch is an ancestor of the base.

It always skips the current worktree, the current branch, the base, and main/master/trunk/develop/dev. It keeps dirty worktrees. It keeps candidates with ahead commits, or an unknown ahead count, unless --force is passed. A branch with none of these signals prints skip ... (active); without gh, squash-merged branches land there.

Workflow

  1. Run the preview and show it. Read each line literally: remove <worktree> and delete <branch> are candidates; KEEP ... (dirty), KEEP ... (N ahead — use --force), and KEEP ... (ahead unknown — run git fetch ...) need the user; skip lines for the current, base, or protected branch are guarded; mention skip ... (active) lines when gh is unavailable.
  2. Present every KEEP line as a decision for the user.
  3. Ask before --apply. Add --force only when the user confirms the ahead commits are throwaway.

Stop if the directory is not a git repo. If no base is found, ask for --base <ref>. A fetch failure makes the preview use stale refs (say so) and makes --apply refuse.

Output

GIT CLEANUP
Status: PREVIEW | APPLIED | BLOCKED
Base: <ref>
Remove: <branch/worktree> — <reason>
Keep: <branch/worktree> — <reason and decision needed>
Verification: <command> — pass/fail/not run
Files (cc-thingz)
  • scripts
    • cleanup-git.sh 8.5 KB
      #!/usr/bin/env bash
      # Remove merged or gone local branches and their clean worktrees.
      # Dry-run by default. Pass --apply to execute, --force to drop ahead branches.
      
      set -euo pipefail
      
      APPLY=false
      FORCE=false
      BASE_OVERRIDE=""
      PROTECTED_BRANCHES=(main master trunk develop dev)
      GH_AVAILABLE=0
      CLEANUP_REASON=""
      CLEANUP_AHEAD=0
      
      usage() {
      	cat <<'EOF'
      Usage: cleanup-git.sh [--apply] [--force] [--base <ref>]
      
      Dry-run by default. Removes clean worktrees and local branches whose branch is
      confirmed MERGED by GitHub, merged into the detected base branch, or whose
      upstream is gone.
      EOF
      }
      
      while [ "$#" -gt 0 ]; do
      	case "$1" in
      	--apply)
      		APPLY=true
      		shift
      		;;
      	--force)
      		FORCE=true
      		shift
      		;;
      	--base)
      		shift
      		[ "$#" -gt 0 ] || {
      			echo "Error: --base requires a ref" >&2
      			exit 2
      		}
      		BASE_OVERRIDE=$1
      		shift
      		;;
      	--base=*)
      		BASE_OVERRIDE=${1#--base=}
      		shift
      		;;
      	-h | --help)
      		usage
      		exit 0
      		;;
      	*)
      		echo "Error: unknown argument: $1" >&2
      		usage >&2
      		exit 2
      		;;
      	esac
      done
      
      cd "$(git rev-parse --show-toplevel)"
      if ! git fetch --all --prune --quiet; then
      	if $APPLY; then
      		echo "Error: git fetch --all --prune failed; refusing apply with stale refs" >&2
      		exit 1
      	fi
      	echo "warning: git fetch --all --prune failed; dry-run uses local refs" >&2
      fi
      
      if command -v gh >/dev/null 2>&1; then
      	GH_AVAILABLE=1
      fi
      
      has_ref() {
      	git rev-parse --verify --quiet "$1" >/dev/null
      }
      
      base_name_for_ref() {
      	local ref=$1 remote stripped
      	case "$ref" in
      	refs/heads/*)
      		printf '%s\n' "${ref#refs/heads/}"
      		return 0
      		;;
      	refs/remotes/*)
      		stripped=${ref#refs/remotes/}
      		for remote in $(git remote); do
      			case "$stripped" in
      			"$remote"/*)
      				printf '%s\n' "${stripped#"$remote/"}"
      				return 0
      				;;
      			esac
      		done
      		;;
      	esac
      	for remote in $(git remote); do
      		case "$ref" in
      		"$remote"/*)
      			printf '%s\n' "${ref#"$remote/"}"
      			return 0
      			;;
      		esac
      	done
      	printf '%s\n' "$ref"
      }
      
      detect_base() {
      	local remote head branch candidate
      
      	for remote in $(git remote); do
      		head=$(git symbolic-ref --quiet --short "refs/remotes/$remote/HEAD" 2>/dev/null || true)
      		[ -n "$head" ] || continue
      		branch=${head#"$remote/"}
      		if has_ref "refs/heads/$branch"; then
      			printf '%s\t%s\n' "$branch" "$branch"
      		else
      			printf '%s\t%s\n' "$head" "$branch"
      		fi
      		return 0
      	done
      
      	for candidate in "${PROTECTED_BRANCHES[@]}"; do
      		if has_ref "refs/heads/$candidate"; then
      			printf '%s\t%s\n' "$candidate" "$candidate"
      			return 0
      		fi
      	done
      
      	for remote in $(git remote); do
      		for candidate in "${PROTECTED_BRANCHES[@]}"; do
      			if has_ref "refs/remotes/$remote/$candidate"; then
      				printf '%s\t%s\n' "$remote/$candidate" "$candidate"
      				return 0
      			fi
      		done
      	done
      
      	return 1
      }
      
      if [ -n "$BASE_OVERRIDE" ]; then
      	BASE_REF=$BASE_OVERRIDE
      	BASE_NAME=$(base_name_for_ref "$BASE_REF")
      else
      	IFS=$'\t' read -r BASE_REF BASE_NAME < <(detect_base) || {
      		echo "Error: no base branch found. Pass --base <ref>." >&2
      		exit 1
      	}
      fi
      
      has_ref "$BASE_REF^{commit}" || {
      	echo "Error: base ref not found: $BASE_REF" >&2
      	exit 1
      }
      
      CUR_BRANCH=$(git symbolic-ref --quiet --short HEAD || true)
      CUR_WORKTREE=$(git rev-parse --show-toplevel)
      
      run() {
      	if $APPLY; then
      		printf '+'
      		printf ' %q' "$@"
      		printf '\n'
      		"$@"
      	else
      		printf 'would:'
      		printf ' %q' "$@"
      		printf '\n'
      	fi
      }
      
      is_current_branch() {
      	local branch=$1
      	[ -n "$CUR_BRANCH" ] && [ "$branch" = "$CUR_BRANCH" ]
      }
      
      is_protected_branch() {
      	local branch=$1 protected
      	[ "$branch" = "$BASE_NAME" ] && return 0
      	for protected in "${PROTECTED_BRANCHES[@]}"; do
      		[ "$branch" = "$protected" ] && return 0
      	done
      	return 1
      }
      
      pr_lookup() {
      	local branch=$1
      	[ "$GH_AVAILABLE" -eq 1 ] || return 1
      	gh pr view "$branch" --json state,headRefOid --template '{{printf "%s\t%s" .state .headRefOid}}' 2>/dev/null
      }
      
      merged_pr_ahead_count() {
      	local branch=$1 pr_head=$2
      	if [ -n "$pr_head" ] && has_ref "$pr_head^{commit}"; then
      		git rev-list --count "$pr_head..$branch" 2>/dev/null || echo unknown
      		return 0
      	fi
      	echo unknown
      }
      
      ahead_count() {
      	git rev-list --count "$BASE_REF..$1" 2>/dev/null || echo unknown
      }
      
      # Fails closed: bash's `[ "unknown" -gt 0 ]` is false with only a stderr
      # warning, so every ahead-count consumer must route through this helper
      # instead of a bare numeric test.
      is_ahead_or_unknown() {
      	[ "$1" = unknown ] || [ "$1" -gt 0 ]
      }
      
      cleanup_reason_for() {
      	local branch=$1 track pr_info pr_state pr_head
      	CLEANUP_REASON=""
      	CLEANUP_AHEAD=0
      
      	pr_info=$(pr_lookup "$branch" || true)
      	if [ -n "$pr_info" ]; then
      		IFS=$'\t' read -r pr_state pr_head <<<"$pr_info"
      		if [ "$pr_state" = "MERGED" ]; then
      			CLEANUP_REASON="PR merged"
      			CLEANUP_AHEAD=$(merged_pr_ahead_count "$branch" "$pr_head")
      			return 0
      		fi
      	fi
      
      	track=$(git for-each-ref --format='%(upstream:track)' "refs/heads/$branch")
      	if [[ "$track" == *gone* ]]; then
      		CLEANUP_REASON="upstream gone"
      		CLEANUP_AHEAD=$(ahead_count "$branch")
      		return 0
      	fi
      	if git merge-base --is-ancestor "$branch" "$BASE_REF" 2>/dev/null; then
      		CLEANUP_REASON="merged"
      		CLEANUP_AHEAD=$(ahead_count "$branch")
      		return 0
      	fi
      	return 1
      }
      
      list_worktrees() {
      	git worktree list --porcelain | awk '
      		/^worktree / { path=substr($0, 10) }
      		/^branch / { branch=$2; sub(/^refs\/heads\//, "", branch); print path "\t" branch }
      		/^detached/ { print path "\tDETACHED" }
      		/^bare/ { print path "\tBARE" }
      	'
      }
      
      branch_in_worktree() {
      	local branch=$1
      	grep -Fxq "$branch" <<<"$WORKTREE_BRANCHES"
      }
      
      delete_branch() {
      	local branch=$1 ahead=$2
      	if is_ahead_or_unknown "$ahead"; then
      		run git branch -D "$branch" || echo "  warning: failed to delete branch $branch" >&2
      		return
      	fi
      	if ! $APPLY; then
      		run git branch -d "$branch"
      		return
      	fi
      	printf '+ git branch -d %q\n' "$branch"
      	git branch -d "$branch" 2>/dev/null || {
      		echo "  -d refused after cleanup proof ($CLEANUP_REASON, $ahead ahead); deleting with -D."
      		run git branch -D "$branch" || echo "  warning: failed to delete branch $branch" >&2
      	}
      }
      
      echo "base branch: $BASE_REF"
      echo "== worktrees =="
      list_worktrees | while IFS=$'\t' read -r path branch; do
      	[ -n "$path" ] || continue
      	case "$branch" in
      	DETACHED | BARE)
      		echo "  skip $path ($branch)"
      		continue
      		;;
      	esac
      	if [ "$path" = "$CUR_WORKTREE" ]; then
      		echo "  skip $path ($branch, current worktree)"
      		continue
      	fi
      	if is_current_branch "$branch"; then
      		echo "  skip $path ($branch, current branch)"
      		continue
      	fi
      	if is_protected_branch "$branch"; then
      		echo "  skip $path ($branch, protected)"
      		continue
      	fi
      	if [ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]; then
      		echo "  KEEP $path ($branch, dirty)"
      		continue
      	fi
      	cleanup_reason_for "$branch" || true
      	if [ -z "$CLEANUP_REASON" ]; then
      		echo "  skip $path ($branch, active)"
      		continue
      	fi
      	if is_ahead_or_unknown "$CLEANUP_AHEAD" && ! $FORCE; then
      		if [ "$CLEANUP_AHEAD" = unknown ]; then
      			echo "  KEEP $path ($branch, $CLEANUP_REASON, ahead unknown — run git fetch to make the PR head reachable, or use --force)"
      		else
      			echo "  KEEP $path ($branch, $CLEANUP_REASON, $CLEANUP_AHEAD ahead — use --force)"
      		fi
      		continue
      	fi
      	ahead_suffix=""
      	if [ "$CLEANUP_AHEAD" = unknown ]; then
      		ahead_suffix=", unknown ahead"
      	elif [ "$CLEANUP_AHEAD" -gt 0 ]; then
      		ahead_suffix=", $CLEANUP_AHEAD ahead"
      	fi
      	echo "  remove $path ($branch, $CLEANUP_REASON$ahead_suffix)"
      	run git worktree remove "$path" || {
      		echo "  warning: failed to remove worktree $path ($branch); leaving in place" >&2
      		continue
      	}
      done
      
      echo "== branches =="
      WORKTREE_BRANCHES=$(list_worktrees | awk -F '\t' '$2 != "DETACHED" && $2 != "BARE" { print $2 }')
      git for-each-ref --format='%(refname:short)' refs/heads/ | while read -r branch; do
      	if is_current_branch "$branch"; then
      		echo "  skip $branch (current branch)"
      		continue
      	fi
      	if is_protected_branch "$branch"; then
      		echo "  skip $branch (protected)"
      		continue
      	fi
      	if branch_in_worktree "$branch"; then
      		echo "  skip $branch (in worktree)"
      		continue
      	fi
      	cleanup_reason_for "$branch" || true
      	if [ -z "$CLEANUP_REASON" ]; then
      		echo "  skip $branch (active)"
      		continue
      	fi
      	if is_ahead_or_unknown "$CLEANUP_AHEAD" && ! $FORCE; then
      		if [ "$CLEANUP_AHEAD" = unknown ]; then
      			echo "  KEEP $branch ($CLEANUP_REASON, ahead unknown — run git fetch to make the PR head reachable, or use --force)"
      		else
      			echo "  KEEP $branch ($CLEANUP_REASON, $CLEANUP_AHEAD ahead — use --force)"
      		fi
      		continue
      	fi
      	ahead_suffix=""
      	if [ "$CLEANUP_AHEAD" = unknown ]; then
      		ahead_suffix=", unknown ahead"
      	elif [ "$CLEANUP_AHEAD" -gt 0 ]; then
      		ahead_suffix=", $CLEANUP_AHEAD ahead"
      	fi
      	echo "  delete $branch ($CLEANUP_REASON$ahead_suffix)"
      	delete_branch "$branch" "$CLEANUP_AHEAD"
      done
      
      $APPLY || echo "(dry-run — pass --apply)"
      
  • SKILL.md 2.7 KB
    ---
    {"allowed-tools":["Bash(scripts/cleanup-git.sh *)"],"context":"fork","description":"Remove merged local branches and stale git worktrees. Use when the user says \"cleanup branches\", \"prune worktrees\", \"tidy git\", \"remove merged branches\", \"delete merged branches\", \"gone branches\", or wants to clean local git state. NOT for creating commits, creating worktrees, or configuring git hooks.","name":"cleanup-git","user-invocable":true}
    ---
    
    # Cleanup Git
    
    All deletion goes through the cleanup script: the target repo's own `scripts/cleanup-git.sh` if it ships one, otherwise this skill's `scripts/cleanup-git.sh`. Do not hand-write branch or worktree deletion commands.
    
    ```bash
    scripts/cleanup-git.sh                  # preview (default)
    scripts/cleanup-git.sh --apply          # delete after user approval
    scripts/cleanup-git.sh --apply --force  # also delete branches with ahead commits
    scripts/cleanup-git.sh --base <ref>     # override base detection
    ```
    
    The script fetches and prunes remotes, then picks the base from the remote default branch, then local or remote `main`, `master`, `trunk`, `develop`, or `dev`.
    
    ## What the script decides
    
    A branch or its worktree is a candidate when one of these holds:
    
    - `PR merged`: `gh pr view <branch>` reports `MERGED`. This catches squash and rebase merges; ahead commits count only past the PR head commit.
    - `upstream gone`: the tracking branch was deleted.
    - `merged`: the branch is an ancestor of the base.
    
    It always skips the current worktree, the current branch, the base, and `main`/`master`/`trunk`/`develop`/`dev`. It keeps dirty worktrees. It keeps candidates with ahead commits, or an unknown ahead count, unless `--force` is passed. A branch with none of these signals prints `skip ... (active)`; without `gh`, squash-merged branches land there.
    
    ## Workflow
    
    1. Run the preview and show it. Read each line literally: `remove <worktree>` and `delete <branch>` are candidates; `KEEP ... (dirty)`, `KEEP ... (N ahead — use --force)`, and `KEEP ... (ahead unknown — run git fetch ...)` need the user; `skip` lines for the current, base, or protected branch are guarded; mention `skip ... (active)` lines when `gh` is unavailable.
    2. Present every `KEEP` line as a decision for the user.
    3. Ask before `--apply`. Add `--force` only when the user confirms the ahead commits are throwaway.
    
    Stop if the directory is not a git repo. If no base is found, ask for `--base <ref>`. A fetch failure makes the preview use stale refs (say so) and makes `--apply` refuse.
    
    ## Output
    
    ```text
    GIT CLEANUP
    Status: PREVIEW | APPLIED | BLOCKED
    Base: <ref>
    Remove: <branch/worktree> — <reason>
    Keep: <branch/worktree> — <reason and decision needed>
    Verification: <command> — pass/fail/not run
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related