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
109 changes: 101 additions & 8 deletions .github/scripts/install-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,113 @@

set -euo pipefail

HOOKS_DIR="$(git rev-parse --git-dir)/hooks"
# Hooks live in the common git dir, which is shared by every worktree.
# --git-dir would resolve to the per-worktree directory, which has no hooks/.
GIT_COMMON="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
HOOKS_DIR="${GIT_COMMON}/hooks"
mkdir -p "$HOOKS_DIR"

# A global core.hooksPath silently overrides repo-local hooks, so installing
# into HOOKS_DIR alone would be a no-op. Point this repo at its own hooks and
# chain to whatever was configured before, so global hooks keep running.
CHAIN_DIR=""
CONFIGURED_PATH="$(git config --get core.hooksPath || true)"
if [[ -n "$CONFIGURED_PATH" && "$CONFIGURED_PATH" != "$HOOKS_DIR" ]]; then
CHAIN_DIR="$(cd "$CONFIGURED_PATH" 2>/dev/null && pwd -P || echo "$CONFIGURED_PATH")"
# Remember the target: on a re-run core.hooksPath already points here, so the
# original path would otherwise be lost and the global hooks silently dropped.
git config --local bluefin.chainedHooksPath "$CHAIN_DIR"
git config --local core.hooksPath "$HOOKS_DIR"
echo "NOTE: core.hooksPath was set to ${CHAIN_DIR}."
echo " Repo-local core.hooksPath now points at ${HOOKS_DIR}; existing hooks are chained."
else
# On a re-run core.hooksPath already points here, so recover the original
# target from the saved value, falling back to the global setting.
CHAIN_DIR="$(git config --local --get bluefin.chainedHooksPath || true)"
if [[ -z "$CHAIN_DIR" ]]; then
CHAIN_DIR="$(git config --global --get core.hooksPath || true)"
[[ -n "$CHAIN_DIR" ]] && git config --local bluefin.chainedHooksPath "$CHAIN_DIR"
fi
fi

cat > "$HOOKS_DIR/pre-push" << 'EOF'
#!/usr/bin/env bash
# Block accidental pushes to origin (ublue-os/bluefin).
# The correct remote for projectbluefin contributors is: git push projectbluefin <branch>
remote="$1"
if [[ "$remote" == "origin" ]]; then
echo "ERROR: Pushing to 'origin' (ublue-os/bluefin) is not allowed." >&2
# Guard 1: never write to a repository outside the projectbluefin org.
# Checking the URL rather than the remote name is deliberate: a clone made from
# projectbluefin/bluefin already calls that remote 'origin', and blocking by
# name would reject every legitimate push in that setup.
# Bypass for a one-off with: SKIP_REMOTE_GUARD=1 git push <remote> <branch>
remote_name="$1"
remote_url="${2:-}"
if [[ -z "$remote_url" && -n "$remote_name" ]]; then
remote_url="$(git remote get-url --push "$remote_name" 2>/dev/null || true)"
fi
if [[ -z "${SKIP_REMOTE_GUARD:-}" && -n "$remote_url" && "$remote_url" != *[:/]projectbluefin/* ]]; then
echo "ERROR: Refusing to push to '${remote_name}' (${remote_url})." >&2
echo "Only repositories in the projectbluefin org accept writes." >&2
echo "Use: git push projectbluefin <branch>" >&2
echo "See docs/contributing.md for repository setup instructions." >&2
echo "Override with SKIP_REMOTE_GUARD=1. See docs/contributing.md." >&2
exit 1
fi

# Guard 2: keep the main checkout clean. Feature branches belong in a worktree
# so the main checkout always sits on testing/main with nothing in flight.
# Bypass for a one-off with: SKIP_WORKTREE_GUARD=1 git push projectbluefin <branch>
if [[ -z "${SKIP_WORKTREE_GUARD:-}" ]]; then
git_dir="$(cd "$(git rev-parse --git-dir)" && pwd -P)"
git_common="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
branch="$(git branch --show-current)"

# git_dir == git_common means this is the main checkout, not a linked worktree.
if [[ "$git_dir" == "$git_common" && -n "$branch" ]]; then
case "$branch" in
testing | main) ;;
*)
echo "ERROR: Refusing to push feature branch '${branch}' from the main checkout." >&2
echo "Create an isolated worktree instead:" >&2
echo " bash .github/scripts/worktree.sh new ${branch}" >&2
echo "See docs/skills/worktrees/SKILL.md. Override with SKIP_WORKTREE_GUARD=1." >&2
exit 1
;;
esac
fi
fi
EOF

if [[ -n "$CHAIN_DIR" ]]; then
cat >> "$HOOKS_DIR/pre-push" << EOF

# Chain to the previously configured hooks directory so global hooks still run.
chained="${CHAIN_DIR}/pre-push"
if [[ -x "\$chained" ]]; then
exec "\$chained" "\$@"
elif [[ -f "\$chained" ]]; then
echo "WARNING: \$chained exists but is not executable; skipping it." >&2
echo " Fix with: chmod +x \$chained" >&2
fi
EOF
fi

chmod +x "$HOOKS_DIR/pre-push"
echo "Installed pre-push hook: blocks accidental pushes to origin (ublue-os/bluefin)."
echo "Installed pre-push hook: blocks pushes outside the projectbluefin org and feature-branch pushes from the main checkout."

# Redirecting core.hooksPath would orphan every other global hook, so shim each
# one this script does not manage back to the previously configured directory.
if [[ -n "$CHAIN_DIR" && -d "$CHAIN_DIR" ]]; then
for chained in "$CHAIN_DIR"/*; do
[[ -f "$chained" ]] || continue
hook="$(basename "$chained")"
[[ "$hook" == "pre-push" || "$hook" == *.sample ]] && continue

cat > "$HOOKS_DIR/$hook" << EOF
#!/usr/bin/env bash
# Passthrough shim to the previously configured hooks directory.
# Generated by .github/scripts/install-hooks.sh — do not edit.
chained="${CHAIN_DIR}/${hook}"
[[ -x "\$chained" ]] || exit 0
exec "\$chained" "\$@"
EOF
chmod +x "$HOOKS_DIR/$hook"
echo "Chained global hook: ${hook}"
done
fi
152 changes: 152 additions & 0 deletions .github/scripts/worktree.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# Worktree helper: keep the main checkout clean by doing all feature work in
# isolated worktrees under .worktrees/.
#
# bash .github/scripts/worktree.sh new fix/my-thing
# bash .github/scripts/worktree.sh list
# bash .github/scripts/worktree.sh done fix/my-thing
# bash .github/scripts/worktree.sh prune

set -euo pipefail

REMOTE="projectbluefin"
BASE_BRANCH="testing"

# --show-toplevel returns the *current* worktree's root, so it cannot identify
# the main checkout when this script runs from inside a worktree. The common
# git dir always lives in the main checkout, so derive the root from that.
GIT_COMMON="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
ROOT="$(dirname "$GIT_COMMON")"
WORKTREE_DIR="${ROOT}/.worktrees"

die() {
echo "ERROR: $*" >&2
exit 1
}

slugify() {
printf '%s' "${1//\//-}"
}

require_remote() {
git remote get-url "$REMOTE" >/dev/null 2>&1 ||
die "no '${REMOTE}' remote. Add it: git remote add ${REMOTE} git@github.com:projectbluefin/bluefin.git"
}

# A branch is disposable once its PR is MERGED or CLOSED. Squash merges leave no
# ancestry, so `git branch --merged` cannot answer this; ask the forge instead.
pr_state() {
local branch="$1"
command -v gh >/dev/null 2>&1 || return 1
gh pr list --repo projectbluefin/bluefin --head "$branch" --state all \
--json state --jq '.[0].state' 2>/dev/null
}

# Emit "<path>\t<branch>" for every worktree that has a branch checked out.
# --porcelain paths may contain spaces, so take everything after the keyword
# rather than a whitespace-split field. Detached worktrees emit no branch line
# and are skipped.
worktree_pairs() {
git worktree list --porcelain |
awk '/^worktree /{p=substr($0, 10)} /^branch /{sub("refs/heads/", "", $2); print p"\t"$2}'
}

cmd_new() {
local branch="${1:-}"
[[ -n "$branch" ]] || die "usage: worktree.sh new <branch-name>"
require_remote

local path
path="${WORKTREE_DIR}/$(slugify "$branch")"
[[ -e "$path" ]] && die "worktree already exists: ${path}"

echo "Fetching ${REMOTE}/${BASE_BRANCH}..."
git fetch "$REMOTE" "$BASE_BRANCH" --quiet

mkdir -p "$WORKTREE_DIR"
git worktree add "$path" -b "$branch" "${REMOTE}/${BASE_BRANCH}"

echo
echo "Worktree ready: ${path}"
echo "Branch '${branch}' is based on ${REMOTE}/${BASE_BRANCH}."
echo " cd ${path}"
echo "Push with: git push ${REMOTE} ${branch}"
}

cmd_list() {
local path branch state
worktree_pairs |
while IFS=$'\t' read -r path branch; do
[[ "$path" == "$ROOT" ]] && { printf '%-50s %-40s %s\n' "$path" "$branch" "(main checkout)"; continue; }
state="$(pr_state "$branch" || true)"
printf '%-50s %-40s %s\n' "$path" "$branch" "PR: ${state:-none}"
done
}

remove_worktree() {
local path="$1" branch="$2"
git worktree remove "$path" --force
git branch -D "$branch" 2>/dev/null || true
echo "Removed ${path} (branch ${branch})"
}

cmd_done() {
local target="${1:-}"
[[ -n "$target" ]] || die "usage: worktree.sh done <branch-name>"

local path
path="${WORKTREE_DIR}/$(slugify "$target")"
[[ -d "$path" ]] || die "no worktree at ${path}"

local branch
branch="$(git -C "$path" branch --show-current)"

if [[ -n "$(git -C "$path" status --porcelain)" ]]; then
die "worktree has uncommitted changes: ${path}
Commit or discard them first, or run: git worktree remove ${path} --force"
fi

remove_worktree "$path" "$branch"
}

cmd_prune() {
git worktree prune

local path branch state
while IFS=$'\t' read -r path branch; do
[[ "$path" == "$ROOT" ]] && continue
[[ "$path" == "$WORKTREE_DIR"/* ]] || continue

state="$(pr_state "$branch" || true)"
case "$state" in
MERGED | CLOSED)
if [[ -n "$(git -C "$path" status --porcelain)" ]]; then
echo "SKIP ${path}: PR ${state} but worktree is dirty"
continue
fi
remove_worktree "$path" "$branch"
;;
*)
echo "KEEP ${path} (branch ${branch}, PR: ${state:-none})"
;;
esac
done < <(worktree_pairs)
}

case "${1:-}" in
new) shift && cmd_new "$@" ;;
list) shift && cmd_list "$@" ;;
done) shift && cmd_done "$@" ;;
prune) shift && cmd_prune "$@" ;;
*)
cat >&2 <<'USAGE'
usage: worktree.sh <command>

new <branch> Create a worktree in .worktrees/ based on projectbluefin/testing
list List worktrees with their PR state
done <branch> Remove a worktree and delete its local branch
prune Remove every worktree whose PR is merged or closed
USAGE
exit 1
;;
esac
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ sbom_out/

devcontainer

# Isolated feature worktrees (see .github/scripts/worktree.sh).
# Never track these: a nested .git directory stages as a gitlink and
# silently corrupts history.
.worktrees/

# Scratch checkouts of sibling projectbluefin repos
.common-ref/

# Python cache
__pycache__/
*.py[cod]
Expand Down
14 changes: 9 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ just build <image> <stream> <flavor>
just clean
```

Install the repository hook once after cloning:
Install the repository hooks once after cloning:

```bash
bash .github/scripts/install-hooks.sh
Expand All @@ -84,9 +84,13 @@ bash .github/scripts/install-hooks.sh

## Change flow

- Branch from the remote target, not from whatever is checked out:
`git fetch projectbluefin testing && git checkout -b <branch> projectbluefin/testing`.
- Push to the `projectbluefin` remote explicitly; a pre-push hook blocks `origin`.
- Do every change in an isolated worktree; never work in the main checkout. The
helper cuts the branch from a freshly fetched `projectbluefin/testing`:
`bash .github/scripts/worktree.sh new <branch>`. See
[worktrees](docs/skills/worktrees/SKILL.md).
- Push to the `projectbluefin` remote explicitly. The `pre-push` hook rejects
remotes outside the `projectbluefin` org and feature branches pushed from the
main checkout.
- All pull requests target `testing`. Never open a content PR against `main`.
- One logical change per pull request; squash merge only.
- Check for an existing pull request before opening a new one:
Expand All @@ -100,7 +104,7 @@ bash .github/scripts/install-hooks.sh

## Boundaries

- Do not modify generated artifacts, caches, or worktree contents.
- Do not modify generated artifacts, caches, or another task's worktree.
- Never create, propose, or add new secrets, tokens, PATs, or app credentials.
Reach for documented git and GitHub primitives first, then ask a human.
- Do not add credentials or personal infrastructure details.
Expand Down
10 changes: 9 additions & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
## Before editing

Read [`../AGENTS.md`](../AGENTS.md), then load the matching skill from
[`skills/index.md`](skills/index.md). Start from the remote `testing` branch, not an unrelated local commit.
[`skills/index.md`](skills/index.md). Do the work in an isolated worktree cut
from the remote `testing` branch, not in the main checkout and not on an
unrelated local commit:

```bash
bash .github/scripts/worktree.sh new <branch>
```

See [`skills/worktrees/SKILL.md`](skills/worktrees/SKILL.md).

## Required local checks

Expand Down
1 change: 1 addition & 0 deletions docs/skills/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ the selected skill directs you to do so.

| Task | Skill |
|---|---|
| Set up an isolated workspace before starting work | [worktrees](worktrees/SKILL.md) |
| Build or validate image changes | [build](build/SKILL.md) |
| Debug workflows | [ci](ci/SKILL.md) |
| Change package inputs | [packages](packages/SKILL.md) |
Expand Down
Loading