Skip to content

Commit b7f781a

Browse files
authored
Merge pull request #13 from maybebyte/fix/existing-repo-scaffolding
feat(template): add in_existing_repo for scaffolding into an existing repo
2 parents 164e689 + 71739a9 commit b7f781a

12 files changed

Lines changed: 164 additions & 22 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ 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+
19+
### Fixed
20+
21+
- The copy-time hook install no longer aborts (and rolls back) the whole copy when
22+
git's `core.hooksPath` is set; pre-commit refuses to install under it, so the task
23+
skips with a hint on stderr instead.
24+
- The generated `.gitignore` ignores pytest-cov's `.coverage` data file alongside
25+
`coverage.xml`.
26+
1027
## [0.1.1] - 2026-08-04
1128

1229
### Changed

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ To update a downstream project after a new template release:
1616
copier update --trust
1717
```
1818

19+
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.
20+
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+
1931
## Toggles
2032

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

‎copier.yml‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,42 @@ _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
3650
when: "{{ _copier_operation == 'copy' }}"
37-
- command: uv run pre-commit install --install-hooks
38-
when: "{{ _copier_operation == 'copy' and enable_precommit_install }}"
51+
# pre-commit refuses to install while core.hooksPath is set (any scope), and a failing
52+
# task rolls the whole copy back — skip with a hint on stderr instead.
53+
- command: |
54+
if git config --get core.hooksPath >/dev/null; then
55+
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
56+
else
57+
uv run pre-commit install --install-hooks
58+
fi
59+
when: "{{ _copier_operation == 'copy' and enable_precommit_install and not in_existing_repo }}"
3960
4061
# _migrations run ONLY on update (never copy), version-gated to the release that
4162
# introduced a breaking rename/restructure (they run when new >= declared > old).
@@ -134,6 +155,16 @@ project_type:
134155
library: library
135156
application: application
136157

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+
137168
# ---- Guardrail toggles / tuning ---------------------------------------------
138169
ruff_ruleset:
139170
type: str

‎template/.gitignore.jinja‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ dist/
99
artifacts/
1010
htmlcov/
1111
coverage.xml
12+
.coverage
1213
requirements-audit.txt

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.

0 commit comments

Comments
 (0)