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
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.)

Expand All @@ -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`)

Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 35 additions & 4 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,42 @@ _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

# 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).
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions template/.gitignore.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ dist/
artifacts/
htmlcov/
coverage.xml
.coverage
requirements-audit.txt
23 changes: 23 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
Loading
Loading