Skip to content
179 changes: 179 additions & 0 deletions docs/worktree-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Worktree Lifecycle Management

Superpowers automatically manages the lifecycle of git worktrees, ensuring they're cleaned up when no longer needed.

## How It Works

### 1. Registration

When `using-git-worktrees` creates a worktree, it registers it in `~/.config/superpowers/worktree-registry.json`:

```json
{
"id": "wt_1234567890_feature_auth",
"path": "/tmp/worktrees/feature-auth",
"branch": "feature/auth-system",
"project_root": "/Users/jason/myapp",
"created_at": "2026-03-10T10:00:00Z",
"status": "active"
}
```

### 2. Status Tracking

Worktrees progress through these states:

- **active** - Currently in development
- **pr_opened** - Pull request has been opened
- **merged** - Branch has been merged to main
- **abandoned** - Branch was deleted without merging
- **cleanup_pending** - Scheduled for garbage collection

### 3. Automatic Detection

The garbage collection daemon (or manual detection) checks:

- Does the branch still exist?
- Is there an open PR for this branch?
- Has the PR been merged?

### 4. Safe Cleanup

Cleanup only happens when:

1. Status is `cleanup_pending`
2. Worktree has no uncommitted changes
3. Delay period has passed (default: 1 hour for merged, immediate for abandoned)

Comment on lines +46 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Documentation inconsistency: default cleanup delay.

Line 46 states "default: 1 hour for merged", but the configuration example on line 130 shows cleanup_delay_hours: 24. Please reconcile which is the actual default.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/worktree-lifecycle.md` around lines 46 - 47, The documentation currently
conflicts about the default cleanup delay: the step text "default: 1 hour for
merged" and the configuration example showing cleanup_delay_hours: 24; decide
which is correct and make them consistent by either updating the step text to
reflect the example (e.g., "default: 24 hours for merged") or changing the
example value to the real default (e.g., cleanup_delay_hours: 1); update both
the descriptive line ("Delay period has passed...") and the configuration block
containing cleanup_delay_hours so they match, and ensure the symbol
cleanup_delay_hours is used consistently throughout the document.

## Installation

### Enable GC Daemon (Recommended)

```bash
# macOS: Create a proper LaunchAgent plist
cat > ~/Library/LaunchAgents/com.superpowers.worktree-gc.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.superpowers.worktree-gc</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>$HOME/.config/superpowers/lib/worktree-gc-daemon</string>
</array>
Comment on lines +62 to +66
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

$HOME won't expand in LaunchAgent plist files.

Property list files are XML and do not perform shell variable expansion. The literal string $HOME/.config/superpowers/lib/worktree-gc-daemon will be passed to bash, but some environments may not expand it correctly depending on how launchd invokes the shell.

Consider using the full expanded path or a more reliable approach:

📝 Suggested fix using full path
     <key>ProgramArguments</key>
     <array>
         <string>/bin/bash</string>
         <string>-c</string>
-        <string>$HOME/.config/superpowers/lib/worktree-gc-daemon</string>
+        <string>"$HOME"/.config/superpowers/lib/worktree-gc-daemon</string>
     </array>

Alternatively, instruct users to replace $HOME with their actual home directory path, e.g., /Users/username/.config/superpowers/lib/worktree-gc-daemon.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>$HOME/.config/superpowers/lib/worktree-gc-daemon</string>
</array>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>"$HOME"/.config/superpowers/lib/worktree-gc-daemon</string>
</array>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/worktree-lifecycle.md` around lines 62 - 66, The LaunchAgent plist's
ProgramArguments array currently contains the literal string
"$HOME/.config/superpowers/lib/worktree-gc-daemon" which will not be reliably
expanded; update the ProgramArguments entry (the array containing "/bin/bash"
and "-c" and the "$HOME/..." string) to use an absolute, fully expanded home
path (e.g., "/Users/yourname/.config/superpowers/lib/worktree-gc-daemon") or
otherwise avoid shell variable expansion by invoking the daemon with its full
path so launchd receives a concrete path.

<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
</dict>
</plist>
EOF
launchctl load ~/Library/LaunchAgents/com.superpowers.worktree-gc.plist

# Linux: Append to existing crontab (preserves existing entries)
(crontab -l 2>/dev/null; echo "@reboot $HOME/.config/superpowers/lib/worktree-gc-daemon") | crontab -
```

### Log Rotation (Optional)

To prevent log file growth, add this to `/etc/logrotate.d/superpowers-worktree-gc`:

```
$HOME/.config/superpowers/worktree-gc.log {
weekly
rotate 4
compress
missingok
notifempty
}
```
Comment on lines +84 to +92
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Logrotate config has issues: missing language specifier and unexpanded $HOME.

  1. The fenced code block is missing a language specifier (flagged by markdownlint).
  2. Logrotate does not expand shell variables like $HOME. Users must substitute their actual home directory path.
📝 Suggested fix
-```
-$HOME/.config/superpowers/worktree-gc.log {
+```text
+# Replace /home/username with your actual home directory
+/home/username/.config/superpowers/worktree-gc.log {
     weekly
     rotate 4
     compress
     missingok
     notifempty
 }
 ```
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 84-84: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/worktree-lifecycle.md` around lines 84 - 92, The fenced code block for
the logrotate snippet is missing a language specifier and uses an unexpanded
shell variable; update the markdown block to include a language tag (e.g.,
```text) and replace the literal $HOME/.config/superpowers/worktree-gc.log with
an absolute path (e.g.,
/home/your-username/.config/superpowers/worktree-gc.log), and add a one-line
comment above the block like "# Replace /home/your-username with your actual
home directory" so readers know to substitute their real home path; ensure the
log filename worktree-gc.log and the logrotate directives (weekly, rotate 4,
compress, missingok, notifempty) remain unchanged.


### Install Git Hooks

For automatic cleanup after merge:

```bash
# In your repository
cp ~/.config/superpowers/lib/hooks/post-merge .git/hooks/
chmod +x .git/hooks/post-merge
```

## Manual Commands

```bash
# List worktrees for current project
~/.config/superpowers/lib/worktree-manager list

# Check for status changes
~/.config/superpowers/lib/worktree-manager detect

# Run garbage collection manually
~/.config/superpowers/lib/worktree-manager gc

# Force cleanup of specific worktree
~/.config/superpowers/lib/worktree-manager unregister <id>
```

## Configuration

Edit `~/.config/superpowers/worktree-registry.json`:

```json
{
"metadata": {
"gc_policy": {
"enabled": true,
"interval_hours": 1,
"cleanup_delay_hours": 24,
"max_age_days": 30
}
}
}
```

- **enabled**: Turn GC on/off
- **interval_hours**: How often to check for cleanup
- **cleanup_delay_hours**: Grace period after merge
- **max_age_days**: Auto-cleanup worktrees older than this

## Safety Guarantees

1. **Never deletes worktrees with uncommitted changes**
2. **Never deletes active worktrees**
3. **Always provides grace period for merged branches**
4. **Logs all cleanup actions** to `~/.config/superpowers/worktree-gc.log`

## Integration with Finishing Branch

When you use the `finishing-a-development-branch` skill:

1. If merging locally, post-merge hook triggers cleanup
2. If creating PR, worktree status updates to `pr_opened`
3. When PR is merged, GitHub webhook (future) or GC daemon detects and schedules cleanup

## Troubleshooting

### Worktree not cleaned up

```bash
# Check status
~/.config/superpowers/lib/worktree-manager find <branch>

# Manually mark for cleanup
~/.config/superpowers/lib/worktree-manager update <id> cleanup_pending

# Run GC
~/.config/superpowers/lib/worktree-manager gc
```

### Accidentally cleaned up

Check logs:
```bash
cat ~/.config/superpowers/worktree-gc.log
```

The registry maintains history of all worktrees created.
45 changes: 45 additions & 0 deletions lib/hooks/post-merge
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Git post-merge hook for automatic worktree cleanup
# Install: Copy to .git/hooks/post-merge and make executable

SUPERPOWERS_LIB="${HOME}/.config/superpowers/lib"
WORKTREE_MANAGER="${SUPERPOWERS_LIB}/worktree-manager"

# Only run if superpowers is installed
if [ ! -x "$WORKTREE_MANAGER" ]; then
exit 0
fi

# Get the branch that was merged (from reflog)
# Post-merge hook fires after merge, so we parse the reflog to find source branch
MERGED_BRANCH=$(git reflog -1 --format='%gs' | sed -n 's/.*merge \([^:]*\).*/\1/p')

# Fallback: if we can't determine merged branch, exit silently
if [ -z "$MERGED_BRANCH" ]; then
exit 0
fi

# Find matching worktrees
+# Get first matching worktree (there could be multiple across projects)
+WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null | jq -s '.[0]' || true)
+
+if [ -n "$WORKTREE_INFO" ]; then
exit 0
fi

# Find matching worktrees
+WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null | jq -s '.[0]' || true)
+
+if [ -n "$WORKTREE_INFO" ]; then
Comment on lines +22 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove the inverted early-exit block.

jq -s '.[0]' returns either an object or null, and both are non-empty strings, so Lines 26-28 exit before the update path ever runs. As written, this hook never marks a matching worktree as merged.

Suggested fix
-# Find matching worktrees
-# Get first matching worktree (there could be multiple across projects)
-WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null | jq -s '.[0]' || true)
-
-if [ -n "$WORKTREE_INFO" ]; then
-    exit 0
-fi
-
-# Find matching worktrees
-WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null | jq -s '.[0]' || true)
-
-if [ -n "$WORKTREE_INFO" ]; then
+# Find matching worktrees
+WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null | jq -s '.[0]' || true)
+
+if [ -n "$WORKTREE_INFO" ] && [ "$WORKTREE_INFO" != "null" ]; then
     WORKTREE_ID=$(echo "$WORKTREE_INFO" | jq -r '.id')
     WORKTREE_PATH=$(echo "$WORKTREE_INFO" | jq -r '.path')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/hooks/post-merge` around lines 22 - 33, The script mistakenly exits early
when a matching worktree is found: the block using WORKTREE_INFO (from
"$WORKTREE_MANAGER" find "$MERGED_BRANCH" | jq -s '.[0]') currently does "if [
-n "$WORKTREE_INFO" ]; then exit 0" which prevents the update path from running;
change that to exit only when no worktree was found (use if [ -z
"$WORKTREE_INFO" ]; then exit 0 fi) or remove the duplicate/inverted early-exit
block entirely so the merge-marking logic runs when WORKTREE_INFO is non-null;
look for the WORKTREE_INFO, WORKTREE_MANAGER and MERGED_BRANCH references in the
post-merge hook to apply the fix.

WORKTREE_ID=$(echo "$WORKTREE_INFO" | jq -r '.id')
WORKTREE_PATH=$(echo "$WORKTREE_INFO" | jq -r '.path')

echo "🎉 Branch '$MERGED_BRANCH' merged!"

# Update status to merged (daemon will handle cleanup)
"$WORKTREE_MANAGER" update "$WORKTREE_ID" "merged"

echo "✅ Worktree marked as merged. GC daemon will clean up: $WORKTREE_PATH"
fi
Comment on lines +22 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential issue with multiple worktrees for the same branch.

If multiple worktrees are registered for the same branch (e.g., from different projects), find_by_branch returns multiple JSON objects, and jq -r '.id' will concatenate all IDs into a single string. The subsequent update call will fail or update the wrong entry.

🛡️ Proposed fix to handle first match only
-WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null || true)
+# Get first matching worktree (there could be multiple across projects)
+WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null | jq -s '.[0]' || true)
 
 if [ -n "$WORKTREE_INFO" ]; then
+    if [ "$WORKTREE_INFO" = "null" ]; then
+        exit 0
+    fi
     WORKTREE_ID=$(echo "$WORKTREE_INFO" | jq -r '.id')

Alternatively, modify find_by_branch to also filter by project_root matching the current repository.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Find matching worktrees
WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null || true)
if [ -n "$WORKTREE_INFO" ]; then
WORKTREE_ID=$(echo "$WORKTREE_INFO" | jq -r '.id')
WORKTREE_PATH=$(echo "$WORKTREE_INFO" | jq -r '.path')
echo "🎉 Branch '$MERGED_BRANCH' merged!"
# Update status to merged (daemon will handle cleanup)
"$WORKTREE_MANAGER" update "$WORKTREE_ID" "merged"
echo "✅ Worktree marked as merged. GC daemon will clean up: $WORKTREE_PATH"
fi
# Find matching worktrees
# Get first matching worktree (there could be multiple across projects)
WORKTREE_INFO=$("$WORKTREE_MANAGER" find "$MERGED_BRANCH" 2>/dev/null | jq -s '.[0]' || true)
if [ -n "$WORKTREE_INFO" ]; then
if [ "$WORKTREE_INFO" = "null" ]; then
exit 0
fi
WORKTREE_ID=$(echo "$WORKTREE_INFO" | jq -r '.id')
WORKTREE_PATH=$(echo "$WORKTREE_INFO" | jq -r '.path')
echo "🎉 Branch '$MERGED_BRANCH' merged!"
# Update status to merged (daemon will handle cleanup)
"$WORKTREE_MANAGER" update "$WORKTREE_ID" "merged"
echo "✅ Worktree marked as merged. GC daemon will clean up: $WORKTREE_PATH"
fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/hooks/post-merge` around lines 22 - 35, The current logic calling
"$WORKTREE_MANAGER" find "$MERGED_BRANCH" can return multiple JSON objects and
jq -r '.id' will concatenate them; change the extraction to only take the first
match instead of all matches: after capturing WORKTREE_INFO, parse WORKTREE_ID
and WORKTREE_PATH with a jq slurp-first expression (e.g., using jq -s and
selecting index 0) so WORKTREE_ID and WORKTREE_PATH are single values before
calling "$WORKTREE_MANAGER" update "$WORKTREE_ID" "merged"; alternatively, if
you prefer stricter matching, update the find invocation to include the current
repository/project_root filter so the returned WORKTREE_INFO is unique.


exit 0
60 changes: 60 additions & 0 deletions lib/worktree-gc-daemon
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Worktree Garbage Collection Daemon
# Runs periodically to clean up abandoned worktrees

set -euo pipefail

SUPERPOWERS_LIB="${HOME}/.config/superpowers/lib"
WORKTREE_MANAGER="${SUPERPOWERS_LIB}/worktree-manager"
DAEMON_PID_FILE="/tmp/superpowers-worktree-gc.pid"
DAEMON_LOCK_FILE="/tmp/superpowers-worktree-gc.lock"
LOG_FILE="${HOME}/.config/superpowers/worktree-gc.log"

# Ensure log directory exists
mkdir -p "$(dirname "$LOG_FILE")"

# Handle shutdown signals
RUNNING=1
shutdown() {
log "Daemon shutting down"
RUNNING=0
}
trap 'shutdown' SIGTERM SIGINT

# Atomic lock acquisition using flock
exec 200>"$DAEMON_LOCK_FILE"
if ! flock -n 200; then
echo "Daemon already running (lock held)" >&2
exit 0
fi

# Cleanup on exit
trap 'flock -u 200; rm -f "$DAEMON_PID_FILE"' EXIT

# Write PID while holding lock
echo $$ > "$DAEMON_PID_FILE"

log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}

log "Worktree GC daemon started (PID: $$)"

# Main loop
while [ "$RUNNING" = "1" ]; do
# Detect status changes
if [ -x "$WORKTREE_MANAGER" ]; then
"$WORKTREE_MANAGER" detect >> "$LOG_FILE" 2>&1 || true

# Run garbage collection
"$WORKTREE_MANAGER" gc >> "$LOG_FILE" 2>&1 || true
fi

# Sleep for 1 hour (in short intervals to allow graceful shutdown)
for i in $(seq 1 360); do
if [ "$RUNNING" = "0" ]; then
break
fi
sleep 10
done
done
Loading