diff --git a/.copier-answers.yaml b/.copier-answers.yaml index f82341e..d5e4e11 100644 --- a/.copier-answers.yaml +++ b/.copier-answers.yaml @@ -1,8 +1,8 @@ # DO NOT EDIT: Changes here will be overwritten by Copier # Run `copier update` instead -_commit: v0.1.0-9-g94592dd +_commit: v0.1.0-12-gc255d97 _src_path: . -created_on: '2026-03-12' +created_on: '2026-03-13' hosting_org: gordon-code hosting_platform: github project_description: Swiss-army project template managed by Copier diff --git a/.editorconfig b/.editorconfig index 5ad5524..269dd81 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,5 +25,8 @@ max_line_length = off [*.json] max_line_length = off +[*.jinja] +max_line_length = off + [{*.xml,*.iml}] max_line_length = off diff --git a/.github/actions/nix-setup/action.yaml b/.github/actions/nix-setup/action.yaml index 2edc251..6de0e03 100644 --- a/.github/actions/nix-setup/action.yaml +++ b/.github/actions/nix-setup/action.yaml @@ -11,7 +11,12 @@ inputs: runs: using: composite steps: - - uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17 + - uses: DeterminateSystems/nix-installer-action@c5a866b6ab867e88becbed4467b93592bce69f8a # v21 with: - extra-conf: ${{ inputs.nix-extra-conf }} - - uses: DeterminateSystems/magic-nix-cache-action@87b14cf437d03d37989d87f0fa5ce4f5dc1a330b # v8 + extra-conf: | + eval-cores = 1 + ${{ inputs.nix-extra-conf }} + diagnostic-endpoint: "" + - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13 + with: + diagnostic-endpoint: "" diff --git a/.github/renovate.json b/.github/renovate.json index 3d598fb..e091435 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -17,7 +17,7 @@ { "customType": "regex", "description": "Update SHA-pinned actions in .jinja template files", - "fileMatch": ["template/.*\\.ya?ml(\\.jinja)?$"], + "fileMatch": ["template/.*\\.ya?ml(\\.jinja)?$", "includes/.*\\.jinja$"], "matchStrings": ["uses:\\s+(?[\\w-]+/[\\w-]+)@(?[a-f0-9]+)\\s+#\\s+(?v[\\S]+)"], "datasourceTemplate": "github-releases" }, diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index c5421cd..a45cdf4 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -40,5 +40,6 @@ jobs: run: nix develop -c just render - name: Check for drift run: | + git checkout -- .copier-answers.yaml git diff test -z "$(git status --porcelain)" diff --git a/.idea/modules.xml b/.idea/modules.xml index 9fc4ff5..12a1202 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -1,5 +1,5 @@ - + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1a110f..6f8952d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ cog bump --major To pull in template updates: - copier update --trust + just update ## Dependency Updates diff --git a/README.md b/README.md index b13aa49..57ee01a 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,19 @@ cd my-project nix run nixpkgs#just -- install ``` -### With direnv +### Development ```shell direnv allow +# or: nix develop -c $SHELL ``` -## Development +## Recipes | Recipe | Description | |--------|-------------| | `just install` | Install dependencies and git hooks | +| `just update` | Pull in template updates | | `just lint` | Run all linters | | `just test` | Run tests | | `just fmt` | Format code | diff --git a/copier.yaml b/copier.yaml index 44d1254..91705a7 100644 --- a/copier.yaml +++ b/copier.yaml @@ -64,7 +64,9 @@ hosting_org: {{ (repo_url | regex_replace('/$', '') | regex_search('[/:]([^/]+)/[^/]+?(\\.git)?$', '\\1') or []) | first }} validator: >- - {% if not (hosting_org | regex_search('^[a-zA-Z0-9._-]+$')) %} + {% if hosting_platform == 'github' and not (hosting_org | regex_search('^[a-zA-Z0-9][a-zA-Z0-9-]*$')) %} + GitHub org/username: letters, digits, and hyphens only (no dots or underscores). + {% elif not (hosting_org | regex_search('^[a-zA-Z0-9._-]+$')) %} Org/username must contain only letters, digits, hyphens, dots, or underscores. {% endif %} when: "{{ hosting_platform in ['github', 'gitlab'] }}" diff --git a/includes/ci-consistency-job.jinja b/includes/ci-consistency-job.jinja index 5c4201f..a1a31be 100644 --- a/includes/ci-consistency-job.jinja +++ b/includes/ci-consistency-job.jinja @@ -1,4 +1,5 @@ {% if _is_template %} + consistency: runs-on: ubuntu-latest steps: @@ -12,6 +13,7 @@ run: nix develop -c just render - name: Check for drift run: | + git checkout -- .copier-answers.yaml git diff test -z "$(git status --porcelain)" {% endif %} diff --git a/includes/readme-template-quickstart.jinja b/includes/readme-template-quickstart.jinja new file mode 100644 index 0000000..9588b7b --- /dev/null +++ b/includes/readme-template-quickstart.jinja @@ -0,0 +1,10 @@ +{% if _is_template %} + +### From template + +```shell +copier copy --trust https://github.com/gordon-code/project-template.git my-project +cd my-project +nix run nixpkgs#just -- install +``` +{% endif %} diff --git a/includes/renovate-template.jinja b/includes/renovate-template.jinja index 7a5fb5c..c0d85af 100644 --- a/includes/renovate-template.jinja +++ b/includes/renovate-template.jinja @@ -3,7 +3,7 @@ { "customType": "regex", "description": "Update SHA-pinned actions in .jinja template files", - "fileMatch": ["template/.*\\.ya?ml(\\.jinja)?$"], + "fileMatch": ["template/.*\\.ya?ml(\\.jinja)?$", "includes/.*\\.jinja$"], "matchStrings": ["uses:\\s+(?[\\w-]+/[\\w-]+)@(?[a-f0-9]+)\\s+#\\s+(?v[\\S]+)"], "datasourceTemplate": "github-releases" }, diff --git a/justfile b/justfile index 386f9a0..eb7c649 100644 --- a/justfile +++ b/justfile @@ -11,15 +11,15 @@ render: set -euo pipefail # Delete generated root files (preserve non-template dirs) find . -maxdepth 1 \ - ! -name '.' ! -name '.git' ! -name '.venv' ! -name '.direnv' \ - ! -name '.serena' \ - ! -name 'template' ! -name 'includes' ! -name 'copier.yaml' \ - ! -name 'hack' ! -name 'tests' ! -name 'pytest.ini' \ - ! -name 'flake.lock' \ - -exec rm -rf {} + + ! -name '.' ! -name '.git' ! -name '.venv' ! -name '.direnv' \ + ! -name '.serena' \ + ! -name 'template' ! -name 'includes' ! -name 'copier.yaml' \ + ! -name 'hack' ! -name 'tests' ! -name 'pytest.ini' \ + ! -name 'flake.lock' \ + -exec rm -rf {} + # Regenerate from template copier copy --vcs-ref=HEAD --trust --defaults \ - --data-file includes/copier-answers-sample.yml -f . . + --data-file includes/copier-answers-sample.yml -f . . # Restore repo-specific files (copier copy overwrites extension sections) git show HEAD:lib/nix/project.nix > lib/nix/project.nix 2>/dev/null || true git show HEAD:justfile > justfile 2>/dev/null || true diff --git a/lib/just/base.just b/lib/just/base.just index 52962bd..a04aea8 100644 --- a/lib/just/base.just +++ b/lib/just/base.just @@ -11,13 +11,18 @@ install: [ -d .git ] || git init git add . if [ ! -f flake.lock ]; then - nix flake update && git add flake.lock + nix flake update && git add flake.lock fi - # prek and cog are devshell tools — use nix develop + # Commit template files before installing hooks (hooks block main-branch commits) + if ! git diff --cached --quiet 2>/dev/null; then + nix develop -c cog commit chore "initial commit, template applied" copier + fi + # Install hooks after the initial commit nix develop -c prek install - git add . - nix develop -c cog commit chore "initial commit, template applied" --scope copier \ - || echo "Nothing to commit" + +# Update from upstream template +update: + copier update --trust --answers-file .copier-answers.yaml # Run all linters lint: @@ -37,7 +42,7 @@ clean: # Format code fmt: - nix fmt + nix fmt -- . # Install git hooks hooks-install: diff --git a/lib/nix/base.nix b/lib/nix/base.nix index 89077a8..69e0d24 100644 --- a/lib/nix/base.nix +++ b/lib/nix/base.nix @@ -5,50 +5,6 @@ config, ... }: -let - # TODO: Remove this override once nixpkgs updates prek past v0.3.2 - # prek.toml support was added in v0.3.2; nixpkgs-unstable is at v0.3.0 - # Track: https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/by-name/pr/prek/package.nix - prek = - let - version = "0.3.4"; - sources = { - x86_64-linux = pkgs.fetchurl { - url = "https://github.com/j178/prek/releases/download/v${version}/prek-x86_64-unknown-linux-gnu.tar.gz"; - hash = "sha256-qoY+dBMaw1SAqLVQ65+wkmI2tQSeXv1tPTbcVz16+zg="; - }; - aarch64-linux = pkgs.fetchurl { - url = "https://github.com/j178/prek/releases/download/v${version}/prek-aarch64-unknown-linux-gnu.tar.gz"; - hash = "sha256-WnoAAAI1xpAVY9qSH9jMZZfzLZVgdT+CsBD93q10EWY="; - }; - x86_64-darwin = pkgs.fetchurl { - url = "https://github.com/j178/prek/releases/download/v${version}/prek-x86_64-apple-darwin.tar.gz"; - hash = "sha256-YKjQkwErzUDo/sNIRQ112PWwnYVSnxH8XWBrhCc7eXg="; - }; - aarch64-darwin = pkgs.fetchurl { - url = "https://github.com/j178/prek/releases/download/v${version}/prek-aarch64-apple-darwin.tar.gz"; - hash = "sha256-8KQWE56vh1PetgOZMxQHejkWCZ0j3mcocIaiE0kVHvA="; - }; - }; - in - pkgs.stdenv.mkDerivation { - pname = "prek"; - inherit version; - src = sources.${pkgs.stdenv.hostPlatform.system}; - sourceRoot = "."; - nativeBuildInputs = lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.autoPatchelfHook ]; - buildInputs = lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.stdenv.cc.cc.lib ]; - installPhase = '' - install -Dm755 */prek $out/bin/prek - ''; - meta = { - description = "Better pre-commit, re-engineered in Rust"; - homepage = "https://github.com/j178/prek"; - license = lib.licenses.mit; - platforms = builtins.attrNames sources; - }; - }; -in { options = { devPkgs = lib.mkOption { @@ -94,7 +50,7 @@ in pkgs.git pkgs.gnugrep pkgs.just - prek + pkgs.prek ]; }; } diff --git a/prek.toml b/prek.toml index ee551b6..b75274f 100644 --- a/prek.toml +++ b/prek.toml @@ -13,6 +13,7 @@ priority = 0 [[repos.hooks]] id = "end-of-file-fixer" +exclude = '\.jinja$' priority = 0 [[repos.hooks]] @@ -144,14 +145,6 @@ id = "check-renovate" name = "Validate Renovate config" priority = 0 -[[repos.hooks]] -id = "check-jsonschema" -name = "Validate cog.toml schema" -args = ["--schemafile", "https://docs.cocogitto.io/cog-schema.json"] -files = '^cog\.toml$' -types = ["toml"] -priority = 0 - [[repos]] repo = "https://github.com/gitleaks/gitleaks" rev = "v8.30.0" diff --git a/template/.editorconfig.jinja b/template/.editorconfig.jinja index 5ad5524..269dd81 100644 --- a/template/.editorconfig.jinja +++ b/template/.editorconfig.jinja @@ -25,5 +25,8 @@ max_line_length = off [*.json] max_line_length = off +[*.jinja] +max_line_length = off + [{*.xml,*.iml}] max_line_length = off diff --git a/template/.github/actions/nix-setup/action.yaml b/template/.github/actions/nix-setup/action.yaml index 2edc251..6de0e03 100644 --- a/template/.github/actions/nix-setup/action.yaml +++ b/template/.github/actions/nix-setup/action.yaml @@ -11,7 +11,12 @@ inputs: runs: using: composite steps: - - uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17 + - uses: DeterminateSystems/nix-installer-action@c5a866b6ab867e88becbed4467b93592bce69f8a # v21 with: - extra-conf: ${{ inputs.nix-extra-conf }} - - uses: DeterminateSystems/magic-nix-cache-action@87b14cf437d03d37989d87f0fa5ce4f5dc1a330b # v8 + extra-conf: | + eval-cores = 1 + ${{ inputs.nix-extra-conf }} + diagnostic-endpoint: "" + - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13 + with: + diagnostic-endpoint: "" diff --git a/template/.github/workflows/pr-checks.yaml.jinja b/template/.github/workflows/pr-checks.yaml.jinja index ea0058a..928014e 100644 --- a/template/.github/workflows/pr-checks.yaml.jinja +++ b/template/.github/workflows/pr-checks.yaml.jinja @@ -27,5 +27,4 @@ jobs: run: nix develop -c just test - name: Integration Test run: nix develop -c just test-integration - {% include pathjoin("includes", "ci-consistency-job.jinja") ignore missing %} diff --git a/template/CONTRIBUTING.md b/template/CONTRIBUTING.md index a1a110f..6f8952d 100644 --- a/template/CONTRIBUTING.md +++ b/template/CONTRIBUTING.md @@ -58,7 +58,7 @@ cog bump --major To pull in template updates: - copier update --trust + just update ## Dependency Updates diff --git a/template/README.md.jinja b/template/README.md.jinja index 9954d10..253435d 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -3,26 +3,21 @@ {{ project_description }} ## Getting Started +{% include pathjoin("includes", "readme-template-quickstart.jinja") ignore missing %} -### From template - -```shell -copier copy --trust https://github.com/gordon-code/project-template.git my-project -cd my-project -nix run nixpkgs#just -- install -``` - -### With direnv +### Development ```shell direnv allow +# or: nix develop -c $SHELL ``` -## Development +## Recipes | Recipe | Description | |--------|-------------| | `just install` | Install dependencies and git hooks | +| `just update` | Pull in template updates | | `just lint` | Run all linters | | `just test` | Run tests | | `just fmt` | Format code | diff --git a/template/lib/just/base.just b/template/lib/just/base.just index e7ac651..a04aea8 100644 --- a/template/lib/just/base.just +++ b/template/lib/just/base.just @@ -11,15 +11,18 @@ install: [ -d .git ] || git init git add . if [ ! -f flake.lock ]; then - nix flake update && git add flake.lock + nix flake update && git add flake.lock fi - # prek and cog are devshell tools — use nix develop - nix develop -c prek install - git add -u # stage modifications only (not untracked files) - # Commit if there are staged changes; skip gracefully if nothing to commit + # Commit template files before installing hooks (hooks block main-branch commits) if ! git diff --cached --quiet 2>/dev/null; then - nix develop -c cog commit chore "initial commit, template applied" --scope copier + nix develop -c cog commit chore "initial commit, template applied" copier fi + # Install hooks after the initial commit + nix develop -c prek install + +# Update from upstream template +update: + copier update --trust --answers-file .copier-answers.yaml # Run all linters lint: @@ -39,7 +42,7 @@ clean: # Format code fmt: - nix fmt + nix fmt -- . # Install git hooks hooks-install: diff --git a/template/lib/nix/base.nix b/template/lib/nix/base.nix index 89077a8..69e0d24 100644 --- a/template/lib/nix/base.nix +++ b/template/lib/nix/base.nix @@ -5,50 +5,6 @@ config, ... }: -let - # TODO: Remove this override once nixpkgs updates prek past v0.3.2 - # prek.toml support was added in v0.3.2; nixpkgs-unstable is at v0.3.0 - # Track: https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/by-name/pr/prek/package.nix - prek = - let - version = "0.3.4"; - sources = { - x86_64-linux = pkgs.fetchurl { - url = "https://github.com/j178/prek/releases/download/v${version}/prek-x86_64-unknown-linux-gnu.tar.gz"; - hash = "sha256-qoY+dBMaw1SAqLVQ65+wkmI2tQSeXv1tPTbcVz16+zg="; - }; - aarch64-linux = pkgs.fetchurl { - url = "https://github.com/j178/prek/releases/download/v${version}/prek-aarch64-unknown-linux-gnu.tar.gz"; - hash = "sha256-WnoAAAI1xpAVY9qSH9jMZZfzLZVgdT+CsBD93q10EWY="; - }; - x86_64-darwin = pkgs.fetchurl { - url = "https://github.com/j178/prek/releases/download/v${version}/prek-x86_64-apple-darwin.tar.gz"; - hash = "sha256-YKjQkwErzUDo/sNIRQ112PWwnYVSnxH8XWBrhCc7eXg="; - }; - aarch64-darwin = pkgs.fetchurl { - url = "https://github.com/j178/prek/releases/download/v${version}/prek-aarch64-apple-darwin.tar.gz"; - hash = "sha256-8KQWE56vh1PetgOZMxQHejkWCZ0j3mcocIaiE0kVHvA="; - }; - }; - in - pkgs.stdenv.mkDerivation { - pname = "prek"; - inherit version; - src = sources.${pkgs.stdenv.hostPlatform.system}; - sourceRoot = "."; - nativeBuildInputs = lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.autoPatchelfHook ]; - buildInputs = lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.stdenv.cc.cc.lib ]; - installPhase = '' - install -Dm755 */prek $out/bin/prek - ''; - meta = { - description = "Better pre-commit, re-engineered in Rust"; - homepage = "https://github.com/j178/prek"; - license = lib.licenses.mit; - platforms = builtins.attrNames sources; - }; - }; -in { options = { devPkgs = lib.mkOption { @@ -94,7 +50,7 @@ in pkgs.git pkgs.gnugrep pkgs.just - prek + pkgs.prek ]; }; } diff --git a/template/prek.toml.jinja b/template/prek.toml.jinja index bb8531a..96e58da 100644 --- a/template/prek.toml.jinja +++ b/template/prek.toml.jinja @@ -13,6 +13,7 @@ priority = 0 [[repos.hooks]] id = "end-of-file-fixer" +exclude = '\.jinja$' priority = 0 [[repos.hooks]] @@ -148,14 +149,6 @@ name = "Validate Renovate config" priority = 0 {% endif %} -[[repos.hooks]] -id = "check-jsonschema" -name = "Validate cog.toml schema" -args = ["--schemafile", "https://docs.cocogitto.io/cog-schema.json"] -files = '^cog\.toml$' -types = ["toml"] -priority = 0 - [[repos]] repo = "https://github.com/gitleaks/gitleaks" rev = "v8.30.0" diff --git a/tests/conftest.py b/tests/conftest.py index 80b3371..0e216f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """Copier template test fixtures.""" + import json import os import shutil @@ -25,6 +26,14 @@ TEMPLATE_ROOT = Path(__file__).parent.parent +DEFAULT_ANSWERS = { + "project_name": "test-project", + "hosting_platform": "github", + "hosting_org": "test-org", + "project_description": "A test project", + "repo_url": "", +} + def _get_env(): """Build environment with git isolation vars.""" @@ -45,7 +54,7 @@ def _prepare_template_source(dest): env = _get_env() subprocess.run(["git", "init", "-b", "main"], cwd=dest, env=env, check=True, capture_output=True) subprocess.run(["git", "add", "."], cwd=dest, env=env, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=dest, env=env, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "chore: init"], cwd=dest, env=env, check=True, capture_output=True) return dest @@ -65,6 +74,21 @@ def _run_copier(src, out, answers): return out +def _init_generated_project(project_path): + """Initialize git and run nix flake update in a generated project.""" + env = _get_env() + subprocess.run(["git", "init", "-b", "main"], cwd=project_path, env=env, check=True, capture_output=True) + subprocess.run(["git", "add", "."], cwd=project_path, env=env, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "chore: init"], cwd=project_path, env=env, check=True, capture_output=True) + subprocess.run(["nix", "flake", "update"], cwd=project_path, check=True, capture_output=True, timeout=300) + subprocess.run(["git", "add", "flake.lock"], cwd=project_path, env=env, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "chore: lock flake inputs"], + cwd=project_path, env=env, check=True, capture_output=True, + ) + return project_path + + @pytest.fixture(scope="session") def template_src(tmp_path_factory): """Session-scoped: prepared template source (copy + git init). Shared by all tests.""" @@ -75,13 +99,7 @@ def template_src(tmp_path_factory): @pytest.fixture def default_answers(): """Default copier answers for test generation.""" - return { - "project_name": "test-project", - "hosting_platform": "github", - "hosting_org": "test-org", - "project_description": "A test project", - "repo_url": "", - } + return dict(DEFAULT_ANSWERS) @pytest.fixture @@ -90,10 +108,11 @@ def generate(tmp_path, template_src, default_answers): Returns a callable that accepts **answers overrides and returns the output Path. Uses session-scoped template_src to avoid re-copying the template on every test. + Pass _skip_defaults=True to omit default answers (for derivation testing). """ - def _generate(**answers): - merged = {**default_answers, **answers} + def _generate(_skip_defaults=False, **answers): + merged = answers if _skip_defaults else {**default_answers, **answers} project_name = merged.get("project_name", "test-project") out = tmp_path / project_name out.mkdir(parents=True, exist_ok=True) @@ -102,54 +121,31 @@ def _generate(**answers): return _generate -@pytest.fixture(scope="session", params=PLATFORMS) -def generate_with_nix(tmp_path_factory, template_src, request): - """Session-scoped: generate + nix flake update. READ-ONLY tests only.""" - platform = request.param - base_tmp = tmp_path_factory.mktemp(f"nix-{platform}") - - answers = { - "project_name": "test-project", - "hosting_platform": platform, - "hosting_org": "test-org", - "project_description": "A test project", - "repo_url": "", - } +@pytest.fixture(scope="session") +def generate_with_nix(tmp_path_factory, template_src): + """Session-scoped: generate + nix flake update. READ-ONLY tests only. + Uses github platform only — nix tests are platform-agnostic. + """ + base_tmp = tmp_path_factory.mktemp("nix-github") out = base_tmp / "test-project" out.mkdir(parents=True, exist_ok=True) - _run_copier(template_src, out, answers) - - # Git init + nix flake update in the generated project - env = _get_env() - subprocess.run(["git", "init", "-b", "main"], cwd=out, env=env, check=True, capture_output=True) - subprocess.run(["git", "add", "."], cwd=out, env=env, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=out, env=env, check=True, capture_output=True) - subprocess.run(["nix", "flake", "update"], cwd=out, check=True, capture_output=True, timeout=300) - subprocess.run(["git", "add", "flake.lock"], cwd=out, env=env, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "lock"], cwd=out, env=env, check=True, capture_output=True) - + _run_copier(template_src, out, DEFAULT_ANSWERS) + _init_generated_project(out) return out @pytest.fixture -def generate_with_nix_mutable(tmp_path, template_src, default_answers): +def generate_with_nix_mutable(tmp_path, template_src): """Function-scoped: generate + nix flake update. For mutation tests.""" def _generate(**answers): - merged = {**default_answers, **answers} + merged = {**DEFAULT_ANSWERS, **answers} project_name = merged.get("project_name", "test-project") out = tmp_path / project_name out.mkdir(parents=True, exist_ok=True) _run_copier(template_src, out, merged) - - env = _get_env() - subprocess.run(["git", "init", "-b", "main"], cwd=out, env=env, check=True, capture_output=True) - subprocess.run(["git", "add", "."], cwd=out, env=env, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=out, env=env, check=True, capture_output=True) - subprocess.run(["nix", "flake", "update"], cwd=out, check=True, capture_output=True, timeout=300) - subprocess.run(["git", "add", "flake.lock"], cwd=out, env=env, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "lock"], cwd=out, env=env, check=True, capture_output=True) + _init_generated_project(out) return out return _generate @@ -163,15 +159,28 @@ def generated_project(tmp_path_factory, template_src, request): """ platform = request.param base_tmp = tmp_path_factory.mktemp(f"gen-{platform}") + answers = {**DEFAULT_ANSWERS, "hosting_platform": platform} + out = base_tmp / "test-project" + out.mkdir(parents=True, exist_ok=True) + _run_copier(template_src, out, answers) + return out - answers = { - "project_name": "test-project", - "hosting_platform": platform, - "hosting_org": "test-org", - "project_description": "A test project", - "repo_url": "", - } +@pytest.fixture(scope="session") +def generated_github_project(tmp_path_factory, template_src): + """Session-scoped: default github project. Shared by github-only read tests.""" + base_tmp = tmp_path_factory.mktemp("gen-github-default") + out = base_tmp / "test-project" + out.mkdir(parents=True, exist_ok=True) + _run_copier(template_src, out, DEFAULT_ANSWERS) + return out + + +@pytest.fixture(scope="session") +def generated_template_project(tmp_path_factory, template_src): + """Session-scoped: _is_template=True github project. For template-repo tests.""" + base_tmp = tmp_path_factory.mktemp("gen-template") + answers = {**DEFAULT_ANSWERS, "_is_template": "True"} out = base_tmp / "test-project" out.mkdir(parents=True, exist_ok=True) _run_copier(template_src, out, answers) diff --git a/tests/test_nix.py b/tests/test_nix.py index 79a6f46..e3c8580 100644 --- a/tests/test_nix.py +++ b/tests/test_nix.py @@ -1,7 +1,5 @@ """Nix layer tests (require nix, session-scoped for performance).""" -import pytest - from conftest import check_file_contents, run_in_project @@ -26,12 +24,24 @@ def test_flake_check(generate_with_nix): assert result.returncode == 0, f"nix flake check failed:\n{result.stderr}" -def test_flake_show(generate_with_nix): - """nix flake show outputs devShells and formatter.""" - result = run_in_project(generate_with_nix, ["nix", "flake", "show"], check=False) - assert result.returncode == 0, f"nix flake show failed:\n{result.stderr}" - assert "devShells" in result.stdout - assert "formatter" in result.stdout +def test_flake_outputs(generate_with_nix): + """Flake exposes devShells and formatter outputs. + + Uses nix eval instead of nix flake show — flake show evaluates ALL platforms + and crashes with SIGSEGV on cross-platform rustPlatform derivations (nix bug). + nix flake check (test_flake_check) validates current-system evaluation. + """ + import json + + for output in ("devShells", "formatter"): + result = run_in_project( + generate_with_nix, + ["nix", "eval", f".#{output}", "--apply", "builtins.attrNames", "--json"], + check=False, + ) + assert result.returncode == 0, f"nix eval {output} failed:\n{result.stderr}" + systems = json.loads(result.stdout) + assert len(systems) >= 1, f"No systems in {output}" def test_nix_fmt_clean(generate_with_nix): @@ -48,7 +58,7 @@ def test_justfile_valid(generate_with_nix): generate_with_nix, ["nix", "develop", "-c", "just", "--list"], check=False ) assert result.returncode == 0, f"just --list failed:\n{result.stderr}" - for recipe in ("install", "lint", "test", "fmt", "clean", "hooks-install"): + for recipe in ("install", "lint", "test", "fmt", "clean", "hooks-install", "update"): assert recipe in result.stdout, f"Recipe '{recipe}' missing from just --list" @@ -71,21 +81,21 @@ def test_just_install(generate_with_nix_mutable): assert (project / ".git" / "hooks" / "pre-commit").exists() -@pytest.mark.xfail(reason="pr-checks.yaml trailing newline + base.just indent + no-commit-to-branch on main") def test_prek_lint_clean(generate_with_nix_mutable): """Generated project passes its own lint checks.""" project = generate_with_nix_mutable() - # Install hooks first so prek config is set up + # Install hooks run_in_project(project, ["nix", "develop", "-c", "prek", "install"], check=False) + # Switch to feature branch so no-commit-to-branch hook passes + run_in_project(project, ["git", "checkout", "-b", "test/lint"]) result = run_in_project( project, ["nix", "develop", "-c", "just", "lint"], check=False ) assert result.returncode == 0, f"just lint failed:\n{result.stderr}\n{result.stdout}" -@pytest.mark.xfail(reason="fixture commits use non-conventional messages ('init', 'lock')") def test_cog_verify(generate_with_nix_mutable): - """cog verify passes on the initial commit.""" + """cog check passes on the initial commits.""" project = generate_with_nix_mutable() result = run_in_project( project, ["nix", "develop", "-c", "cog", "check"], check=False diff --git a/tests/test_project.sh b/tests/test_project.sh index 6481b25..4f25460 100755 --- a/tests/test_project.sh +++ b/tests/test_project.sh @@ -9,7 +9,9 @@ TMPDIR="$(mktemp -d)" cleanup() { rm -rf "$TMPDIR"; } trap cleanup EXIT INT TERM -# Git identity +# Isolate from host git config (ensures parity with CI) +export GIT_CONFIG_NOSYSTEM=1 +export HOME="$TMPDIR" export GIT_AUTHOR_NAME="Test User" export GIT_COMMITTER_NAME="Test User" export GIT_AUTHOR_EMAIL="test@example.com" @@ -31,24 +33,29 @@ copier copy --trust --vcs-ref HEAD --defaults \ "$TEMPLATE_DIR" "$TMPDIR/test-project" cd "$TMPDIR/test-project" -git init -b main && git add . && nix flake update && git add flake.lock +git init -b main +git config user.name "Test User" +git config user.email "test@example.com" +git add . && nix flake update && git add flake.lock nix develop -c just install pass "Generate + Install" echo "=== Phase 2: File structure ===" for f in flake.nix flake.lock justfile prek.toml cog.toml \ - lib/nix/base.nix lib/nix/project.nix lib/just/base.just \ - .copier-answers.yaml .editorconfig .gitignore .envrc \ - LICENSE README.md CONTRIBUTING.md; do + lib/nix/base.nix lib/nix/project.nix lib/just/base.just \ + .copier-answers.yaml .editorconfig .gitignore .envrc \ + LICENSE README.md CONTRIBUTING.md; do [ -f "$f" ] || fail "Missing: $f" done pass "Core files present" if [ "$HOSTING_PLATFORM" = "github" ]; then - for f in .github/workflows/pr-checks.yaml .github/workflows/renovate.yaml \ - .github/renovate.json .github/actions/nix-setup/action.yaml \ - .github/SECURITY.md; do + for f in .github/workflows/pr-checks.yaml \ + .github/workflows/renovate.yaml \ + .github/renovate.json \ + .github/actions/nix-setup/action.yaml \ + .github/SECURITY.md; do [ -f "$f" ] || fail "Missing GitHub file: $f" done pass "GitHub files present" diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 461992d..4c1d96c 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -30,7 +30,7 @@ def test_readme_content(generated_project): assert "A test project" in content -# --- URL derivation (needs custom answers, uses generate) --- +# --- URL derivation (truly tests regex derivation) --- @pytest.mark.parametrize( @@ -53,12 +53,11 @@ def test_readme_content(generated_project): def test_repo_url_derivation( generate, repo_url, expected_platform, expected_org, expected_name ): - """Copier answers reflect expected values for different repo URLs.""" + """Copier derives project_name, hosting_platform, hosting_org from repo_url.""" project = generate( + _skip_defaults=True, repo_url=repo_url, - project_name=expected_name, - hosting_platform=expected_platform, - hosting_org=expected_org, + project_description="A test project", ) answers = parse_yaml(project / ".copier-answers.yaml") assert answers["project_name"] == expected_name @@ -83,11 +82,10 @@ def test_empty_url_uses_explicit(generate): # --- Default project structure (session-scoped github) --- -def test_default_project_structure(generate): +def test_default_project_structure(generated_github_project): """Default github project has all expected files.""" - project = generate() assert_files_present( - project, + generated_github_project, "flake.nix", "lib/nix/base.nix", "lib/nix/project.nix", @@ -106,26 +104,23 @@ def test_default_project_structure(generate): ".idea/test-project.iml", ) assert_files_absent( - project, "copier.yaml", "template", "includes", "hack", "tests", "pytest.ini" + generated_github_project, "copier.yaml", "template", "includes", "hack", "tests", "pytest.ini" ) -def test_justfile_imports_base(generate): +def test_justfile_imports_base(generated_github_project): """justfile imports lib/just/base.just.""" - project = generate() - assert "import 'lib/just/base.just'" in (project / "justfile").read_text() + assert "import 'lib/just/base.just'" in (generated_github_project / "justfile").read_text() -def test_envrc_content(generate): +def test_envrc_content(generated_github_project): """`.envrc` contains `use flake`.""" - project = generate() - assert "use flake" in (project / ".envrc").read_text() + assert "use flake" in (generated_github_project / ".envrc").read_text() -def test_copier_answers_valid_yaml(generate): +def test_copier_answers_valid_yaml(generated_github_project): """.copier-answers.yaml is valid YAML with expected keys.""" - project = generate() - answers = parse_yaml(project / ".copier-answers.yaml") + answers = parse_yaml(generated_github_project / ".copier-answers.yaml") assert "project_name" in answers assert "hosting_platform" in answers @@ -143,17 +138,15 @@ def test_cog_valid_toml(generated_project): parse_toml(generated_project / "cog.toml") -def test_cog_branch(generate): +def test_cog_branch(generated_github_project): """cog.toml branch_whitelist contains main.""" - project = generate() - data = parse_toml(project / "cog.toml") + data = parse_toml(generated_github_project / "cog.toml") assert "main" in data["branch_whitelist"] -def test_prek_github_hooks(generate): +def test_prek_github_hooks(generated_github_project): """GitHub platform includes actionlint hook.""" - project = generate(hosting_platform="github") - assert "actionlint" in (project / "prek.toml").read_text() + assert "actionlint" in (generated_github_project / "prek.toml").read_text() @pytest.mark.parametrize("hosting_platform", ["gitlab", "other"]) @@ -163,51 +156,51 @@ def test_prek_no_github_hooks(generate, hosting_platform): assert "actionlint" not in (project / "prek.toml").read_text() -def test_editorconfig_content(generate): +def test_editorconfig_content(generated_github_project): """.editorconfig has expected settings.""" - project = generate() - content = (project / ".editorconfig").read_text() + content = (generated_github_project / ".editorconfig").read_text() assert "root = true" in content assert "end_of_line = lf" in content assert "indent_style = tab" in content -def test_gitignore_content(generate): +def test_gitignore_content(generated_github_project): """.gitignore has expected entries.""" - project = generate() - content = (project / ".gitignore").read_text() + content = (generated_github_project / ".gitignore").read_text() assert ".direnv/" in content assert "result/" in content def test_readme_sections(generated_project): - """README has Getting Started and Development sections.""" + """README has Getting Started and Recipes sections.""" content = (generated_project / "README.md").read_text() assert "Getting Started" in content - assert "Development" in content + assert "Recipes" in content -def test_license_content(generate): +def test_license_content(generated_github_project): """LICENSE has MIT license with hosting_org.""" - project = generate() - content = (project / "LICENSE").read_text() + content = (generated_github_project / "LICENSE").read_text() assert "test-org" in content assert "MIT License" in content -def test_contributing_content(generate): +def test_contributing_content(generated_github_project): """CONTRIBUTING.md references Conventional Commits.""" - project = generate() - assert "Conventional Commits" in (project / "CONTRIBUTING.md").read_text() + assert "Conventional Commits" in (generated_github_project / "CONTRIBUTING.md").read_text() + + +def test_contributing_update_recipe(generated_github_project): + """CONTRIBUTING.md documents just update for template updates.""" + assert "just update" in (generated_github_project / "CONTRIBUTING.md").read_text() # --- GitHub integration --- -def test_nix_setup_action(generate): +def test_nix_setup_action(generated_github_project): """GitHub projects have SHA-pinned nix-setup composite action.""" - project = generate(hosting_platform="github") - action = project / ".github" / "actions" / "nix-setup" / "action.yaml" + action = generated_github_project / ".github" / "actions" / "nix-setup" / "action.yaml" assert action.exists() content = action.read_text() assert "composite" in content @@ -219,57 +212,52 @@ def test_github_files_absent(generate, hosting_platform): """Non-GitHub platforms have no .github/ files.""" project = generate(hosting_platform=hosting_platform) github_dir = project / ".github" - files = list(github_dir.rglob("*")) if github_dir.exists() else [] - assert not files, f"Unexpected .github/ files for {hosting_platform}: {files}" + if github_dir.exists(): + files = list(github_dir.rglob("*")) + assert not files, f"Unexpected .github/ files for {hosting_platform}: {files}" -def test_pr_checks_valid_yaml(generate): +def test_pr_checks_valid_yaml(generated_github_project): """pr-checks.yaml is valid YAML with expected structure.""" - project = generate(hosting_platform="github") - data = parse_yaml(project / ".github" / "workflows" / "pr-checks.yaml") + data = parse_yaml(generated_github_project / ".github" / "workflows" / "pr-checks.yaml") assert "jobs" in data assert "checks" in data["jobs"] -def test_pr_checks_has_steps(generate): +def test_pr_checks_has_steps(generated_github_project): """pr-checks has lint, test, and integration test steps.""" - project = generate(hosting_platform="github") - content = (project / ".github" / "workflows" / "pr-checks.yaml").read_text() + content = (generated_github_project / ".github" / "workflows" / "pr-checks.yaml").read_text() assert "just lint" in content assert "just test" in content assert "just test-integration" in content -def test_pr_checks_pinned_actions(generate): +def test_pr_checks_pinned_actions(generated_github_project): """All uses: refs in pr-checks are SHA-pinned.""" - project = generate(hosting_platform="github") - content = (project / ".github" / "workflows" / "pr-checks.yaml").read_text() + content = (generated_github_project / ".github" / "workflows" / "pr-checks.yaml").read_text() for line in content.splitlines(): if "uses:" in line and "@" in line: ref = line.split("@")[1].split()[0] assert len(ref) >= 40 or ref.startswith("./"), f"Unpinned action: {line.strip()}" -def test_renovate_config_valid_json(generate): +def test_renovate_config_valid_json(generated_github_project): """renovate.json is valid JSON with expected keys.""" - project = generate(hosting_platform="github") - data = parse_json(project / ".github" / "renovate.json") + data = parse_json(generated_github_project / ".github" / "renovate.json") assert "extends" in data assert "customManagers" in data -def test_renovate_no_template_config(generate): +def test_renovate_no_template_config(generated_github_project): """Default projects don't have template-specific Renovate managers.""" - project = generate(hosting_platform="github") - content = (project / ".github" / "renovate.json").read_text() + content = (generated_github_project / ".github" / "renovate.json").read_text() assert "postUpgradeTasks" not in content assert "template/" not in content -def test_security_md(generate): +def test_security_md(generated_github_project): """GitHub projects have SECURITY.md with project name.""" - project = generate(hosting_platform="github") - security = project / ".github" / "SECURITY.md" + security = generated_github_project / ".github" / "SECURITY.md" assert security.exists() check_file_contents(security, expected=("test-project",)) @@ -284,29 +272,60 @@ def test_security_md_absent(generate, hosting_platform): # --- _is_template conditional --- -def test_renovate_has_template_config(generate): +def test_renovate_has_template_config(generated_template_project): """Template repo (_is_template=True) has template-specific managers.""" - project = generate(hosting_platform="github", _is_template=True) - data = parse_json(project / ".github" / "renovate.json") + data = parse_json(generated_template_project / ".github" / "renovate.json") managers = data.get("customManagers", []) template_managers = [m for m in managers if any("template/" in f for f in m.get("fileMatch", []))] assert template_managers, "No template-specific customManagers found" -def test_no_consistency_job_default(generate): +def test_no_consistency_job_default(generated_github_project): """Default projects have no consistency job in pr-checks.""" - project = generate(hosting_platform="github") - assert "consistency" not in (project / ".github" / "workflows" / "pr-checks.yaml").read_text() + assert "consistency" not in (generated_github_project / ".github" / "workflows" / "pr-checks.yaml").read_text() -def test_has_consistency_job_template(generate): +def test_has_consistency_job_template(generated_template_project): """Template repo (_is_template=True) has consistency job.""" - project = generate(hosting_platform="github", _is_template=True) - assert "consistency" in (project / ".github" / "workflows" / "pr-checks.yaml").read_text() + assert "consistency" in (generated_template_project / ".github" / "workflows" / "pr-checks.yaml").read_text() -def test_idea_xml_valid(generate): +def test_idea_xml_valid(generated_github_project): """JetBrains modules.xml is valid XML.""" import xml.etree.ElementTree as ET - project = generate() - ET.parse(project / ".idea" / "modules.xml") + ET.parse(generated_github_project / ".idea" / "modules.xml") + + +def test_base_just_has_update(generated_github_project): + """base.just includes the update recipe.""" + content = (generated_github_project / "lib" / "just" / "base.just").read_text() + assert "update:" in content + assert "--answers-file" in content + + +def test_readme_no_template_quickstart(generated_github_project): + """Default projects don't show template quickstart in README.""" + content = (generated_github_project / "README.md").read_text() + assert "From template" not in content + assert "gordon-code/project-template" not in content + + +def test_readme_template_quickstart(generated_template_project): + """Template repo README shows quickstart for creating new projects.""" + content = (generated_template_project / "README.md").read_text() + assert "From template" in content + assert "copier copy" in content + + +def test_base_nix_uses_pkgs_prek(generated_github_project): + """base.nix uses pkgs.prek directly (no binary-fetch overlay).""" + content = (generated_github_project / "lib" / "nix" / "base.nix").read_text() + assert "pkgs.prek" in content + assert "fetchurl" not in content + assert "TODO" not in content + + +def test_nix_setup_no_telemetry(generated_github_project): + """nix-setup action disables Determinate Systems telemetry.""" + content = (generated_github_project / ".github" / "actions" / "nix-setup" / "action.yaml").read_text() + assert 'diagnostic-endpoint: ""' in content diff --git a/tests/test_update.py b/tests/test_update.py index eb654c0..7ed6a65 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -24,7 +24,7 @@ def template_repo(tmp_path): ) subprocess.run(["git", "init", "-b", "main"], cwd=src, env=_ENV, check=True, capture_output=True) subprocess.run(["git", "add", "."], cwd=src, env=_ENV, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=src, env=_ENV, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "chore: init"], cwd=src, env=_ENV, check=True, capture_output=True) subprocess.run(["git", "tag", "-a", "v1", "-m", "v1"], cwd=src, env=_ENV, check=True, capture_output=True) return src @@ -38,11 +38,13 @@ def _generate_from(template_repo, out, **answers): subprocess.run(cmd, env=_ENV, check=True, capture_output=True, text=True) subprocess.run(["git", "init", "-b", "main"], cwd=out, env=_ENV, check=True, capture_output=True) subprocess.run(["git", "add", "."], cwd=out, env=_ENV, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "initial"], cwd=out, env=_ENV, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "chore: init from template"], + cwd=out, env=_ENV, check=True, capture_output=True, + ) return out -@pytest.mark.xfail(reason="copier update looks for .copier-answers.yml not .yaml — needs investigation") def test_copier_update_applies_cleanly(template_repo, tmp_path): """copier update from v1 to v2 applies without conflicts.""" project = tmp_path / "test-project" @@ -58,12 +60,15 @@ def test_copier_update_applies_cleanly(template_repo, tmp_path): content = contributing.read_text() contributing.write_text(content + "\n## Template Update Test\n\nThis line was added in v2.\n") subprocess.run(["git", "add", "."], cwd=template_repo, env=_ENV, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "v2 change"], cwd=template_repo, env=_ENV, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "feat: v2 change"], + cwd=template_repo, env=_ENV, check=True, capture_output=True, + ) subprocess.run(["git", "tag", "-a", "v2", "-m", "v2"], cwd=template_repo, env=_ENV, check=True, capture_output=True) # Run copier update result = subprocess.run( - ["copier", "update", "--trust", "--defaults"], + ["copier", "update", "--trust", "--defaults", "--answers-file", ".copier-answers.yaml"], cwd=project, env=_ENV, capture_output=True, text=True, ) assert result.returncode == 0, f"copier update failed:\n{result.stderr}" @@ -74,7 +79,6 @@ def test_copier_update_applies_cleanly(template_repo, tmp_path): assert "Template Update Test" in updated, "v2 change not present" -@pytest.mark.xfail(reason="copier update looks for .copier-answers.yml not .yaml — needs investigation") def test_copier_update_preserves_project_nix(template_repo, tmp_path): """copier update honors _skip_if_exists for project.nix.""" project = tmp_path / "test-project" @@ -89,26 +93,29 @@ def test_copier_update_preserves_project_nix(template_repo, tmp_path): project_nix = project / "lib" / "nix" / "project.nix" project_nix.write_text('{ lib, pkgs, ... }: { devPkgs = lib.mkAfter [ pkgs.hello ]; }\n') subprocess.run(["git", "add", "."], cwd=project, env=_ENV, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "customize"], cwd=project, env=_ENV, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "feat: customize project.nix"], + cwd=project, env=_ENV, check=True, capture_output=True, + ) # Tag v2 in template (trivial change) readme = template_repo / "template" / "README.md.jinja" readme.write_text(readme.read_text() + "\n\n") subprocess.run(["git", "add", "."], cwd=template_repo, env=_ENV, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "v2"], cwd=template_repo, env=_ENV, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "chore: v2"], cwd=template_repo, env=_ENV, check=True, capture_output=True) subprocess.run(["git", "tag", "-a", "v2", "-m", "v2"], cwd=template_repo, env=_ENV, check=True, capture_output=True) # Update - subprocess.run( - ["copier", "update", "--trust", "--defaults"], - cwd=project, env=_ENV, check=True, capture_output=True, text=True, + result = subprocess.run( + ["copier", "update", "--trust", "--defaults", "--answers-file", ".copier-answers.yaml"], + cwd=project, env=_ENV, capture_output=True, text=True, ) + assert result.returncode == 0, f"copier update failed:\n{result.stderr}" # project.nix should still have our customization assert "pkgs.hello" in project_nix.read_text(), "project.nix customization was overwritten" -@pytest.mark.xfail(reason="copier update looks for .copier-answers.yml not .yaml — needs investigation") def test_copier_update_preserves_justfile_extensions(template_repo, tmp_path): """copier update preserves user recipes in justfile extension section.""" project = tmp_path / "test-project" @@ -123,20 +130,24 @@ def test_copier_update_preserves_justfile_extensions(template_repo, tmp_path): justfile = project / "justfile" justfile.write_text(justfile.read_text() + "\n# My custom recipe\nmy-recipe:\n\t@echo custom\n") subprocess.run(["git", "add", "."], cwd=project, env=_ENV, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "add recipe"], cwd=project, env=_ENV, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "feat: add custom recipe"], + cwd=project, env=_ENV, check=True, capture_output=True, + ) # Tag v2 readme = template_repo / "template" / "README.md.jinja" readme.write_text(readme.read_text() + "\n\n") subprocess.run(["git", "add", "."], cwd=template_repo, env=_ENV, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", "v2"], cwd=template_repo, env=_ENV, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "chore: v2"], cwd=template_repo, env=_ENV, check=True, capture_output=True) subprocess.run(["git", "tag", "-a", "v2", "-m", "v2"], cwd=template_repo, env=_ENV, check=True, capture_output=True) # Update - subprocess.run( - ["copier", "update", "--trust", "--defaults"], - cwd=project, env=_ENV, check=True, capture_output=True, text=True, + result = subprocess.run( + ["copier", "update", "--trust", "--defaults", "--answers-file", ".copier-answers.yaml"], + cwd=project, env=_ENV, capture_output=True, text=True, ) + assert result.returncode == 0, f"copier update failed:\n{result.stderr}" # User's recipe should survive the 3-way merge assert "my-recipe" in justfile.read_text(), "Custom justfile recipe was lost"