diff --git a/AGENTS.md b/AGENTS.md index 3e90f77..b6fc050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ just precommit # run every hook (commit-stage + pre-push basedpyright) over the This is a **local-only** gate: there is no pre-commit CI job, matching the template (whose downstream CI also never runs pre-commit). The ruff and basedpyright *substance* is enforced by the existing `lint` and `typecheck` CI jobs; the hygiene hooks (eof / trailing-whitespace / check-merge-conflict / forbid-rej) have **no** CI backstop and are a local convenience here. A bare `uv run pre-commit run --all-files` runs commit-stage hooks only — basedpyright fires on push or via `just typecheck`; `just precommit` runs both. -Deliberate divergences from `template/.pre-commit-config.yaml.jinja`: ruff runs via local `uv run ruff` hooks (locked 0.15.19) instead of the `astral-sh/ruff-pre-commit` repo (pinned 0.15.18) — the venv is always synced here, so there is no bootstrap reason to keep the isolated-env repo hook; the pytest hook is dropped (the maintainer's only suite is the heavy generation matrix — CI-only). The two configs share the SHA-pinned `pre-commit-hooks` block: **bump both `rev:` pins together** (v6.0.0 = `3e8a8703…`). +Deliberate divergences from the template's `.pre-commit-config.yaml` (`template/{% if not in_existing_repo %}.pre-commit-config.yaml{% endif %}.jinja`): ruff runs via local `uv run ruff` hooks (locked 0.15.19) instead of the `astral-sh/ruff-pre-commit` repo (pinned 0.15.18) — the venv is always synced here, so there is no bootstrap reason to keep the isolated-env repo hook; the pytest hook is dropped (the maintainer's only suite is the heavy generation matrix — CI-only). The two configs share the SHA-pinned `pre-commit-hooks` block: **bump both `rev:` pins together** (v6.0.0 = `3e8a8703…`). ## Scanning @@ -64,7 +64,7 @@ just scan # out-of-band secret + SAST scan: semgrep (no-eval) + gitleaks (full `just scan` runs semgrep's `no-eval` rule and a gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It is out-of-band (chained into no recipe), but CI enforces it: the `scan` job in `.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` + `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). **semgrep scans non-test Python only** — its built-in `.semgrepignore` excludes `tests/`, and there is no `src/`, so on this repo it currently scans **0 files** (a forward guard that mirrors the shipped gate and fires the moment any non-test Python is added at root); gitleaks scans the whole tree + full history regardless of language and is the substantive gate here. Never pass semgrep `--config auto` (it drops the pinned rule and needs metrics on); never hardcode the gitleaks version in CI (install via `mise exec`). -Deliberate divergences from the template's `scan.yml` (`template/.github/workflows/…scan.yml….jinja`): the maintainer folds scanning into the existing `test-template.yml` as a sibling `scan` job (the template consolidates into a standalone `scan.yml`), matching the one-workflow / per-tool layout and letting the existing zizmor job audit it; zizmor stays its own job here rather than a step in `scan` (already dogfooded standalone). The CI `mise-action` comment drops the template's "kept fresh by Renovate" note — **the maintainer has no Renovate**, so the pins are static. +Deliberate divergences from the template's `scan.yml` (`template/{% if not in_existing_repo %}.github{% endif %}/workflows/…scan.yml….jinja`): the maintainer folds scanning into the existing `test-template.yml` as a sibling `scan` job (the template consolidates into a standalone `scan.yml`), matching the one-workflow / per-tool layout and letting the existing zizmor job audit it; zizmor stays its own job here rather than a step in `scan` (already dogfooded standalone). The CI `mise-action` comment drops the template's "kept fresh by Renovate" note — **the maintainer has no Renovate**, so the pins are static. Because nothing here re-derives the pins (no Renovate; the generation drift test reads only the *rendered* downstream), **bump every literal site by hand, against the template.** gitleaks (`8.30.1`) has two maintainer sites — `mise.toml` and the prose above — synced to `template/mise.toml.jinja` (CI installs via `mise exec`, so there is no third gitleaks literal). semgrep (`1.167.0`) has three — the `just scan` recipe, the `scan` job in `test-template.yml`, and the prose above — synced to `template/justfile.jinja` and the template `scan.yml`. (Mirrors the pre-commit "bump both `rev:` pins together" obligation.) @@ -78,7 +78,7 @@ just audit # dependency vulnerability audit: pip-audit over the full locked gr Deliberate divergences from the template's dependency-audit layer: `--no-dev` is dropped (above); pip-audit runs via `uvx pip-audit@2.10.1` in both the recipe and CI with **no** pyproject dep (the template adds `pip-audit>=2.10` to its dev group and runs `uv run pip-audit` locally); it is folded into `test-template.yml`'s `scan` job as a step (the template ships it in a standalone `scan.yml`); and, like the template, `audit` is chained into `just ci` (see "Run every gate") while additionally enforced in CI as the `pip-audit` step in the `scan` job. -Because nothing here re-derives the pin (no Renovate; the generation drift test reads only the *rendered* downstream), **bump every literal by hand, against the template.** pip-audit (`2.10.1`) has three maintainer sites — the `just audit` recipe, the `pip-audit` step in `test-template.yml`, and the prose above — synced to `template/.github/workflows/…scan.yml….jinja` (the only exact-version template site; the template justfile uses unpinned `uv run pip-audit` and template pyproject floors `pip-audit>=2.10`). No `mise.toml` or `pyproject.toml` pip-audit literal exists (uvx-run, unlike gitleaks). **Sync only the pin *value* — never the export flags:** the template's `uv export` keeps `--no-dev`, but the maintainer must not (it exports 0 packages here — see above), so a mechanical sync against the template would silently neuter the gate. (Mirrors the semgrep/gitleaks pin-sync note and the pre-commit "bump both `rev:` pins together" rule.) +Because nothing here re-derives the pin (no Renovate; the generation drift test reads only the *rendered* downstream), **bump every literal by hand, against the template.** pip-audit (`2.10.1`) has three maintainer sites — the `just audit` recipe, the `pip-audit` step in `test-template.yml`, and the prose above — synced to `template/{% if not in_existing_repo %}.github{% endif %}/workflows/…scan.yml….jinja` (the only exact-version template site; the template justfile uses unpinned `uv run pip-audit` and template pyproject floors `pip-audit>=2.10`). No `mise.toml` or `pyproject.toml` pip-audit literal exists (uvx-run, unlike gitleaks). **Sync only the pin *value* — never the export flags:** the template's `uv export` keeps `--no-dev`, but the maintainer must not (it exports 0 packages here — see above), so a mechanical sync against the template would silently neuter the gate. (Mirrors the semgrep/gitleaks pin-sync note and the pre-commit "bump both `rev:` pins together" rule.) ## Policy gate (`just policy`) @@ -100,7 +100,7 @@ The SHA-pin sub-check overlaps the zizmor job (the security control), so its net 1. Add an `enable_*` toggle to `copier.yml`. 2. Add the conditional file(s) under `template/` (file: `{% if flag %}name{% endif %}.jinja`; dir: `{% if flag %}dir{% endif %}/`). -3. Wire it into `template/justfile.jinja` (a recipe; add it as a `ci` dep only for a *gating* layer — out-of-band checks like `scan`/`mutate` ship a recipe but stay off `ci`, and CI-only layers like renovate/sha-pin add no recipe at all). Then, where applicable: a dep in `template/pyproject.toml.jinja` (skip it for `uvx`-run tools like the scanners), a section in `template/AGENTS.md.jinja`, and a CI surface under `template/.github/workflows/` (a conditional step in `scan.yml`, or a dedicated conditional workflow file via the empty-name idiom). +3. Wire it into `template/justfile.jinja` (a recipe; add it as a `ci` dep only for a *gating* layer — out-of-band checks like `scan`/`mutate` ship a recipe but stay off `ci`, and CI-only layers like renovate/sha-pin add no recipe at all). Then, where applicable: a dep in `template/pyproject.toml.jinja` (skip it for `uvx`-run tools like the scanners), a section in `template/AGENTS.md.jinja`, and a CI surface under the template's `.github/workflows/` (a conditional step in `scan.yml`, or a dedicated conditional workflow file via the empty-name idiom). Root-only files — the `.github/` dir, `.pre-commit-config.yaml`, `renovate.json` — carry `not in_existing_repo` in their path condition (GitHub and Renovate read them only at a repository root); a new root-only file must too, and `test_existing_repo_layer`'s omission list grows with it. 4. Extend `tests/test_generation.py`: assert present-when-on AND absent-when-off, and that the layer's gate passes. ## Release diff --git a/CHANGELOG.md b/CHANGELOG.md index b0b0765..4fc7e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `in_existing_repo` answer (default `false`): scaffold into a subdirectory of an + existing git repository. Skips `git init` and the hook install, and omits the + root-only files GitHub and Renovate read only at the repository root + (`.github/workflows/*.yml`, `.pre-commit-config.yaml`, `renovate.json`), with an + after-copy note listing what to recreate there. Copying into a subdirectory of a + repository without it now aborts instead of silently creating a nested repository. + +### Fixed + +- The copy-time hook install no longer aborts (and rolls back) the whole copy when + git's `core.hooksPath` is set; pre-commit refuses to install under it, so the task + skips with a hint on stderr instead. +- The generated `.gitignore` ignores pytest-cov's `.coverage` data file alongside + `coverage.xml`. + ## [0.1.1] - 2026-08-04 ### Changed diff --git a/README.md b/README.md index 5487cf8..df39e80 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,18 @@ To update a downstream project after a new template release: copier update --trust ``` +Hooks are installed on copy unless git's `core.hooksPath` is set (pre-commit refuses to install under it); the copy then skips that step with a hint instead of failing. + +### Scaffolding into an existing repository + +To render the project as a subdirectory of a repository you already have, answer `in_existing_repo` with yes (or pass it as data): + +```bash +copier copy --trust --data in_existing_repo=true gh:maybebyte/python-kickstarter ./subdir +``` + +This skips `git init` and the hook install, and does not render the root-only files GitHub and Renovate read only at the repository root: `.github/workflows/*.yml`, `.pre-commit-config.yaml`, and `renovate.json`. Recreate them at the root by hand if you want CI, hooks, or Renovate for the subproject (the workflows need a `working-directory`). Without the answer, copying into a subdirectory of a repository aborts rather than silently creating a nested one. + ## Toggles All toggles default to `true` — every guardrail layer ships unless you opt out. diff --git a/copier.yml b/copier.yml index 2b92cba..1d29a37 100644 --- a/copier.yml +++ b/copier.yml @@ -21,6 +21,12 @@ _exclude: _message_after_copy: | "{{ project_name }}" is ready. + {% if in_existing_repo -%} + Rendered inside an existing repository: no git init, no hook install, and the + root-only files were not rendered (.github/workflows/*.yml, .pre-commit-config.yaml, + renovate.json). Recreate them at the repository root by hand if you want CI, hooks, + or Renovate for this subproject; the workflows need a working-directory for it. + {% endif -%} Next: cd {{ _copier_conf.dst_path }} just ci # everything should be green @@ -28,14 +34,29 @@ _message_after_copy: | # Run once on initial copy only (guarded by _copier_operation, requires Copier >= 9.6). # These are UNSAFE features: `copier copy --trust` / `copier update --trust` required. _tasks: - - command: git init --quiet - when: "{{ _copier_operation == 'copy' }}" + # Fail closed when the destination sits inside another repository's work tree: a bare + # `git init` there silently nests a repo. Copier's documented `git init && copier copy … .` + # pattern still passes (the toplevel IS the destination). in_existing_repo=true skips this. + - command: | + if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ "$(git rev-parse --show-toplevel)" != "$(pwd -P)" ]; then + echo "$(pwd) is inside an existing git repository; re-run with --data in_existing_repo=true" >&2 + exit 1 + fi + git init --quiet + when: "{{ _copier_operation == 'copy' and not in_existing_repo }}" - command: uv lock when: "{{ _copier_operation == 'copy' }}" - command: uv sync when: "{{ _copier_operation == 'copy' }}" - - command: uv run pre-commit install --install-hooks - when: "{{ _copier_operation == 'copy' and enable_precommit_install }}" + # pre-commit refuses to install while core.hooksPath is set (any scope), and a failing + # task rolls the whole copy back — skip with a hint on stderr instead. + - command: | + if git config --get core.hooksPath >/dev/null; then + echo "core.hooksPath is set: skipping 'pre-commit install'. Run 'uv run pre-commit install --install-hooks' yourself once it is unset, or dispatch pre-commit from your global hooks." >&2 + else + uv run pre-commit install --install-hooks + fi + when: "{{ _copier_operation == 'copy' and enable_precommit_install and not in_existing_repo }}" # _migrations run ONLY on update (never copy), version-gated to the release that # introduced a breaking rename/restructure (they run when new >= declared > old). @@ -134,6 +155,16 @@ project_type: library: library application: application +# ---- Layout ----------------------------------------------------------------- +# Scaffold into a subdirectory of an existing repository: no `git init`, no hook install, +# and the root-only files (.github/, .pre-commit-config.yaml, renovate.json) are not +# rendered — GitHub and Renovate read them only at the repository root, so they would be +# inert there, and every local deletion becomes a `copier update` conflict later. +in_existing_repo: + type: bool + default: false + help: Scaffold into a subdirectory of an existing git repository? (skips git init and hook install; omits .github/, .pre-commit-config.yaml and renovate.json for you to recreate at the repo root) + # ---- Guardrail toggles / tuning --------------------------------------------- ruff_ruleset: type: str diff --git a/template/.gitignore.jinja b/template/.gitignore.jinja index 9cd8629..a2d7a16 100644 --- a/template/.gitignore.jinja +++ b/template/.gitignore.jinja @@ -9,4 +9,5 @@ dist/ artifacts/ htmlcov/ coverage.xml +.coverage requirements-audit.txt diff --git a/template/{% if enable_renovate %}renovate.json{% endif %}.jinja b/template/{% if enable_renovate and not in_existing_repo %}renovate.json{% endif %}.jinja similarity index 100% rename from template/{% if enable_renovate %}renovate.json{% endif %}.jinja rename to template/{% if enable_renovate and not in_existing_repo %}renovate.json{% endif %}.jinja diff --git a/template/.github/workflows/ci.yml.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/ci.yml.jinja similarity index 100% rename from template/.github/workflows/ci.yml.jinja rename to template/{% if not in_existing_repo %}.github{% endif %}/workflows/ci.yml.jinja diff --git a/template/.github/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja similarity index 100% rename from template/.github/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja rename to template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja diff --git a/template/.github/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja b/template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja similarity index 100% rename from template/.github/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja rename to template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja diff --git a/template/.pre-commit-config.yaml.jinja b/template/{% if not in_existing_repo %}.pre-commit-config.yaml{% endif %}.jinja similarity index 100% rename from template/.pre-commit-config.yaml.jinja rename to template/{% if not in_existing_repo %}.pre-commit-config.yaml{% endif %}.jinja diff --git a/tests/conftest.py b/tests/conftest.py index 4760986..aa7ba98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,6 +74,29 @@ def without_interpreter_pins() -> Generator[None]: _restore_pins(saved_os, saved_pl) +@contextlib.contextmanager +def git_global_config(path: Path, *, hooks_path: Path | None = None) -> Generator[None]: + """Point git's global config at a throwaway file for the duration. + + Carries an identity (copier commits a dirty HEAD template with it) and, when + `hooks_path` is given, `core.hooksPath` -- the setting pre-commit refuses to install + under. Set in os.environ AND plumbum's local.env (copier's git + task channel), so a + render behaves the same on a machine with or without a global core.hooksPath. + """ + lines = ["[user]", "\tname = Test", "\temail = test@example.com"] + if hooks_path is not None: + lines += ["[core]", f"\thooksPath = {hooks_path}"] + _ = path.write_text("\n".join(lines) + "\n") + saved_os = os.environ.get("GIT_CONFIG_GLOBAL") + saved_pl = local.env.get("GIT_CONFIG_GLOBAL") + os.environ["GIT_CONFIG_GLOBAL"] = str(path) + local.env["GIT_CONFIG_GLOBAL"] = str(path) + try: + yield + finally: + _restore_pins({"GIT_CONFIG_GLOBAL": saved_os}, {"GIT_CONFIG_GLOBAL": saved_pl}) + + def _missing_tools() -> list[str]: return [t for t in REQUIRED_TOOLS if shutil.which(t) is None] diff --git a/tests/test_generation.py b/tests/test_generation.py index 2123740..4d0a3f7 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -7,12 +7,12 @@ import tomllib from typing import TYPE_CHECKING, NotRequired, TypedDict, cast -import copier import pytest import yaml +from copier.errors import TaskError from plumbum import local -from tests.conftest import RenderFn, run_in, without_interpreter_pins +from tests.conftest import RenderFn, git_global_config, run_in if TYPE_CHECKING: from pathlib import Path @@ -96,6 +96,7 @@ def test_minimal_renders(render: RenderFn, tmp_path: Path) -> None: assert "toml" not in editorconfig gitignore = (project / ".gitignore").read_text() assert "coverage.xml" in gitignore + assert ".coverage" in gitignore assert "requirements-audit.txt" in gitignore assert (project / "tests" / "conftest.py").is_file() @@ -301,23 +302,80 @@ def test_precommit_config_valid(render: RenderFn, tmp_path: Path) -> None: _ = run_in(project, "uv", "run", "pre-commit", "run", "--all-files") -def test_precommit_install_task_runs(template_root: Path, tmp_path: Path) -> None: +def test_precommit_install_task_runs(render: RenderFn, tmp_path: Path) -> None: """The copy-only hook-install task fires when the hidden flag is left at default.""" - dst = tmp_path / "installed" - with without_interpreter_pins(): - _ = copier.run_copy( - str(template_root), - str(dst), - data={**MINIMAL, "enable_precommit_install": True}, - defaults=True, - unsafe=True, - overwrite=True, - quiet=True, - ) + # A global core.hooksPath makes pre-commit refuse to install (see the skip test + # below), so the machine's git config must not leak in. + with git_global_config(tmp_path / "gitconfig"): + dst = render({**MINIMAL, "enable_precommit_install": True}, tmp_path / "installed") assert (dst / ".git" / "hooks" / "pre-commit").exists() assert (dst / ".git" / "hooks" / "pre-push").exists() +def test_precommit_install_skipped_when_hookspath_set( + render: RenderFn, tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """A global core.hooksPath must not abort the copy: the install task skips with a hint. + + pre-commit refuses to install hooks while core.hooksPath is set (any scope), and a + failing _task makes copier roll the whole copy back. The task guards on the setting + instead, so the render succeeds hook-less and says why on stderr. + """ + hooks = tmp_path / "hooks" + hooks.mkdir() + with git_global_config(tmp_path / "gitconfig", hooks_path=hooks): + project = render({**MINIMAL, "enable_precommit_install": True}, tmp_path / "out") + assert (project / "uv.lock").is_file() + assert not (project / ".git" / "hooks" / "pre-commit").exists() + assert not (hooks / "pre-commit").exists() + assert "core.hooksPath" in capfd.readouterr().err + + +def test_existing_repo_layer(render: RenderFn, tmp_path: Path) -> None: + """in_existing_repo renders a hook-less, CI-less subproject inside a parent repo. + + No nested `.git`, no hook installed into the parent, and the root-only files + (.github/, .pre-commit-config.yaml, renovate.json) are omitted: GitHub and Renovate + read them only at the repository root, so rendering them would leave inert files + whose local deletion conflicts on every later `copier update`. + """ + parent = tmp_path / "parent" + parent.mkdir() + _ = run_in(parent, "git", "init", "--quiet") + sub = render( + { + **MINIMAL, + "enable_renovate": True, + "enable_precommit_install": True, + "in_existing_repo": True, + }, + parent / "sub", + ) + assert not (sub / ".git").exists() + assert not (parent / ".git" / "hooks" / "pre-commit").exists() + for omitted in (".github", ".pre-commit-config.yaml", "renovate.json"): + assert not (sub / omitted).exists() + assert (sub / "uv.lock").is_file() + # The rendered project's own gate is still green from a subdirectory. + _ = run_in(sub, "just", "ci") + + +def test_nested_destination_without_flag_fails_closed(render: RenderFn, tmp_path: Path) -> None: + """Copying into a subdirectory of a repo without in_existing_repo aborts, not nests. + + A bare `git init` there would silently create a nested repository (the original + dogfood finding); the init task detects the enclosing work tree and fails with the + fix in its message, so copier rolls the copy back instead. + """ + parent = tmp_path / "parent" + parent.mkdir() + _ = run_in(parent, "git", "init", "--quiet") + with pytest.raises(TaskError, match="git init"): + _ = render(MINIMAL, parent / "sub") + # copier created `sub`, so cleanup_on_error removed it again. + assert not (parent / "sub").exists() + + def test_property_layer(render: RenderFn, tmp_path: Path) -> None: on = render({**MINIMAL, "enable_property_tests": True}, tmp_path / "on") assert (on / "tests" / "property" / "test_example_property.py").is_file()