diff --git a/.gitignore b/.gitignore index 0c7acc1..c92b968 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ celerybeat-schedule # Environments .env +.env.worktree .venv env/ venv/ diff --git a/AGENTS.md b/AGENTS.md index 0f4b15b..eabe0e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,9 +59,15 @@ URLs. Install dependencies with: ```sh -make install +make bootstrap ``` +The bootstrap uses Git metadata to identify the primary checkout. In a linked +worktree, it can share the primary checkout's ignored `.env` and creates an +ignored `.env.worktree` with a stable `WORKTREE_ID`. Applications must +explicitly load dotenv files; when they do, `.env.worktree` should load after +`.env`. + Use these commands while making changes: ```sh @@ -74,6 +80,20 @@ make test-parallel # Use for a large, independent test suite Use `make fix` or `make format` only when changes to source files are intended. `make hooks` may also modify files. +## Worktrees and Parallel Agents + +- Edit only the current checkout. Never modify the primary checkout or sibling + worktrees. +- Avoid broad clean, reset, or delete operations. Do not stop services that may + be shared with another checkout or agent. +- Coordinate ownership of conflict-prone files such as lockfiles, schemas, + migrations, snapshots, and generated artifacts. +- Serialize or stack changes that update state which cannot be merged safely. +- Never hand-edit files declared as generated; use their generator. +- `pytest-xdist` runs tests in parallel, but it does not isolate external + resources across worktrees. Give each worktree separate ports, databases, + caches, containers, and similar resources when tests use them. + After adding a library package, verify its wheel can be installed and imported: ```sh @@ -106,6 +126,6 @@ human approval. - Add tests for new library behavior under `tests/`. - Use the configured Ruff and ty checks; do not introduce duplicate tooling without a project need. -- Copy `.env.example` to `.env` for local environment configuration. Do not - commit generated build output, virtual environments, `.env` files, or - credentials. +- Copy `.env.example` to `.env` or use `make bootstrap` in a linked worktree. + Do not commit generated build output, virtual environments, `.env` files, + `.env.worktree`, or credentials. diff --git a/CHANGELOG.md b/CHANGELOG.md index aab8220..cd3f400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Add an idempotent `make bootstrap` command for primary checkouts and Git + worktrees. + ### Changed ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78072e2..6262bf6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,12 +2,15 @@ Clone the repository. Move into the directory on your terminal. -Install dependencies for development. +Prepare the checkout and install dependencies for development. ```sh -make install +make bootstrap ``` +The command can be run in either the primary checkout or a linked worktree. +See the [README](README.md) for its environment-file behavior. + Install pre-commit to run a battery of automatic quick fixes against your work. ```sh diff --git a/MANIFEST.in b/MANIFEST.in index 4897864..be60802 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include .codespell-ignore-words.txt include .editorconfig +include .env.example include .gitattributes include .gitignore include .pre-commit-config.yaml @@ -19,4 +20,5 @@ include zizmor.yml recursive-include .devcontainer * recursive-include .github * recursive-include docs *.md *.py Makefile +recursive-include scripts *.py recursive-include tests *.py diff --git a/Makefile b/Makefile index a72cb47..9d53b8d 100644 --- a/Makefile +++ b/Makefile @@ -2,16 +2,20 @@ UV ?= uv UV_PYTHON ?= +PYTHON ?= python3 PACKAGE ?= COVERAGE_FAIL_UNDER ?= 80 TEST_ARGS ?= RUN = $(if $(UV_PYTHON),UV_PYTHON=$(UV_PYTHON)) $(UV) run -.PHONY: all help install install-all install-dev install-test install-test-extras install-docs check verify diff-check lint format-check format fix type-check dependency-check workflow-check manifest-check test test-serial test-parallel coverage build package-check package-verify docs docs-check linkcheck build-docs serve-docs hooks clean +.PHONY: all help bootstrap install install-all install-dev install-test install-test-extras install-docs check verify diff-check lint format-check format fix type-check dependency-check workflow-check manifest-check test test-serial test-parallel coverage build package-check package-verify docs docs-check linkcheck build-docs serve-docs hooks clean help: ## Show available commands @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST) +bootstrap: ## Prepare this checkout and install locked dependencies + $(PYTHON) scripts/worktree_bootstrap.py --uv "$(UV)" + install: install-all ## Install all development dependencies install-all: ## Install every optional dependency group diff --git a/README.md b/README.md index 0178037..df3dba1 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,25 @@ GitHub Actions, and agent-friendly project guidance. 1. Use this template to create a repository. 2. Complete [TEMPLATE_SETUP.md](TEMPLATE_SETUP.md) before the first release. -3. Install development dependencies: +3. Bootstrap the checkout and install development dependencies: ```sh - make install + make bootstrap ``` The template intentionally does not define a package, API, or application. Choose those when adapting it. +`make bootstrap` is safe to run again. In a linked Git worktree, it links the +primary checkout's ignored `.env` when one exists, without replacing any local +file. It also creates an ignored `.env.worktree` containing a stable +`WORKTREE_ID` for namespacing ports, databases, caches, or containers. Existing +local settings in that file are preserved. + +Applications must opt in to loading dotenv files. When supported, load the +shared `.env` first and `.env.worktree` second so worktree-local values take +precedence. The bootstrap does not assume a web framework or dotenv library. + ## Development ```sh diff --git a/scripts/worktree_bootstrap.py b/scripts/worktree_bootstrap.py new file mode 100644 index 0000000..540d0da --- /dev/null +++ b/scripts/worktree_bootstrap.py @@ -0,0 +1,180 @@ +"""Prepare a Git worktree for local development.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Sequence + +WORKTREE_ID_VARIABLE = "WORKTREE_ID" + + +class Runner(Protocol): + """Run commands needed by the bootstrap.""" + + def capture(self, command: Sequence[str], cwd: Path) -> str: + """Run a command and return its standard output.""" + + def execute(self, command: Sequence[str], cwd: Path) -> None: + """Run a command with output attached to the terminal.""" + + +class SubprocessRunner: + """Run bootstrap commands in subprocesses.""" + + def capture(self, command: Sequence[str], cwd: Path) -> str: + """Run a command and return its standard output.""" + result = subprocess.run( # noqa: S603 - Commands are never shell strings. + command, + cwd=cwd, + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return result.stdout + + def execute(self, command: Sequence[str], cwd: Path) -> None: + """Run a command with output attached to the terminal.""" + subprocess.run( # noqa: S603 - Commands are never shell strings. + command, + cwd=cwd, + check=True, + ) + + +@dataclass(frozen=True) +class GitCheckout: + """Paths Git reports for the current and primary checkouts.""" + + current: Path + primary: Path + + @classmethod + def discover(cls, runner: Runner, cwd: Path) -> GitCheckout: + """Discover checkout paths from Git metadata.""" + current_output = runner.capture( + ("git", "rev-parse", "--show-toplevel"), + cwd, + ) + current = Path(current_output.strip()).resolve() + worktree_output = runner.capture( + ("git", "worktree", "list", "--porcelain"), + current, + ) + primary = parse_primary_checkout(worktree_output) + return cls(current=current, primary=primary) + + @property + def is_linked_worktree(self) -> bool: + """Return whether the current checkout is not the primary checkout.""" + return self.current != self.primary + + +def parse_primary_checkout(output: str) -> Path: + """Return the primary checkout from Git's porcelain worktree listing.""" + for line in output.splitlines(): + if line.startswith("worktree "): + return Path(line.removeprefix("worktree ")).resolve() + msg = "Git did not report a primary checkout." + raise ValueError(msg) + + +def worktree_identifier(checkout: Path) -> str: + """Build a stable, environment-safe identifier for a checkout.""" + resolved = checkout.resolve() + slug = re.sub(r"[^a-z0-9]+", "-", resolved.name.lower()).strip("-") + safe_slug = slug or "worktree" + path_hash = hashlib.sha256(os.fsencode(resolved)).hexdigest()[:8] + return f"{safe_slug}-{path_hash}" + + +def ensure_shared_env(checkout: GitCheckout) -> None: + """Link the primary .env into a linked worktree without overwriting files.""" + if not checkout.is_linked_worktree: + return + + source = checkout.primary / ".env" + destination = checkout.current / ".env" + if not source.exists(): + return + + if destination.is_symlink() and destination.resolve() == source.resolve(): + return + if destination.exists() or destination.is_symlink(): + msg = f"Refusing to replace existing {destination}." + raise FileExistsError(msg) + + destination.symlink_to(source) + + +def ensure_worktree_override(checkout: Path, identifier: str) -> None: + """Create or update the managed identifier while preserving local settings.""" + override = checkout / ".env.worktree" + assignment = f"{WORKTREE_ID_VARIABLE}={identifier}" + if not override.exists(): + override.write_text( + "# Worktree-local overrides. Load this after .env when supported.\n" + f"{assignment}\n", + encoding="utf-8", + ) + return + + original = override.read_text(encoding="utf-8") + lines = original.splitlines() + matching_indexes = [ + index + for index, line in enumerate(lines) + if line.startswith(f"{WORKTREE_ID_VARIABLE}=") + ] + if len(matching_indexes) > 1: + msg = f"{override} contains multiple {WORKTREE_ID_VARIABLE} assignments." + raise ValueError(msg) + if matching_indexes: + index = matching_indexes[0] + if lines[index] == assignment: + return + lines[index] = assignment + else: + if lines and lines[-1]: + lines.append("") + lines.append(assignment) + + override.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def bootstrap(cwd: Path, uv: str, runner: Runner) -> GitCheckout: + """Prepare local environment files and install locked dependencies.""" + checkout = GitCheckout.discover(runner, cwd) + ensure_shared_env(checkout) + ensure_worktree_override( + checkout.current, + worktree_identifier(checkout.current), + ) + runner.execute( + (uv, "sync", "--all-groups", "--locked"), + checkout.current, + ) + return checkout + + +def main() -> None: + """Run the worktree bootstrap.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--uv", default=os.environ.get("UV", "uv")) + args = parser.parse_args() + + checkout = bootstrap(Path.cwd(), args.uv, SubprocessRunner()) + location = "linked worktree" if checkout.is_linked_worktree else "primary checkout" + print(f"Bootstrap complete for {location}.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_worktree_bootstrap.py b/tests/test_worktree_bootstrap.py new file mode 100644 index 0000000..dc93b14 --- /dev/null +++ b/tests/test_worktree_bootstrap.py @@ -0,0 +1,185 @@ +"""Tests for the worktree development bootstrap.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +import pytest + +from scripts.worktree_bootstrap import ( + GitCheckout, + bootstrap, + ensure_shared_env, + ensure_worktree_override, + parse_primary_checkout, + worktree_identifier, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + +class FakeRunner: + """Return fixed Git metadata and record command orchestration.""" + + def __init__(self, current: Path, primary: Path) -> None: + self.current = current + self.primary = primary + self.captured: list[tuple[tuple[str, ...], Path]] = [] + self.executed: list[tuple[tuple[str, ...], Path]] = [] + + def capture(self, command: Sequence[str], cwd: Path) -> str: + self.captured.append((tuple(command), cwd)) + if command == ("git", "rev-parse", "--show-toplevel"): + return f"{self.current}\n" + if command == ("git", "worktree", "list", "--porcelain"): + return ( + f"worktree {self.primary}\n" + "HEAD abc123\n" + "branch refs/heads/main\n\n" + f"worktree {self.current}\n" + "HEAD def456\n" + "branch refs/heads/feature\n" + ) + msg = f"Unexpected command: {command}" + raise AssertionError(msg) + + def execute(self, command: Sequence[str], cwd: Path) -> None: + self.executed.append((tuple(command), cwd)) + + +@pytest.mark.unit +def test_worktree_identifier_is_stable_and_environment_safe(tmp_path: Path) -> None: + checkout = tmp_path / "My Feature@2" + checkout.mkdir() + + first = worktree_identifier(checkout) + second = worktree_identifier(checkout) + + assert first == second + assert re.fullmatch(r"my-feature-2-[0-9a-f]{8}", first) + assert first != worktree_identifier(tmp_path) + + +@pytest.mark.unit +def test_parse_primary_checkout_uses_first_porcelain_entry(tmp_path: Path) -> None: + primary = tmp_path / "main checkout" + linked = tmp_path / "linked checkout" + output = ( + f"worktree {primary}\nHEAD abc\nbranch refs/heads/main\n\n" + f"worktree {linked}\nHEAD def\nbranch refs/heads/feature\n" + ) + + assert parse_primary_checkout(output) == primary.resolve() + + +@pytest.mark.unit +def test_checkout_discovery_uses_git_metadata(tmp_path: Path) -> None: + primary = tmp_path / "primary" + current = tmp_path / "current" + primary.mkdir() + current.mkdir() + runner = FakeRunner(current, primary) + + checkout = GitCheckout.discover(runner, current / "nested") + + assert checkout == GitCheckout(current.resolve(), primary.resolve()) + assert runner.captured == [ + (("git", "rev-parse", "--show-toplevel"), current / "nested"), + (("git", "worktree", "list", "--porcelain"), current.resolve()), + ] + + +@pytest.mark.unit +def test_shared_env_symlink_is_created_and_idempotent(tmp_path: Path) -> None: + primary = tmp_path / "primary" + current = tmp_path / "current" + primary.mkdir() + current.mkdir() + source = primary / ".env" + source.write_text("SECRET=not-printed\n", encoding="utf-8") + checkout = GitCheckout(current, primary) + + ensure_shared_env(checkout) + ensure_shared_env(checkout) + + destination = current / ".env" + assert destination.is_symlink() + assert destination.resolve() == source.resolve() + + +@pytest.mark.unit +def test_shared_env_refuses_to_replace_existing_file(tmp_path: Path) -> None: + primary = tmp_path / "primary" + current = tmp_path / "current" + primary.mkdir() + current.mkdir() + (primary / ".env").write_text("PRIMARY=1\n", encoding="utf-8") + destination = current / ".env" + destination.write_text("LOCAL=1\n", encoding="utf-8") + + with pytest.raises(FileExistsError, match="Refusing to replace"): + ensure_shared_env(GitCheckout(current, primary)) + + assert destination.read_text(encoding="utf-8") == "LOCAL=1\n" + + +@pytest.mark.unit +def test_primary_checkout_does_not_replace_its_env(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("PRIMARY=1\n", encoding="utf-8") + + ensure_shared_env(GitCheckout(tmp_path, tmp_path)) + + assert not env_file.is_symlink() + assert env_file.read_text(encoding="utf-8") == "PRIMARY=1\n" + + +@pytest.mark.unit +def test_worktree_override_is_created_and_preserves_local_settings( + tmp_path: Path, +) -> None: + ensure_worktree_override(tmp_path, "feature-12345678") + override = tmp_path / ".env.worktree" + assert override.read_text(encoding="utf-8") == ( + "# Worktree-local overrides. Load this after .env when supported.\n" + "WORKTREE_ID=feature-12345678\n" + ) + + override.write_text( + "# Local setting\nPORT=8123\nWORKTREE_ID=old-00000000\n", + encoding="utf-8", + ) + ensure_worktree_override(tmp_path, "feature-12345678") + + assert override.read_text(encoding="utf-8") == ( + "# Local setting\nPORT=8123\nWORKTREE_ID=feature-12345678\n" + ) + + +@pytest.mark.unit +def test_bootstrap_orchestrates_files_and_locked_sync(tmp_path: Path) -> None: + primary = tmp_path / "primary" + current = tmp_path / "feature" + primary.mkdir() + current.mkdir() + (primary / ".env").write_text("SHARED=1\n", encoding="utf-8") + runner = FakeRunner(current, primary) + + checkout = bootstrap(current, "custom-uv", runner) + + assert checkout == GitCheckout(current.resolve(), primary.resolve()) + assert (current / ".env").resolve() == (primary / ".env").resolve() + assert ( + (current / ".env.worktree") + .read_text(encoding="utf-8") + .startswith("# Worktree-local overrides.") + ) + assert runner.executed == [ + ( + ("custom-uv", "sync", "--all-groups", "--locked"), + current.resolve(), + ) + ]