From 924172413b2ea1a5d7f907298f52748655c3355c Mon Sep 17 00:00:00 2001 From: Ashlen Date: Tue, 1 Sep 2026 15:32:52 -0600 Subject: [PATCH 1/4] fix(template): ignore pytest-cov's .coverage data file `just ci` runs pytest with --cov, which writes .coverage next to the coverage.xml the generated .gitignore already covers. A downstream had to add the line by hand, and every local edit to a template-owned file is a conflict waiting for the next `copier update`. --- CHANGELOG.md | 5 +++++ template/.gitignore.jinja | 1 + tests/test_generation.py | 1 + 3 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0b0765..64b5adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The generated `.gitignore` ignores pytest-cov's `.coverage` data file alongside + `coverage.xml`. + ## [0.1.1] - 2026-08-04 ### Changed 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/tests/test_generation.py b/tests/test_generation.py index 2123740..d974d41 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -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() From 861cfe13b2ca07dba0789fe2b80b29b45abd10cf Mon Sep 17 00:00:00 2001 From: Ashlen Date: Tue, 1 Sep 2026 15:32:54 -0600 Subject: [PATCH 2/4] fix(tests): render the hook-install test from HEAD test_precommit_install_task_runs called copier.run_copy directly without vcs_ref="HEAD", so it rendered copier's default ref, the latest release tag, and could not observe any change to the copy-time _tasks (the same freeze PR #4 fixed for the render fixture). It also failed on any machine whose global git config sets core.hooksPath, because pre-commit refuses to install hooks under it. Route it through the render fixture and add git_global_config, which points GIT_CONFIG_GLOBAL at a throwaway file in both os.environ and plumbum's local.env (copier's git and task channel), so the machine's config cannot leak into a render in either direction. --- tests/conftest.py | 23 +++++++++++++++++++++++ tests/test_generation.py | 20 ++++++-------------- 2 files changed, 29 insertions(+), 14 deletions(-) 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 d974d41..caf49a5 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -7,12 +7,11 @@ import tomllib from typing import TYPE_CHECKING, NotRequired, TypedDict, cast -import copier import pytest import yaml 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 @@ -302,19 +301,12 @@ 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, 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() From fb4b6200e28619152c3ebf14270a102a6714771b Mon Sep 17 00:00:00 2001 From: Ashlen Date: Tue, 1 Sep 2026 15:32:56 -0600 Subject: [PATCH 3/4] fix(template): skip the hook install when core.hooksPath is set pre-commit refuses to install hooks while git's core.hooksPath is set in any scope, and a failing _task makes copier delete the whole copy. The task now checks the setting first and skips with a hint on stderr, so a machine with a global hooks directory still gets a complete render; the hooks can be installed by hand once the setting is gone. A generation test pins the skip: the copy succeeds, no hook lands, the hint prints. --- CHANGELOG.md | 3 +++ README.md | 2 ++ copier.yml | 9 ++++++++- tests/test_generation.py | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64b5adc..5aedb5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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`. diff --git a/README.md b/README.md index 5487cf8..2196578 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ 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. + ## Toggles All toggles default to `true` — every guardrail layer ships unless you opt out. diff --git a/copier.yml b/copier.yml index 2b92cba..38b9354 100644 --- a/copier.yml +++ b/copier.yml @@ -34,7 +34,14 @@ _tasks: when: "{{ _copier_operation == 'copy' }}" - command: uv sync when: "{{ _copier_operation == 'copy' }}" - - command: uv run pre-commit install --install-hooks + # 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 }}" # _migrations run ONLY on update (never copy), version-gated to the release that diff --git a/tests/test_generation.py b/tests/test_generation.py index caf49a5..6377737 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -311,6 +311,25 @@ def test_precommit_install_task_runs(render: RenderFn, tmp_path: Path) -> None: 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_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() From 71739a9b0bfdd4da95a0231c45d4114b744e0368 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Tue, 1 Sep 2026 15:33:00 -0600 Subject: [PATCH 4/4] feat(template): add in_existing_repo for nested scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding into a subdirectory of an existing repository used to need two manual repairs: the unconditional `git init` created a nested repo, and the CI workflows, .pre-commit-config.yaml and renovate.json rendered where GitHub and Renovate never read them. Deleting those locally then conflicts on every later `copier update` that touches them. The new answer (default false) skips `git init` and the hook install and omits the three root-only artifacts, with an after-copy note listing what to recreate at the repository root. Without it, the init task now fails closed when the destination sits inside another work tree and names the answer to re-run with, instead of nesting a repository silently; copier's own `git init && copier copy … .` pattern still passes because the toplevel is the destination there. --- AGENTS.md | 8 +-- CHANGELOG.md | 9 ++++ README.md | 10 ++++ copier.yml | 30 +++++++++-- ...ing_repo %}renovate.json{% endif %}.jinja} | 0 .../workflows/ci.yml.jinja | 0 ...tion_tests %}mutation.yml{% endif %}.jinja | 0 ...sha_pin_policy %}scan.yml{% endif %}.jinja | 0 ...}.pre-commit-config.yaml{% endif %}.jinja} | 0 tests/test_generation.py | 50 ++++++++++++++++++- 10 files changed, 98 insertions(+), 9 deletions(-) rename template/{{% if enable_renovate %}renovate.json{% endif %}.jinja => {% if enable_renovate and not in_existing_repo %}renovate.json{% endif %}.jinja} (100%) rename template/{.github => {% if not in_existing_repo %}.github{% endif %}}/workflows/ci.yml.jinja (100%) rename template/{.github => {% if not in_existing_repo %}.github{% endif %}}/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja (100%) rename template/{.github => {% if not in_existing_repo %}.github{% endif %}}/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja (100%) rename template/{.pre-commit-config.yaml.jinja => {% if not in_existing_repo %}.pre-commit-config.yaml{% endif %}.jinja} (100%) 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 5aedb5f..4fc7e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ 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 diff --git a/README.md b/README.md index 2196578..df39e80 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,16 @@ 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 38b9354..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,8 +34,16 @@ _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 @@ -42,7 +56,7 @@ _tasks: else uv run pre-commit install --install-hooks fi - when: "{{ _copier_operation == 'copy' and enable_precommit_install }}" + 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). @@ -141,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/{% 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/test_generation.py b/tests/test_generation.py index 6377737..4d0a3f7 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -9,6 +9,7 @@ import pytest import yaml +from copier.errors import TaskError from plumbum import local from tests.conftest import RenderFn, git_global_config, run_in @@ -303,8 +304,8 @@ def test_precommit_config_valid(render: RenderFn, 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.""" - # A global core.hooksPath makes pre-commit refuse to install, so the machine's git - # config must not leak in. + # 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() @@ -330,6 +331,51 @@ def test_precommit_install_skipped_when_hookspath_set( 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()