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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ celerybeat-schedule

# Environments
.env
.env.worktree
.venv
env/
venv/
Expand Down
28 changes: 24 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
include .codespell-ignore-words.txt
include .editorconfig
include .env.example
include .gitattributes
include .gitignore
include .pre-commit-config.yaml
Expand All @@ -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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 180 additions & 0 deletions scripts/worktree_bootstrap.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading