Skip to content

Commit 71739a9

Browse files
committed
feat(template): add in_existing_repo for nested scaffolding
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.
1 parent fb4b620 commit 71739a9

10 files changed

Lines changed: 98 additions & 9 deletions

‎AGENTS.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ just precommit # run every hook (commit-stage + pre-push basedpyright) over the
5454

5555
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.
5656

57-
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…`).
57+
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…`).
5858

5959
## Scanning
6060

@@ -64,7 +64,7 @@ just scan # out-of-band secret + SAST scan: semgrep (no-eval) + gitleaks (full
6464

6565
`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`).
6666

67-
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.
67+
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.
6868

6969
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.)
7070

@@ -78,7 +78,7 @@ just audit # dependency vulnerability audit: pip-audit over the full locked gr
7878

7979
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.
8080

81-
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.)
81+
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.)
8282

8383
## Policy gate (`just policy`)
8484

@@ -100,7 +100,7 @@ The SHA-pin sub-check overlaps the zizmor job (the security control), so its net
100100

101101
1. Add an `enable_*` toggle to `copier.yml`.
102102
2. Add the conditional file(s) under `template/` (file: `{% if flag %}name{% endif %}.jinja`; dir: `{% if flag %}dir{% endif %}/`).
103-
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).
103+
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.
104104
4. Extend `tests/test_generation.py`: assert present-when-on AND absent-when-off, and that the layer's gate passes.
105105

106106
## Release

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `in_existing_repo` answer (default `false`): scaffold into a subdirectory of an
13+
existing git repository. Skips `git init` and the hook install, and omits the
14+
root-only files GitHub and Renovate read only at the repository root
15+
(`.github/workflows/*.yml`, `.pre-commit-config.yaml`, `renovate.json`), with an
16+
after-copy note listing what to recreate there. Copying into a subdirectory of a
17+
repository without it now aborts instead of silently creating a nested repository.
18+
1019
### Fixed
1120

1221
- The copy-time hook install no longer aborts (and rolls back) the whole copy when

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ copier update --trust
1818

1919
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.
2020

21+
### Scaffolding into an existing repository
22+
23+
To render the project as a subdirectory of a repository you already have, answer `in_existing_repo` with yes (or pass it as data):
24+
25+
```bash
26+
copier copy --trust --data in_existing_repo=true gh:maybebyte/python-kickstarter ./subdir
27+
```
28+
29+
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.
30+
2131
## Toggles
2232

2333
All toggles default to `true` — every guardrail layer ships unless you opt out.

‎copier.yml‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,29 @@ _exclude:
2121

2222
_message_after_copy: |
2323
"{{ project_name }}" is ready.
24+
{% if in_existing_repo -%}
25+
Rendered inside an existing repository: no git init, no hook install, and the
26+
root-only files were not rendered (.github/workflows/*.yml, .pre-commit-config.yaml,
27+
renovate.json). Recreate them at the repository root by hand if you want CI, hooks,
28+
or Renovate for this subproject; the workflows need a working-directory for it.
29+
{% endif -%}
2430
Next:
2531
cd {{ _copier_conf.dst_path }}
2632
just ci # everything should be green
2733
2834
# Run once on initial copy only (guarded by _copier_operation, requires Copier >= 9.6).
2935
# These are UNSAFE features: `copier copy --trust` / `copier update --trust` required.
3036
_tasks:
31-
- command: git init --quiet
32-
when: "{{ _copier_operation == 'copy' }}"
37+
# Fail closed when the destination sits inside another repository's work tree: a bare
38+
# `git init` there silently nests a repo. Copier's documented `git init && copier copy … .`
39+
# pattern still passes (the toplevel IS the destination). in_existing_repo=true skips this.
40+
- command: |
41+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ "$(git rev-parse --show-toplevel)" != "$(pwd -P)" ]; then
42+
echo "$(pwd) is inside an existing git repository; re-run with --data in_existing_repo=true" >&2
43+
exit 1
44+
fi
45+
git init --quiet
46+
when: "{{ _copier_operation == 'copy' and not in_existing_repo }}"
3347
- command: uv lock
3448
when: "{{ _copier_operation == 'copy' }}"
3549
- command: uv sync
@@ -42,7 +56,7 @@ _tasks:
4256
else
4357
uv run pre-commit install --install-hooks
4458
fi
45-
when: "{{ _copier_operation == 'copy' and enable_precommit_install }}"
59+
when: "{{ _copier_operation == 'copy' and enable_precommit_install and not in_existing_repo }}"
4660
4761
# _migrations run ONLY on update (never copy), version-gated to the release that
4862
# introduced a breaking rename/restructure (they run when new >= declared > old).
@@ -141,6 +155,16 @@ project_type:
141155
library: library
142156
application: application
143157

158+
# ---- Layout -----------------------------------------------------------------
159+
# Scaffold into a subdirectory of an existing repository: no `git init`, no hook install,
160+
# and the root-only files (.github/, .pre-commit-config.yaml, renovate.json) are not
161+
# rendered — GitHub and Renovate read them only at the repository root, so they would be
162+
# inert there, and every local deletion becomes a `copier update` conflict later.
163+
in_existing_repo:
164+
type: bool
165+
default: false
166+
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)
167+
144168
# ---- Guardrail toggles / tuning ---------------------------------------------
145169
ruff_ruleset:
146170
type: str

template/{% if enable_renovate %}renovate.json{% endif %}.jinja renamed to template/{% if enable_renovate and not in_existing_repo %}renovate.json{% endif %}.jinja

File renamed without changes.

template/.github/workflows/ci.yml.jinja renamed to template/{% if not in_existing_repo %}.github{% endif %}/workflows/ci.yml.jinja

File renamed without changes.

template/.github/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja renamed to template/{% if not in_existing_repo %}.github{% endif %}/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja

File renamed without changes.

template/.github/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja renamed 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

File renamed without changes.

template/.pre-commit-config.yaml.jinja renamed to template/{% if not in_existing_repo %}.pre-commit-config.yaml{% endif %}.jinja

File renamed without changes.

‎tests/test_generation.py‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import pytest
1111
import yaml
12+
from copier.errors import TaskError
1213
from plumbum import local
1314

1415
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:
303304

304305
def test_precommit_install_task_runs(render: RenderFn, tmp_path: Path) -> None:
305306
"""The copy-only hook-install task fires when the hidden flag is left at default."""
306-
# A global core.hooksPath makes pre-commit refuse to install, so the machine's git
307-
# config must not leak in.
307+
# A global core.hooksPath makes pre-commit refuse to install (see the skip test
308+
# below), so the machine's git config must not leak in.
308309
with git_global_config(tmp_path / "gitconfig"):
309310
dst = render({**MINIMAL, "enable_precommit_install": True}, tmp_path / "installed")
310311
assert (dst / ".git" / "hooks" / "pre-commit").exists()
@@ -330,6 +331,51 @@ def test_precommit_install_skipped_when_hookspath_set(
330331
assert "core.hooksPath" in capfd.readouterr().err
331332

332333

334+
def test_existing_repo_layer(render: RenderFn, tmp_path: Path) -> None:
335+
"""in_existing_repo renders a hook-less, CI-less subproject inside a parent repo.
336+
337+
No nested `.git`, no hook installed into the parent, and the root-only files
338+
(.github/, .pre-commit-config.yaml, renovate.json) are omitted: GitHub and Renovate
339+
read them only at the repository root, so rendering them would leave inert files
340+
whose local deletion conflicts on every later `copier update`.
341+
"""
342+
parent = tmp_path / "parent"
343+
parent.mkdir()
344+
_ = run_in(parent, "git", "init", "--quiet")
345+
sub = render(
346+
{
347+
**MINIMAL,
348+
"enable_renovate": True,
349+
"enable_precommit_install": True,
350+
"in_existing_repo": True,
351+
},
352+
parent / "sub",
353+
)
354+
assert not (sub / ".git").exists()
355+
assert not (parent / ".git" / "hooks" / "pre-commit").exists()
356+
for omitted in (".github", ".pre-commit-config.yaml", "renovate.json"):
357+
assert not (sub / omitted).exists()
358+
assert (sub / "uv.lock").is_file()
359+
# The rendered project's own gate is still green from a subdirectory.
360+
_ = run_in(sub, "just", "ci")
361+
362+
363+
def test_nested_destination_without_flag_fails_closed(render: RenderFn, tmp_path: Path) -> None:
364+
"""Copying into a subdirectory of a repo without in_existing_repo aborts, not nests.
365+
366+
A bare `git init` there would silently create a nested repository (the original
367+
dogfood finding); the init task detects the enclosing work tree and fails with the
368+
fix in its message, so copier rolls the copy back instead.
369+
"""
370+
parent = tmp_path / "parent"
371+
parent.mkdir()
372+
_ = run_in(parent, "git", "init", "--quiet")
373+
with pytest.raises(TaskError, match="git init"):
374+
_ = render(MINIMAL, parent / "sub")
375+
# copier created `sub`, so cleanup_on_error removed it again.
376+
assert not (parent / "sub").exists()
377+
378+
333379
def test_property_layer(render: RenderFn, tmp_path: Path) -> None:
334380
on = render({**MINIMAL, "enable_property_tests": True}, tmp_path / "on")
335381
assert (on / "tests" / "property" / "test_example_property.py").is_file()

0 commit comments

Comments
 (0)