diff --git a/.deps-installed b/.deps-installed deleted file mode 100644 index e69de29b..00000000 diff --git a/.gitignore b/.gitignore index cf89f56b..57adb759 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ xnotes/* .env .envrc .venv +.deps-installed .vscode/settings.json # mypy diff --git a/AGENTS.md b/AGENTS.md index 4511e753..28ddd2cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,22 @@ Do not attribute work to Claude, Copilot or any other AI agent anywhere in this This rule takes precedence over any default tool behaviour that would add attribution. +## Worktrees + +Create worktrees with `scripts/new-worktree.sh`, never with `git worktree add` or an agent's built-in worktree tool: + +```bash +./scripts/new-worktree.sh [] # defaults to feat/ +``` + +It creates `../span-panel-api-` beside the primary checkout from a freshly fetched `origin/main`, prepares it with `setup-hooks.sh`, and links `.env`. That location is the one the integration's developer notes expect when installing a library +worktree into its environment. [DEVELOPMENT.md](DEVELOPMENT.md#worktrees) has the details. + +- **Never share a virtual environment between checkouts.** This repository installs its own distributions in editable mode, each recording one checkout's source path, so a shared `.venv` imports whichever checkout synced last and tests pass against the + wrong code without an error. +- **Install the pre-commit hook from the primary checkout.** Hooks are shared by every worktree and record the interpreter that installed them. `new-worktree.sh` installs the hook from the primary checkout when it is missing or bound elsewhere, and refuses + to create the worktree if that fails. `setup-hooks.sh` in a worktree checks the shared hook rather than rebinding it to an environment that is deleted with the worktree. + ## The rule every field follows A value the panel did not publish is `None` (or unknown), never a constant. A `0.0`, `False`, `""` or empty collection in a snapshot field is a reading the panel published, or an answer the library can derive from what the panel declared, and the docstring diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1a9e72c7..0cc96cf7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -280,7 +280,20 @@ To install pre-commit hooks: ./setup-hooks.sh ``` -This installs dependencies (if needed) and configures git pre-commit hooks. +This syncs dependencies and, in the primary checkout, installs the git pre-commit hook. Run from a worktree, it syncs that worktree's own environment and checks the shared hook instead of reinstalling it: hooks are shared by every worktree and record the +interpreter that installed them, so a hook installed from a worktree stops working when that worktree is removed. `./setup-hooks.sh --check-hook` only checks the shared hook, and exits non-zero when it is missing or not bound to the primary checkout. + +## Worktrees + +Create worktrees with the script rather than `git worktree add`: + +```bash +./scripts/new-worktree.sh [] # defaults to feat/ +``` + +It fetches `origin`, fast-forwards `main` where that is safe, installs the shared pre-commit hook from the primary checkout if it is missing or bound elsewhere, creates `../span-panel-api-` on a branch that does not track a remote, links `.env` from +the primary checkout, and runs `setup-hooks.sh` inside it. The worktree gets a virtual environment of its own rather than a link to the primary checkout's: editable installs record one checkout's source path, so a shared environment would import whichever +checkout synced last. ## Workspace layout diff --git a/scripts/new-worktree.sh b/scripts/new-worktree.sh new file mode 100755 index 00000000..b75e4b6c --- /dev/null +++ b/scripts/new-worktree.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# +# Create a worktree for a branch of work beside the primary checkout, and prepare +# it with setup-hooks.sh. +# +# Usage: scripts/new-worktree.sh [] +# +# The worktree is created at ../-, beside the primary +# checkout: the layout the integration's developer notes expect when +# installing a library worktree into its environment. +# The branch to create. Defaults to feat/. +# +# The branch starts from origin/main as just fetched and does not track it, so a +# bare `git push` can never land on main. Local main is fast-forwarded as well where +# that is safe, and the script refuses when local main holds commits origin/main +# lacks, because branching from origin/main would silently leave them out. +# +# The worktree gets a virtual environment of its own rather than a link to the +# primary checkout's. This repository installs its own distributions in editable +# mode, and each records the path of one checkout's sources, so a shared +# environment imports whichever checkout synced last and tests pass against the +# wrong code without an error. uv clones packages from its cache, so the second +# environment costs little. Local configuration that is not bound to a path, `.env`, +# is linked instead. +# +# The shared pre-commit hook is installed from the primary checkout first when it is +# missing or bound elsewhere, so no worktree starts out committing unchecked. + +set -euo pipefail + +usage() { + echo "Usage: $0 []" >&2 + exit 2 +} + +[[ $# -ge 1 && $# -le 2 ]] || usage +name=$1 +if [[ ! "$name" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Worktree name '$name' may use only letters, digits, dots, underscores and hyphens." >&2 + exit 2 +fi +branch=${2:-feat/$name} + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +checkout=$(cd "$script_dir/.." && pwd) +common_dir=$(git -C "$checkout" rev-parse --path-format=absolute --git-common-dir) +primary_root=$(dirname "$common_dir") +target="$(dirname "$primary_root")/$(basename "$primary_root")-$name" + +if [[ -e "$target" ]]; then + echo "$target already exists." >&2 + exit 1 +fi +if git -C "$primary_root" show-ref --verify --quiet "refs/heads/$branch"; then + echo "Branch $branch already exists." >&2 + exit 1 +fi + +echo "Fetching origin..." +git -C "$primary_root" fetch origin + +if git -C "$primary_root" show-ref --verify --quiet refs/heads/main; then + ahead=$(git -C "$primary_root" rev-list --count origin/main..main) + if ((ahead > 0)); then + echo "Local main has $ahead commit(s) that origin/main does not. Reconcile main first;" >&2 + echo "branching from origin/main would silently leave them out." >&2 + exit 1 + fi + + # Where main is checked out, move it only if that checkout is clean. + main_checkout=$(git -C "$primary_root" worktree list --porcelain | + awk '/^worktree /{wt = substr($0, 10)} /^branch refs\/heads\/main$/{print wt}') + if [[ -z "$main_checkout" ]]; then + # update-ref rather than `branch --force`: that would also rewrite main's + # upstream to origin/main, silently re-pointing a main that tracks another + # remote. The old value guards against main moving since it was checked. + git -C "$primary_root" update-ref -m "new-worktree.sh: fast-forward main to origin/main" \ + refs/heads/main "$(git -C "$primary_root" rev-parse refs/remotes/origin/main)" \ + "$(git -C "$primary_root" rev-parse refs/heads/main)" + elif [[ -n "$(git -C "$main_checkout" status --porcelain --untracked-files=no)" ]]; then + echo "main is checked out with uncommitted changes at $main_checkout; leaving it as it is." + elif ! git -C "$main_checkout" merge --ff-only --quiet origin/main; then + # Untracked files are not counted as changes above, because a fast-forward + # refuses to overwrite one; this is where that refusal lands. + echo "Could not fast-forward main at $main_checkout; leaving it as it is." + fi +fi + +# Every worktree commits through the one hook in the common directory. Missing, its +# commits skip every check without a word; bound to an environment that is gone, +# they fail. The primary checkout is the one place it can be installed to last, so +# install it there before the worktree exists, and refuse if that does not take. +if ! (cd "$checkout" && bash ./setup-hooks.sh --check-hook >/dev/null); then + echo "Installing the shared pre-commit hook from the primary checkout..." + if ! (cd "$primary_root" && ./setup-hooks.sh); then + echo "Could not install the pre-commit hook from $primary_root; not creating the worktree." >&2 + exit 1 + fi + if ! (cd "$checkout" && bash ./setup-hooks.sh --check-hook); then + echo "The pre-commit hook is still not bound to $primary_root; not creating the worktree." >&2 + exit 1 + fi +fi + +echo "Creating $target on $branch from origin/main..." +git -C "$primary_root" worktree add --no-track -b "$branch" "$target" origin/main + +if [[ -f "$primary_root/.env" && ! -e "$target/.env" ]]; then + ln -s "$primary_root/.env" "$target/.env" + echo "Linked .env from the primary checkout." +fi + +# The setup script beside this one rather than the target's copy. The branch starts +# from origin/main, which can predate the worktree-aware setup, and running an older +# copy there would rebind the shared hook to the new worktree. +echo "Preparing the worktree..." +(cd "$target" && bash "$checkout/setup-hooks.sh") + +echo +echo "Worktree ready: $target" +echo "Branch: $branch, from $(git -C "$target" rev-parse --short HEAD), not tracking a remote" diff --git a/setup-hooks.sh b/setup-hooks.sh index 3143a50b..25f87ab7 100755 --- a/setup-hooks.sh +++ b/setup-hooks.sh @@ -1,31 +1,114 @@ #!/bin/bash +# +# Prepare this checkout for development: install dependencies and make sure the +# git pre-commit hook is in place. +# +# Usage: ./setup-hooks.sh [--update | --check-hook] +# +# --update Upgrade the lockfile before syncing. +# --check-hook Only check the shared pre-commit hook, and exit non-zero when it +# is missing or not bound to the primary checkout. +# +# Safe to run from the primary checkout and from a linked worktree, which differ +# in the one respect that matters here. Git hooks live in the repository's common +# directory and every worktree shares them, and `pre-commit install` writes the +# interpreter that runs it into the hook. Installing from a worktree therefore +# binds every checkout's commits to that worktree's virtual environment -- which +# is deleted with the worktree, after which every commit fails with "`pre-commit` +# not found". So only the primary checkout installs the hook. A worktree checks +# that the shared hook is bound to the primary checkout and says how to fix it if +# not, and leaves it alone either way. -# Check if we should force update (pass --update flag) -FORCE_UPDATE=false -if [[ "$1" == "--update" ]]; then - FORCE_UPDATE=true -fi +usage() { + echo "Usage: $0 [--update | --check-hook]" >&2 + exit 2 +} + +mode=setup +case "${1:-}" in + "") ;; + --update) mode=update ;; + --check-hook) mode=check-hook ;; + *) usage ;; +esac +[[ $# -le 1 ]] || usage + +git_dir=$(git rev-parse --absolute-git-dir) || exit 1 +common_dir=$(git rev-parse --path-format=absolute --git-common-dir) || exit 1 +primary_root=$(dirname "$common_dir") -# Ensure dependencies are installed first -if [[ ! -f ".deps-installed" ]] || [[ "pyproject.toml" -nt ".deps-installed" ]] || [[ "uv.lock" -nt ".deps-installed" ]] || [[ "$FORCE_UPDATE" == "true" ]]; then - echo "Installing/updating dependencies..." +# Whether the shared hook exists and runs with the primary checkout's environment. +# Silent when it does; otherwise says what is wrong and how to fix it. +check_shared_hook() { + local hook install_python - if [[ "$FORCE_UPDATE" == "true" ]]; then - echo "Forcing update to latest versions..." - uv lock --upgrade - uv sync - else - uv sync + hook=$(git rev-parse --path-format=absolute --git-path hooks/pre-commit) + if [[ ! -f "$hook" ]]; then + echo "No pre-commit hook is installed for this repository yet, so commits skip every" + echo "check. Install it from the primary checkout, where it outlives every worktree:" + echo " (cd \"$primary_root\" && ./setup-hooks.sh)" + return 1 fi - if [[ $? -ne 0 ]]; then - echo "Failed to install dependencies. Please check the output above." + install_python=$(sed -n "s/^INSTALL_PYTHON=//p" "$hook") + install_python=${install_python#\'} + install_python=${install_python%\'} + + if [[ "$install_python" != "$primary_root"/.venv/* ]]; then + echo "The shared pre-commit hook runs with ${install_python:-an unknown interpreter}, not the" + echo "primary checkout's environment, so commits will fail once that environment is gone." + echo "Rebind it from the primary checkout:" + echo " (cd \"$primary_root\" && ./setup-hooks.sh)" + return 1 + fi + + if [[ ! -x "$install_python" ]]; then + echo "The shared pre-commit hook runs with $install_python, which no longer exists, so" + echo "commits will fail. Reinstall it from the primary checkout:" + echo " (cd \"$primary_root\" && ./setup-hooks.sh)" + return 1 + fi +} + +if [[ "$mode" == "check-hook" ]]; then + check_shared_hook || exit 1 + echo "Shared pre-commit hook is bound to the primary checkout." + exit 0 +fi + +if [[ "$mode" == "update" ]]; then + echo "Forcing update to latest versions..." + if ! uv lock --upgrade; then + echo "Failed to upgrade the lockfile. Please check the output above." exit 1 fi - touch .deps-installed fi -# Install pre-commit hooks -uv run pre-commit install +# Every run syncs. uv is idempotent and clones packages from its cache, so an +# environment that is already current costs little. This used to be gated on a +# stamp file that had been committed to the repository, which meant a fresh +# worktree checked the stamp out and whether it got an environment at all came +# down to the order in which checkout happened to write files. +echo "Installing/updating dependencies..." +if ! uv sync; then + echo "Failed to install dependencies. Please check the output above." + exit 1 +fi -echo "Git hooks installed successfully!" +if [[ "$git_dir" == "$common_dir" ]]; then + if ! uv run pre-commit install; then + echo "Failed to install the pre-commit hook. Please check the output above." + exit 1 + fi + if ! check_shared_hook; then + echo "Failed to bind the pre-commit hook to this checkout's environment." + exit 1 + fi + echo "Git hooks installed successfully!" + exit 0 +fi + +# A linked worktree from here on. +if check_shared_hook; then + echo "Shared pre-commit hook is bound to the primary checkout; leaving it alone." +fi