Skip to content

Commit 679e206

Browse files
chore: enforce isolated worktrees for feature work
The main checkout kept accumulating in-flight work from concurrent sessions. Three defects made that unavoidable: - `.worktrees/` was never gitignored, even though validate-docs.py already excluded it. A nested .git directory stages as a gitlink and silently corrupts history. - `install-hooks.sh` resolved hooks via `--git-dir`, which points at the per-worktree directory with no `hooks/`, so it failed outright inside a worktree. - A global `core.hooksPath` silently overrode the repo-local hook, so the origin guard never actually ran. Add `worktree.sh` to create, list, retire, and prune worktrees branched from a freshly fetched projectbluefin/testing. Branch retirement is resolved through the forge because squash merges leave no ancestry for `git branch --merged` to find. Extend the pre-push hook to refuse feature-branch pushes from the main checkout, overridable with SKIP_WORKTREE_GUARD=1. Install hooks into the common git dir and, when core.hooksPath is overridden, redirect it locally while shimming every unmanaged global hook so it still runs. Assisted-by: Claude Opus 5 via GitHub Copilot CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9eb4a18 commit 679e206

6 files changed

Lines changed: 343 additions & 3 deletions

File tree

.github/scripts/install-hooks.sh

100755100644
Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,38 @@
44

55
set -euo pipefail
66

7-
HOOKS_DIR="$(git rev-parse --git-dir)/hooks"
7+
# Hooks live in the common git dir, which is shared by every worktree.
8+
# --git-dir would resolve to the per-worktree directory, which has no hooks/.
9+
GIT_COMMON="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
10+
HOOKS_DIR="${GIT_COMMON}/hooks"
11+
mkdir -p "$HOOKS_DIR"
12+
13+
# A global core.hooksPath silently overrides repo-local hooks, so installing
14+
# into HOOKS_DIR alone would be a no-op. Point this repo at its own hooks and
15+
# chain to whatever was configured before, so global hooks keep running.
16+
CHAIN_DIR=""
17+
CONFIGURED_PATH="$(git config --get core.hooksPath || true)"
18+
if [[ -n "$CONFIGURED_PATH" && "$CONFIGURED_PATH" != "$HOOKS_DIR" ]]; then
19+
CHAIN_DIR="$(cd "$CONFIGURED_PATH" 2>/dev/null && pwd -P || echo "$CONFIGURED_PATH")"
20+
# Remember the target: on a re-run core.hooksPath already points here, so the
21+
# original path would otherwise be lost and the global hooks silently dropped.
22+
git config --local bluefin.chainedHooksPath "$CHAIN_DIR"
23+
git config --local core.hooksPath "$HOOKS_DIR"
24+
echo "NOTE: core.hooksPath was set to ${CHAIN_DIR}."
25+
echo " Repo-local core.hooksPath now points at ${HOOKS_DIR}; existing hooks are chained."
26+
else
27+
# On a re-run core.hooksPath already points here, so recover the original
28+
# target from the saved value, falling back to the global setting.
29+
CHAIN_DIR="$(git config --local --get bluefin.chainedHooksPath || true)"
30+
if [[ -z "$CHAIN_DIR" ]]; then
31+
CHAIN_DIR="$(git config --global --get core.hooksPath || true)"
32+
[[ -n "$CHAIN_DIR" ]] && git config --local bluefin.chainedHooksPath "$CHAIN_DIR"
33+
fi
34+
fi
835

936
cat > "$HOOKS_DIR/pre-push" << 'EOF'
1037
#!/usr/bin/env bash
11-
# Block accidental pushes to origin (ublue-os/bluefin).
38+
# Guard 1: block accidental pushes to origin (ublue-os/bluefin).
1239
# The correct remote for projectbluefin contributors is: git push projectbluefin <branch>
1340
remote="$1"
1441
if [[ "$remote" == "origin" ]]; then
@@ -17,7 +44,65 @@ if [[ "$remote" == "origin" ]]; then
1744
echo "See docs/contributing.md for repository setup instructions." >&2
1845
exit 1
1946
fi
47+
48+
# Guard 2: keep the main checkout clean. Feature branches belong in a worktree
49+
# so the main checkout always sits on testing/main with nothing in flight.
50+
# Bypass for a one-off with: SKIP_WORKTREE_GUARD=1 git push projectbluefin <branch>
51+
if [[ -z "${SKIP_WORKTREE_GUARD:-}" ]]; then
52+
git_dir="$(cd "$(git rev-parse --git-dir)" && pwd -P)"
53+
git_common="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
54+
branch="$(git branch --show-current)"
55+
56+
# git_dir == git_common means this is the main checkout, not a linked worktree.
57+
if [[ "$git_dir" == "$git_common" && -n "$branch" ]]; then
58+
case "$branch" in
59+
testing | main) ;;
60+
*)
61+
echo "ERROR: Refusing to push feature branch '${branch}' from the main checkout." >&2
62+
echo "Create an isolated worktree instead:" >&2
63+
echo " bash .github/scripts/worktree.sh new ${branch}" >&2
64+
echo "See docs/skills/worktrees/SKILL.md. Override with SKIP_WORKTREE_GUARD=1." >&2
65+
exit 1
66+
;;
67+
esac
68+
fi
69+
fi
2070
EOF
2171

72+
if [[ -n "$CHAIN_DIR" ]]; then
73+
cat >> "$HOOKS_DIR/pre-push" << EOF
74+
75+
# Chain to the previously configured hooks directory so global hooks still run.
76+
chained="${CHAIN_DIR}/pre-push"
77+
if [[ -x "\$chained" ]]; then
78+
exec "\$chained" "\$@"
79+
elif [[ -f "\$chained" ]]; then
80+
echo "WARNING: \$chained exists but is not executable; skipping it." >&2
81+
echo " Fix with: chmod +x \$chained" >&2
82+
fi
83+
EOF
84+
fi
85+
2286
chmod +x "$HOOKS_DIR/pre-push"
23-
echo "Installed pre-push hook: blocks accidental pushes to origin (ublue-os/bluefin)."
87+
echo "Installed pre-push hook: blocks pushes to origin and feature-branch pushes from the main checkout."
88+
89+
# Redirecting core.hooksPath would orphan every other global hook, so shim each
90+
# one this script does not manage back to the previously configured directory.
91+
if [[ -n "$CHAIN_DIR" && -d "$CHAIN_DIR" ]]; then
92+
for chained in "$CHAIN_DIR"/*; do
93+
[[ -f "$chained" ]] || continue
94+
hook="$(basename "$chained")"
95+
[[ "$hook" == "pre-push" || "$hook" == *.sample ]] && continue
96+
97+
cat > "$HOOKS_DIR/$hook" << EOF
98+
#!/usr/bin/env bash
99+
# Passthrough shim to the previously configured hooks directory.
100+
# Generated by .github/scripts/install-hooks.sh — do not edit.
101+
chained="${CHAIN_DIR}/${hook}"
102+
[[ -x "\$chained" ]] || exit 0
103+
exec "\$chained" "\$@"
104+
EOF
105+
chmod +x "$HOOKS_DIR/$hook"
106+
echo "Chained global hook: ${hook}"
107+
done
108+
fi

.github/scripts/worktree.sh

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env bash
2+
# Worktree helper: keep the main checkout clean by doing all feature work in
3+
# isolated worktrees under .worktrees/.
4+
#
5+
# bash .github/scripts/worktree.sh new fix/my-thing
6+
# bash .github/scripts/worktree.sh list
7+
# bash .github/scripts/worktree.sh done fix/my-thing
8+
# bash .github/scripts/worktree.sh prune
9+
10+
set -euo pipefail
11+
12+
REMOTE="projectbluefin"
13+
BASE_BRANCH="testing"
14+
15+
# --show-toplevel returns the *current* worktree's root, so it cannot identify
16+
# the main checkout when this script runs from inside a worktree. The common
17+
# git dir always lives in the main checkout, so derive the root from that.
18+
GIT_COMMON="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
19+
ROOT="$(dirname "$GIT_COMMON")"
20+
WORKTREE_DIR="${ROOT}/.worktrees"
21+
22+
die() {
23+
echo "ERROR: $*" >&2
24+
exit 1
25+
}
26+
27+
slugify() {
28+
printf '%s' "${1//\//-}"
29+
}
30+
31+
require_remote() {
32+
git remote get-url "$REMOTE" >/dev/null 2>&1 ||
33+
die "no '${REMOTE}' remote. Add it: git remote add ${REMOTE} git@github.com:projectbluefin/bluefin.git"
34+
}
35+
36+
# A branch is disposable once its PR is MERGED or CLOSED. Squash merges leave no
37+
# ancestry, so `git branch --merged` cannot answer this; ask the forge instead.
38+
pr_state() {
39+
local branch="$1"
40+
command -v gh >/dev/null 2>&1 || return 1
41+
gh pr list --repo projectbluefin/bluefin --head "$branch" --state all \
42+
--json state --jq '.[0].state' 2>/dev/null
43+
}
44+
45+
cmd_new() {
46+
local branch="${1:-}"
47+
[[ -n "$branch" ]] || die "usage: worktree.sh new <branch-name>"
48+
require_remote
49+
50+
local path
51+
path="${WORKTREE_DIR}/$(slugify "$branch")"
52+
[[ -e "$path" ]] && die "worktree already exists: ${path}"
53+
54+
echo "Fetching ${REMOTE}/${BASE_BRANCH}..."
55+
git fetch "$REMOTE" "$BASE_BRANCH" --quiet
56+
57+
mkdir -p "$WORKTREE_DIR"
58+
git worktree add "$path" -b "$branch" "${REMOTE}/${BASE_BRANCH}"
59+
60+
echo
61+
echo "Worktree ready: ${path}"
62+
echo "Branch '${branch}' is based on ${REMOTE}/${BASE_BRANCH}."
63+
echo " cd ${path}"
64+
echo "Push with: git push ${REMOTE} ${branch}"
65+
}
66+
67+
cmd_list() {
68+
local path branch state
69+
git worktree list --porcelain |
70+
awk '/^worktree /{p=$2} /^branch /{sub("refs/heads/","",$2); print p"\t"$2}' |
71+
while IFS=$'\t' read -r path branch; do
72+
[[ "$path" == "$ROOT" ]] && { printf '%-50s %-40s %s\n' "$path" "$branch" "(main checkout)"; continue; }
73+
state="$(pr_state "$branch" || true)"
74+
printf '%-50s %-40s %s\n' "$path" "$branch" "PR: ${state:-none}"
75+
done
76+
}
77+
78+
remove_worktree() {
79+
local path="$1" branch="$2"
80+
git worktree remove "$path" --force
81+
git branch -D "$branch" 2>/dev/null || true
82+
echo "Removed ${path} (branch ${branch})"
83+
}
84+
85+
cmd_done() {
86+
local target="${1:-}"
87+
[[ -n "$target" ]] || die "usage: worktree.sh done <branch-name>"
88+
89+
local path
90+
path="${WORKTREE_DIR}/$(slugify "$target")"
91+
[[ -d "$path" ]] || die "no worktree at ${path}"
92+
93+
local branch
94+
branch="$(git -C "$path" branch --show-current)"
95+
96+
if [[ -n "$(git -C "$path" status --porcelain)" ]]; then
97+
die "worktree has uncommitted changes: ${path}
98+
Commit or discard them first, or run: git worktree remove ${path} --force"
99+
fi
100+
101+
remove_worktree "$path" "$branch"
102+
}
103+
104+
cmd_prune() {
105+
git worktree prune
106+
107+
local path branch state
108+
while IFS=$'\t' read -r path branch; do
109+
[[ "$path" == "$ROOT" ]] && continue
110+
[[ "$path" == "$WORKTREE_DIR"/* ]] || continue
111+
112+
state="$(pr_state "$branch" || true)"
113+
case "$state" in
114+
MERGED | CLOSED)
115+
if [[ -n "$(git -C "$path" status --porcelain)" ]]; then
116+
echo "SKIP ${path}: PR ${state} but worktree is dirty"
117+
continue
118+
fi
119+
remove_worktree "$path" "$branch"
120+
;;
121+
*)
122+
echo "KEEP ${path} (branch ${branch}, PR: ${state:-none})"
123+
;;
124+
esac
125+
done < <(git worktree list --porcelain |
126+
awk '/^worktree /{p=$2} /^branch /{sub("refs/heads/","",$2); print p"\t"$2}')
127+
}
128+
129+
case "${1:-}" in
130+
new) shift && cmd_new "$@" ;;
131+
list) shift && cmd_list "$@" ;;
132+
done) shift && cmd_done "$@" ;;
133+
prune) shift && cmd_prune "$@" ;;
134+
*)
135+
cat >&2 <<'USAGE'
136+
usage: worktree.sh <command>
137+
138+
new <branch> Create a worktree in .worktrees/ based on projectbluefin/testing
139+
list List worktrees with their PR state
140+
done <branch> Remove a worktree and delete its local branch
141+
prune Remove every worktree whose PR is merged or closed
142+
USAGE
143+
exit 1
144+
;;
145+
esac

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ sbom_out/
1111

1212
devcontainer
1313

14+
# Isolated feature worktrees (see .github/scripts/worktree.sh).
15+
# Never track these: a nested .git directory stages as a gitlink and
16+
# silently corrupts history.
17+
.worktrees/
18+
19+
# Scratch checkouts of sibling projectbluefin repos
20+
.common-ref/
21+
1422
# Python cache
1523
__pycache__/
1624
*.py[cod]

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ Install the repository hook once after cloning:
4949
bash .github/scripts/install-hooks.sh
5050
```
5151

52+
Do feature work in an isolated worktree, never in the main checkout. The
53+
`pre-push` hook enforces this. See [worktrees](docs/skills/worktrees/SKILL.md).
54+
55+
```bash
56+
bash .github/scripts/worktree.sh new fix/my-thing
57+
```
58+
5259
## Source-of-truth rules
5360

5461
- `Justfile` defines local commands.

docs/skills/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ the selected skill directs you to do so.
55

66
| Task | Skill |
77
|---|---|
8+
| Set up an isolated workspace before starting work | [worktrees](worktrees/SKILL.md) |
89
| Build or validate image changes | [build](build/SKILL.md) |
910
| Debug workflows | [ci](ci/SKILL.md) |
1011
| Change package inputs | [packages](packages/SKILL.md) |

docs/skills/worktrees/SKILL.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
---
2+
name: worktrees
3+
description: Keep the main checkout clean by doing all feature work in isolated git worktrees. Use before starting any change, or when the tree is dirty.
4+
metadata:
5+
source-of-truth:
6+
- .github/scripts/worktree.sh
7+
- .github/scripts/install-hooks.sh
8+
- .gitignore
9+
---
10+
11+
# Worktrees
12+
13+
## Use when
14+
15+
- Starting any change that is not a one-line fix on `testing`.
16+
- The main checkout is dirty, on a stale feature branch, or holds another
17+
task's work in progress.
18+
- Running an agent: every agent session gets its own worktree so concurrent
19+
sessions cannot collide in the same working tree.
20+
21+
## Do not use when
22+
23+
- You are already inside a linked worktree. Check first:
24+
25+
```bash
26+
[[ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]] && echo "already isolated"
27+
```
28+
29+
## The contract
30+
31+
The main checkout at the repository root stays on `testing` or `main` with a
32+
clean tree. All feature work happens in `.worktrees/<slug>`, branched from
33+
`projectbluefin/testing`.
34+
35+
Two mechanisms enforce this:
36+
37+
- `.worktrees/` is gitignored. A nested `.git` directory stages as a gitlink
38+
and silently corrupts history, so it must never be tracked.
39+
- The `pre-push` hook refuses to push a feature branch from the main checkout.
40+
Install it once after cloning with `bash .github/scripts/install-hooks.sh`.
41+
42+
## Procedure
43+
44+
Create a worktree. The branch is always cut from a freshly fetched
45+
`projectbluefin/testing`, never from whatever the main checkout happens to be
46+
sitting on:
47+
48+
```bash
49+
bash .github/scripts/worktree.sh new fix/my-thing
50+
cd .worktrees/fix-my-thing
51+
```
52+
53+
Work, validate, and push from inside the worktree:
54+
55+
```bash
56+
just check && pre-commit run --all-files
57+
git push projectbluefin fix/my-thing
58+
```
59+
60+
Clean up after the PR merges:
61+
62+
```bash
63+
bash .github/scripts/worktree.sh done fix/my-thing
64+
```
65+
66+
## Housekeeping
67+
68+
List every worktree with its PR state:
69+
70+
```bash
71+
bash .github/scripts/worktree.sh list
72+
```
73+
74+
Remove all worktrees whose PRs are merged or closed. Dirty worktrees are
75+
skipped, never discarded:
76+
77+
```bash
78+
bash .github/scripts/worktree.sh prune
79+
```
80+
81+
Squash merges leave no ancestry, so `git branch --merged` cannot tell you
82+
whether a branch is finished. `worktree.sh` asks the forge via `gh` instead.
83+
84+
## Failure modes
85+
86+
| Symptom | Cause | Fix |
87+
|---|---|---|
88+
| `pre-push` rejects a feature branch | Pushing from the main checkout | Move the work into a worktree, or `SKIP_WORKTREE_GUARD=1` for a one-off |
89+
| `worktree already exists` | Stale directory from earlier work | `worktree.sh done <branch>`, or `git worktree prune` if the directory is already gone |
90+
| Untracked `.worktrees/` in `git status` | Hook and ignore rules predate this setup | Confirm `.worktrees/` is in `.gitignore` |
91+
| Uncommitted work blocks `done` | Real changes in the worktree | Commit them, or `git worktree remove <path> --force` to discard |
92+
93+
Never use `git add -A` or `git add .`. Stage explicit paths, then verify with
94+
`git status` and `git diff --cached --name-only` before committing.

0 commit comments

Comments
 (0)