Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ flowchart LR

Commit, push, and merge the planning PR as `finish-planning.sh` shows, then
remove the planning worktree with
`./scripts/cleanup-worktree.sh ../<repository>-planning-project-bootstrap`.
`./scripts/cleanup-worktree.sh planning/project-bootstrap`.
4. For each roadmap feature, from the primary checkout and then the feature
worktree:

Expand All @@ -54,6 +54,7 @@ flowchart LR

Push, open a PR containing `Closes #12`, merge it after CI, and remove the
worktree from the primary checkout with
`./scripts/cleanup-worktree.sh ../<repository>-12-recipes`.
`./scripts/cleanup-worktree.sh 12`, or all merged worktrees at once with
`./scripts/cleanup-worktree.sh --merged`.

Rules for agents are in `AGENTS.md`; boundaries are in `.agents/policies/`.
16 changes: 13 additions & 3 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,11 +522,21 @@ git push -u origin feature/12-player-movement

Open a PR containing `Closes #12`. After CI and required human gates pass,
merge it; GitHub then closes the linked Issue. From the primary checkout,
remove the merged worktree:
remove the merged worktree and its branch:

```bash
./scripts/cleanup-worktree.sh ../project-12-player-movement
```
./scripts/cleanup-worktree.sh 12
```

The script asks GitHub whether the branch's pull request is merged, so it also
works after a squash merge. It refuses an unmerged worktree, a branch with
commits after its merged pull request, and a worktree with uncommitted changes;
ignored review and triage files do not count. After removing the worktree and
the local branch it fast-forwards `main` when the primary checkout is a clean
checkout of `main`. A planning worktree is cleaned with
`./scripts/cleanup-worktree.sh planning/<name>`, every merged worktree at once
with `--merged`, and an abandoned, unmerged one with `--discard` after
confirmation.

## Linking a feature plan

Expand Down
4 changes: 2 additions & 2 deletions docs/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ its description, and merge it after CI. Remove the planning worktree from the
primary checkout:

```bash
./scripts/cleanup-worktree.sh ../recipe-box-planning-project-bootstrap
./scripts/cleanup-worktree.sh planning/project-bootstrap
```

## 4. Start the first feature
Expand Down Expand Up @@ -241,7 +241,7 @@ Open a pull request containing `Closes #12` and merge it after CI. Remove the
worktree from the primary checkout:

```bash
./scripts/cleanup-worktree.sh ../recipe-box-12-recipes
./scripts/cleanup-worktree.sh 12
```

The next feature starts again at step 4 with `create-feature-issue.sh F02`.
Expand Down
2 changes: 1 addition & 1 deletion docs/project-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ What each part of the template is for. The workflow that connects them is in
| `scripts/finish-feature.sh` | Checks the feature and creates the commit |
| `scripts/check-review.sh` | Reports whether a review still matches what it covers |
| `scripts/update-issue-with-plan.sh` | Links an optional feature plan to its Issue |
| `scripts/cleanup-worktree.sh` | Removes a worktree after its merge |
| `scripts/cleanup-worktree.sh` | Removes a merged worktree and its branch and updates `main` |

## Script libraries

Expand Down
6 changes: 3 additions & 3 deletions docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ flowchart TD
- When the triage has no `FIX_NOW` findings, only deferred and accepted ones,
you can finish directly.
- After `finish-feature.sh`, push the branch, open a pull request containing
`Closes #<issue>`, and merge it after CI. Then remove the worktree with
`cleanup-worktree.sh`.
`Closes #<issue>`, and merge it after CI. Then remove the worktree and its
branch with `cleanup-worktree.sh <issue>`.

## Artifacts

Expand Down Expand Up @@ -215,4 +215,4 @@ tools.
| `finish-planning.sh --check` | To see whether the planning approval still matches the planning documents |
| `triage-review.sh --publish <triage-json>` | To repeat a failed publication of the review and triage reports |
| `update-issue-with-plan.sh <issue> <plan>` | To link an optional feature plan in `.agents/plans/` to its Issue |
| `cleanup-worktree.sh <path>` | After a merge, from the primary checkout, to remove the worktree |
| `cleanup-worktree.sh <issue>`, `planning/<name>`, or `--merged` | After a merge, from the primary checkout: removes the worktree and its branch once GitHub reports the pull request as merged, and updates `main` |
193 changes: 190 additions & 3 deletions scripts/cleanup-worktree.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,193 @@
#!/usr/bin/env bash

set -euo pipefail
if [[ $# -ne 1 ]]; then echo "Usage: $0 <worktree-path>"; exit 1; fi
git worktree remove "$1"

usage() {
echo "Usage:"
echo " $0 <issue-number> Remove the merged worktree of feature/<issue>-*"
echo " $0 planning/<name> Remove the merged planning worktree"
echo " $0 <worktree-path> Remove the merged worktree at a path"
echo " $0 --merged Remove every worktree whose pull request is merged"
echo
echo "Add --discard to remove an unmerged worktree and its branch after confirmation."
echo "Run from the primary checkout."
exit 1
}

fail() {
echo "Error: $*" >&2
exit 1
}

discard=0
merged_mode=0
target=""
for argument in "$@"; do
case "$argument" in
--discard) discard=1 ;;
--merged) merged_mode=1 ;;
-*) usage ;;
*)
[[ -z "$target" ]] || usage
target="$argument"
;;
esac
done
[[ "$merged_mode" -eq 1 && -z "$target" && "$discard" -eq 0 ]] || [[ "$merged_mode" -eq 0 && -n "$target" ]] || usage

command -v gh >/dev/null 2>&1 || fail "GitHub CLI 'gh' is not installed."
command -v jq >/dev/null 2>&1 || fail "jq is required."

# The primary checkout is the first entry of the worktree list.
primary="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
current="$(git rev-parse --show-toplevel)"
current="$(cd "$current" && pwd -P)"
primary="$(cd "$primary" && pwd -P)"
[[ "$current" == "$primary" ]] ||
fail "run cleanup-worktree.sh from the primary checkout ($primary), not from a linked worktree."

# linked_worktrees [all]: prints "<path>\t<branch>" for every linked worktree
# on a feature or planning branch, or on any branch with "all". Paths are
# compared resolved, because Git may print them unresolved.
linked_worktrees() {
local scope="${1:-workflow}"
local path
local branch

git worktree list --porcelain | awk -v scope="$scope" '
/^worktree / { path = substr($0, 10) }
/^branch refs\/heads\// {
branch = substr($0, 19)
if (scope == "all" || branch ~ /^feature\// || branch ~ /^planning\//) {
print path "\t" branch
}
}
' | while IFS=$'\t' read -r path branch; do
[[ -d "$path" && "$(cd "$path" && pwd -P)" != "$primary" ]] || continue
printf '%s\t%s\n' "$path" "$branch"
done
}

# merged_head <branch>: prints the head commit of the merged pull request of
# a branch, or nothing when no pull request of it is merged.
merged_head() {
gh pr list --head "$1" --state merged --limit 1 --json headRefOid --jq '.[0].headRefOid // empty' </dev/null
}

# cleanup <path> <branch>: removes one worktree and its branch when that is
# safe. Returns 0 when removed, 1 when kept, and 2 when a removal failed;
# prints the reason. Callers run it in a condition, so every destructive step
# checks its own status.
cleanup() {
local path="$1"
local branch="$2"
local head
local tip
local answer=""

path="$(cd "$path" && pwd -P)"
if [[ "$path" == "$current" ]]; then
echo "Kept $branch: this is the checkout you run the script in. Run it from the primary checkout."
return 1
fi

# Ignored files, such as review and triage results, do not count.
if [[ -n "$(git -C "$path" status --porcelain)" ]]; then
echo "Kept $branch: $path has uncommitted changes."
return 1
fi

if ! head="$(merged_head "$branch")"; then
echo "Kept $branch: could not ask GitHub about its pull request."
return 1
fi
tip="$(git -C "$path" rev-parse HEAD)"

if [[ -z "$head" || "$head" != "$tip" ]]; then
if [[ "$discard" -eq 1 ]]; then
printf "Discard %s and its worktree %s, although it is not merged? [y/N] " "$branch" "$path"
read -r answer || true
case "$answer" in
y | Y | yes | YES) ;;
*)
echo "Kept $branch."
return 1
;;
esac
elif [[ -z "$head" ]]; then
echo "Kept $branch: its pull request is not merged."
return 1
else
echo "Kept $branch: it has commits after its merged pull request."
return 1
fi
fi

if ! git worktree remove "$path"; then
echo "Error: could not remove the worktree of $branch at $path; the branch was kept." >&2
return 2
fi
if ! git branch -D "$branch" >/dev/null; then
echo "Error: removed the worktree of $branch, but could not delete the branch." >&2
return 2
fi
echo "Removed $branch and its worktree $path."
return 0
}

removed=0
errors=0
if [[ "$merged_mode" -eq 1 ]]; then
while IFS=$'\t' read -r path branch; do
[[ -n "$path" ]] || continue
status=0
cleanup "$path" "$branch" </dev/null || status=$?
case "$status" in
0) removed=$((removed + 1)) ;;
2) errors=$((errors + 1)) ;;
esac
done < <(linked_worktrees)
[[ "$removed" -gt 0 || "$errors" -gt 0 ]] || echo "No merged worktree to remove."
else
# Resolve the target to exactly one worktree.
matches="$(linked_worktrees | awk -F '\t' -v target="$target" '
BEGIN { issue = (target ~ /^[0-9]+$/) }
issue && index($2, "feature/" target "-") == 1 { print; next }
!issue && $2 == target { print; next }
')"
if [[ -z "$matches" && -e "$target" ]]; then
target_path="$(cd "$target" && pwd -P)"
matches="$(linked_worktrees all | while IFS=$'\t' read -r path branch; do
[[ "$(cd "$path" && pwd -P)" == "$target_path" ]] && printf '%s\t%s\n' "$path" "$branch"
done || true)"
fi
[[ -n "$matches" ]] || fail "no feature or planning worktree matches: $target"
[[ "$(printf '%s\n' "$matches" | grep -c .)" -eq 1 ]] || fail "more than one worktree matches: $target"

IFS=$'\t' read -r path branch <<<"$matches"
status=0
cleanup "$path" "$branch" || status=$?
case "$status" in
0) removed=1 ;;
1) exit 1 ;;
*) errors=1 ;;
esac
fi

git worktree prune
echo "Removed worktree $1"

if [[ "$removed" -gt 0 ]]; then
# Bring main up to date with the merged work.
if [[ "$(git -C "$primary" branch --show-current)" == "main" && -z "$(git -C "$primary" status --porcelain)" ]]; then
if git -C "$primary" pull -q --ff-only origin main; then
echo "Updated main in $primary."
else
echo "Warning: could not fast-forward main in $primary; update it by hand."
fi
else
git -C "$primary" fetch -q origin main || true
echo "Fetched origin/main; $primary is not a clean checkout of main, so it was not updated."
fi
fi

[[ "$errors" -eq 0 ]] || exit 1
1 change: 1 addition & 0 deletions scripts/finish-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,4 @@ echo
echo "Next steps:"
echo " git push -u origin $branch"
echo " Open a pull request whose description contains: Closes #$issue"
echo "After the merge, from the primary checkout: ./scripts/cleanup-worktree.sh $issue"
3 changes: 2 additions & 1 deletion scripts/finish-planning.sh
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,5 @@ echo " git push -u origin \"$branch\""
echo
echo "Open the planning PR; $PLANNING_APPROVAL_FILE summarizes the review rounds and the"
echo "findings that were not adopted for its description. After the merge, create"
echo "feature Issues with ./scripts/create-feature-issue.sh <feature-id>."
echo "feature Issues with ./scripts/create-feature-issue.sh <feature-id>, and remove"
echo "this worktree from the primary checkout with ./scripts/cleanup-worktree.sh $branch."
1 change: 1 addition & 0 deletions scripts/verify.conf
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ review-planning: ./tests/review-planning-test.sh
revise-planning: ./tests/revise-planning-test.sh
finish-planning: ./tests/finish-planning-test.sh
finish-feature: ./tests/finish-feature-test.sh
cleanup-worktree: ./tests/cleanup-worktree-test.sh
triage-review: ./tests/triage-review-test.sh
apply-triage: ./tests/apply-triage-test.sh
start-planning: ./tests/start-planning-test.sh
Loading
Loading