From 4765afb2a51738a2e065afd57ee933f3aecae8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 2 Oct 2026 11:26:20 +0200 Subject: [PATCH] chore: sync worktree scripts with the shared standard bin/worktree.sh takes --dir and WORKTREES_DIR, and uses ../.worktrees// when that directory exists. bin/worktree-done.sh finds the worktree by its branch, wherever it is. Argument checks are stricter, and the script no longer exits silently in a repo without a compose file. --- bin/worktree-done.sh | 29 ++++++++++++++++++----- bin/worktree.sh | 55 ++++++++++++++++++++++++++++++++++++-------- docs/workflow.md | 22 ++++++++++++++---- 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/bin/worktree-done.sh b/bin/worktree-done.sh index 16e2ddb..5a83800 100755 --- a/bin/worktree-done.sh +++ b/bin/worktree-done.sh @@ -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 (run from the main checkout) +# Usage: bin/worktree-done.sh set -euo pipefail -issue="${1:?usage: bin/worktree-done.sh }" +usage="usage: bin/worktree-done.sh " +[[ $# -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 (/issue--), 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 diff --git a/bin/worktree.sh b/bin/worktree.sh index 7337a35..529a8fc 100755 --- a/bin/worktree.sh +++ b/bin/worktree.sh @@ -1,18 +1,38 @@ #!/usr/bin/env bash # Create an isolated worktree for one issue and prepare its environment. # -# Usage: bin/worktree.sh [type] -# type: feat|fix|docs|refactor|test|chore (default: derived from the type:* label) +# Usage: bin/worktree.sh [--dir ] [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 ../-worktrees/issue- on branch /issue--, empty +# Creates a worktree (location: see below) on branch /issue--, 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 [type]}" -type="${2:-}" +usage="usage: bin/worktree.sh [--dir ] [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)" @@ -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 exactly +# 2. WORKTREES_DIR= //issue- +# 3. a .worktrees/ next to the clone ../.worktrees//issue- +# 4. otherwise ../-worktrees/issue- +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" @@ -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" diff --git a/docs/workflow.md b/docs/workflow.md index 3122f7b..d71f77d 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -50,11 +50,25 @@ score breakdown. You still make the final pick. Blocked issues ```bash bin/worktree.sh # optional 2nd argument: feat|fix|docs|refactor|test|chore -cd ../-worktrees/issue- +cd ``` -The script fetches the default branch and creates the worktree -`../-worktrees/issue-` on branch `/issue--`. It also: +The script fetches the default branch and creates a worktree on branch +`/issue--`. You choose where it goes; the first match wins: + +1. `--dir `: exactly `` (a relative path is relative to where you are), +2. `WORKTREES_DIR=` in the environment: `//issue-`, +3. a `.worktrees` directory next to the clone (a workspace with several clones side by + side): `../.worktrees//issue-`, +4. otherwise: `../-worktrees/issue-`. + +`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 `/issue--` (the cleanup looks for `/issue--`) 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 @@ -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 `/issue--` +- [ ] Work done in a worktree (not the main checkout), branch `/issue--` - [ ] Tests added, all checks pass in the worktree - [ ] `CHANGELOG.md` and docs updated - [ ] Committed but not pushed before the review accepted