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
29 changes: 23 additions & 6 deletions bin/worktree-done.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
#!/usr/bin/env bash
# Remove the worktree of a merged issue and return to a fresh default branch.
#
# Usage: bin/worktree-done.sh <issue-number> (run from the main checkout)
# Usage: bin/worktree-done.sh <issue-number>
set -euo pipefail

issue="${1:?usage: bin/worktree-done.sh <issue-number>}"
usage="usage: bin/worktree-done.sh <issue-number>"
[[ $# -eq 1 && $1 =~ ^[0-9]+$ ]] || { echo "$usage" >&2; exit 1; }
issue="$1"

root="$(git rev-parse --show-toplevel)"
repo="$(basename "$root")"
# The main checkout, also when this runs inside one of its worktrees.
root="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
default="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)"
dir="$(dirname "$root")/$repo-worktrees/issue-$issue"
# Find the worktree by its branch (<type>/issue-<N>-<slug>), wherever it is.
# The first record is the main checkout; it is never removed.
matches="$(git -C "$root" worktree list --porcelain | awk -v n="$issue" '
/^worktree / { wt = substr($0, 10); count++ }
/^branch / && count > 1 { b = $2; sub("^refs/heads/", "", b); if (b ~ ("/issue-" n "(-|$)")) print wt }
')"

if [[ -z "$matches" ]]; then
echo "No worktree with a branch for issue #$issue (git worktree list)" >&2
exit 1
fi
if [[ "$(wc -l <<<"$matches")" -gt 1 ]]; then
echo "More than one worktree for issue #$issue; remove the right one by hand:" >&2
echo "$matches" >&2
exit 1
fi
dir="$matches"
if [[ ! -d "$dir" ]]; then
echo "No worktree at $dir" >&2
echo "Worktree $dir is missing; run: git worktree prune" >&2
exit 1
fi

Expand Down
55 changes: 46 additions & 9 deletions bin/worktree.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
#!/usr/bin/env bash
# Create an isolated worktree for one issue and prepare its environment.
#
# Usage: bin/worktree.sh <issue-number> [type]
# type: feat|fix|docs|refactor|test|chore (default: derived from the type:* label)
# Usage: bin/worktree.sh [--dir <path>] <issue-number> [type]
# type: feat|fix|docs|refactor|test|chore (default: derived from the type:* label)
# --dir: exact path of the new worktree (see "Worktree location" below)
#
# Creates ../<repo>-worktrees/issue-<N> on branch <type>/issue-<N>-<slug>, empty
# Creates a worktree (location: see below) on branch <type>/issue-<N>-<slug>, empty
# findings.md and review.md (both gitignored), a unique COMPOSE_PROJECT_NAME and
# free host ports in .env.worktree, then runs the optional bin/worktree-setup.sh.
set -euo pipefail

issue="${1:?usage: bin/worktree.sh <issue-number> [type]}"
type="${2:-}"
usage="usage: bin/worktree.sh [--dir <path>] <issue-number> [type]"
dir=""
args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dir) dir="${2:?$usage}"; shift 2 ;;
--dir=*) dir="${1#--dir=}"; [[ -n "$dir" ]] || { echo "$usage" >&2; exit 1; }; shift ;;
-h|--help) echo "$usage"; exit 0 ;;
-*) echo "unknown option: $1" >&2; echo "$usage" >&2; exit 1 ;;
*) args+=("$1"); shift ;;
esac
done
if [[ ${#args[@]} -lt 1 || ${#args[@]} -gt 2 ]]; then echo "$usage" >&2; exit 1; fi
issue="${args[0]}"
type="${args[1]:-}"
[[ $issue =~ ^[0-9]+$ ]] || { echo "issue must be a number: $issue" >&2; exit 1; }
case "$type" in
""|feat|fix|docs|refactor|test|chore) ;;
*) echo "unknown type: $type (feat|fix|docs|refactor|test|chore)" >&2; exit 1 ;;
esac

root="$(git rev-parse --show-toplevel)"
# The main checkout, also when this runs inside one of its worktrees.
root="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
repo="$(basename "$root")"
default="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)"

Expand All @@ -33,7 +53,23 @@ fi

slug="$(tr '[:upper:]' '[:lower:]' <<<"$title" | tr -cs 'a-z0-9' '-' | sed 's/^-//; s/-$//' | cut -c1-40 | sed 's/-$//')"
branch="$type/issue-$issue-$slug"
dir="$(dirname "$root")/$repo-worktrees/issue-$issue"
# Worktree location, first match wins:
# 1. --dir <path> exactly <path>
# 2. WORKTREES_DIR=<dir> <dir>/<repo>/issue-<N>
# 3. a .worktrees/ next to the clone ../.worktrees/<repo>/issue-<N>
# 4. otherwise ../<repo>-worktrees/issue-<N>
parent="$(dirname "$root")"
if [[ -z "$dir" ]]; then
if [[ -n "${WORKTREES_DIR:-}" ]]; then
dir="$WORKTREES_DIR/$repo/issue-$issue"
elif [[ -d "$parent/.worktrees" ]]; then
dir="$parent/.worktrees/$repo/issue-$issue"
else
dir="$parent/$repo-worktrees/issue-$issue"
fi
fi
# A relative path is relative to the current directory, not to the main checkout.
case "$dir" in /*) ;; *) dir="$PWD/$dir" ;; esac

git -C "$root" fetch origin "$default"
git -C "$root" worktree add -b "$branch" "$dir" "origin/$default"
Expand All @@ -43,11 +79,12 @@ for f in findings.md review.md; do
printf '# %s for #%s\n\n' "${f%.md}" "$issue" >"$f"
done

project="$(tr -c 'a-z0-9\n' '-' <<<"$repo-issue-$issue")"
project="$(tr '[:upper:]' '[:lower:]' <<<"$repo-issue-$issue" | tr -c 'a-z0-9\n' '-')"
{
echo "COMPOSE_PROJECT_NAME=$project"
# Every ${NAME_PORT:-1234} in a compose file gets a free host port here.
grep -rhoE '\$\{[A-Z0-9_]*PORT[A-Z0-9_]*:-[0-9]+\}' --include='*compose*.y*ml' . 2>/dev/null \
# grep exits 1 when there is no compose file; that is fine.
{ grep -rhoE '\$\{[A-Z0-9_]*PORT[A-Z0-9_]*:-[0-9]+\}' --include='*compose*.y*ml' . 2>/dev/null || true; } \
| sed -E 's/^\$\{([A-Z0-9_]+):-.*/\1/' | sort -u | while read -r name; do
port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')"
echo "$name=$port"
Expand Down
22 changes: 18 additions & 4 deletions docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,25 @@ score breakdown. You still make the final pick. Blocked issues

```bash
bin/worktree.sh <issue-number> # optional 2nd argument: feat|fix|docs|refactor|test|chore
cd ../<repo>-worktrees/issue-<N>
cd <worktree path printed by the script>
```

The script fetches the default branch and creates the worktree
`../<repo>-worktrees/issue-<N>` on branch `<type>/issue-<N>-<slug>`. It also:
The script fetches the default branch and creates a worktree on branch
`<type>/issue-<N>-<slug>`. You choose where it goes; the first match wins:

1. `--dir <path>`: exactly `<path>` (a relative path is relative to where you are),
2. `WORKTREES_DIR=<dir>` in the environment: `<dir>/<repo>/issue-<N>`,
3. a `.worktrees` directory next to the clone (a workspace with several clones side by
side): `../.worktrees/<repo>/issue-<N>`,
4. otherwise: `../<repo>-worktrees/issue-<N>`.

`bin/worktree-done.sh` finds the worktree by its branch name, so any location works. The
scripts are a convenience: a worktree you create yourself is fine, as long as its branch
is named `<type>/issue-<N>-<slug>` (the cleanup looks for `/issue-<N>-`) and the rest of
this process is followed.

The main checkout stays on the default branch and is not edited; it is for reading only.
The script also:

- creates empty `findings.md` and `review.md` (both gitignored),
- writes `.env.worktree` with a unique `COMPOSE_PROJECT_NAME` and a free host port for
Expand Down Expand Up @@ -219,7 +233,7 @@ Every repo must allow several worktrees to build and test at the same time.
## Checklist

- [ ] Issue picked with `bin/pick-issue.sh`; it has `type:*`, `priority:*` and a milestone
- [ ] Work done in a worktree from `bin/worktree.sh`, branch `<type>/issue-<N>-<slug>`
- [ ] Work done in a worktree (not the main checkout), branch `<type>/issue-<N>-<slug>`
- [ ] Tests added, all checks pass in the worktree
- [ ] `CHANGELOG.md` and docs updated
- [ ] Committed but not pushed before the review accepted
Expand Down
Loading