create-forge 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {create_forge-0.3.1 → create_forge-0.3.2}/CHANGELOG.md +6 -1
  2. {create_forge-0.3.1 → create_forge-0.3.2}/PKG-INFO +3 -2
  3. {create_forge-0.3.1 → create_forge-0.3.2}/docs/adr/README.md +1 -0
  4. {create_forge-0.3.1 → create_forge-0.3.2}/pyproject.toml +13 -6
  5. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/cli.py +142 -7
  6. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/runner.py +152 -2
  7. {create_forge-0.3.1 → create_forge-0.3.2}/tests/installed_client.py +1 -1
  8. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_cli.py +53 -0
  9. create_forge-0.3.2/tests/test_copier_cache.py +130 -0
  10. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_e2e_installed_data_science.py +3 -3
  11. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_engine_contract.py +15 -13
  12. create_forge-0.3.2/tests/test_runner.py +325 -0
  13. create_forge-0.3.1/tests/test_runner.py +0 -164
  14. {create_forge-0.3.1 → create_forge-0.3.2}/.gitignore +0 -0
  15. {create_forge-0.3.1 → create_forge-0.3.2}/LICENSE +0 -0
  16. {create_forge-0.3.1 → create_forge-0.3.2}/README.md +0 -0
  17. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/README.md +0 -0
  18. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/00-governance-and-principles/README.md +0 -0
  19. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/01-python-core/README.md +0 -0
  20. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/02-developer-experience/README.md +0 -0
  21. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/03-quality-and-ci/README.md +0 -0
  22. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/04-runtime-and-configuration/README.md +0 -0
  23. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/05-security-and-supply-chain/README.md +0 -0
  24. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/06-extension-and-composition-contract/README.md +0 -0
  25. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/07-forge-cli-integration/README.md +0 -0
  26. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/08-reference-archetype-validation/README.md +0 -0
  27. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/09-blueprint-compatibility/README.md +0 -0
  28. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/README.md +0 -0
  29. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/10-data-science-architecture-contract/README.md +0 -0
  30. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/11-reusable-data-science-capabilities/README.md +0 -0
  31. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/12-data-science-archetype/README.md +0 -0
  32. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/13-data-science-cli-integration/README.md +0 -0
  33. {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/14-data-science-validation-and-rollout/README.md +0 -0
  34. {create_forge-0.3.1 → create_forge-0.3.2}/examples/README.md +0 -0
  35. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/__init__.py +0 -0
  36. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/compat.py +0 -0
  37. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/config.py +0 -0
  38. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/engine.py +0 -0
  39. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/models.py +0 -0
  40. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/pipeline.py +0 -0
  41. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/prompts.py +0 -0
  42. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/registry.py +0 -0
  43. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/sources.py +0 -0
  44. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/spec.py +0 -0
  45. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/staging.py +0 -0
  46. {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/templates.toml +0 -0
  47. {create_forge-0.3.1 → create_forge-0.3.2}/tests/__init__.py +0 -0
  48. {create_forge-0.3.1 → create_forge-0.3.2}/tests/conftest.py +0 -0
  49. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_adr.py +0 -0
  50. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_archetype_parity.py +0 -0
  51. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_component_selection.py +0 -0
  52. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_config.py +0 -0
  53. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_data_science_pipeline.py +0 -0
  54. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_downstream_reference.py +0 -0
  55. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_drift.py +0 -0
  56. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_e2e_engine_generation.py +0 -0
  57. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_e2e_generation.py +0 -0
  58. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_e2e_installed_rollout.py +0 -0
  59. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_engine_adapter.py +0 -0
  60. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_engine_cross_repository.py +0 -0
  61. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_labels.py +0 -0
  62. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_models.py +0 -0
  63. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_pipeline.py +0 -0
  64. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_policy_hook.py +0 -0
  65. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_prompts.py +0 -0
  66. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_reference_client_boundary.py +0 -0
  67. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_registry.py +0 -0
  68. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_sources.py +0 -0
  69. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_spec.py +0 -0
  70. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_staging.py +0 -0
  71. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_update.py +0 -0
  72. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_update_network.py +0 -0
  73. {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_workflows.py +0 -0
@@ -1,12 +1,17 @@
1
1
  # Changelog
2
2
 
3
3
  Generated by git-cliff from Conventional Commits.
4
+ ## [0.3.2] - 2026-09-08
5
+
6
+ ### Features
7
+
8
+ - Diagnose the Copier template cache
4
9
  ## [0.3.1] - 2026-09-08
5
10
 
6
11
  ### Bug Fixes
7
12
 
8
13
  - Prevent template source credentials from reaching CLI output (#144)
9
- - Raise the Copier floor past the destination-escape advisories
14
+ - Raise the Copier floor past the destination-escape advisories (#146)
10
15
 
11
16
  ### Documentation
12
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: create-forge
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Scaffold modern Python projects from maintained templates.
5
5
  Project-URL: Homepage, https://github.com/Sandsy09/create-forge
6
6
  Project-URL: Repository, https://github.com/Sandsy09/create-forge
@@ -20,7 +20,8 @@ Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Topic :: Software Development :: Code Generators
21
21
  Classifier: Typing :: Typed
22
22
  Requires-Python: >=3.11
23
- Requires-Dist: copier<10,>=9.15.2
23
+ Requires-Dist: copier<10,>=9.16
24
+ Requires-Dist: platformdirs>=4.3.6
24
25
  Requires-Dist: pydantic>=2.10
25
26
  Requires-Dist: pyyaml>=6.0
26
27
  Requires-Dist: questionary>=2.0
@@ -41,6 +41,7 @@ format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions
41
41
  - [0036 — Keep credentials out of template sources and CLI diagnostics](0036-template-source-credentials.md)
42
42
  - [0037 — Pin external Actions to reviewed commits and scope permissions per job](0037-immutable-workflow-actions.md)
43
43
  - [0038 — Raise the Copier floor past the destination-escape advisories and hold uv](0038-dependency-floor-review.md)
44
+ - [0039 — Diagnose the Copier template cache](0039-copier-cache-diagnostics.md)
44
45
 
45
46
  Add a new record by copying the most recent one and incrementing the number.
46
47
  Records are immutable: supersede them rather than editing.
@@ -7,7 +7,7 @@ build-backend = "hatchling.build"
7
7
 
8
8
  [project]
9
9
  name = "create-forge"
10
- version = "0.3.1"
10
+ version = "0.3.2"
11
11
  description = "Scaffold modern Python projects from maintained templates."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -29,11 +29,13 @@ classifiers = [
29
29
 
30
30
  dependencies = [
31
31
  # Pinned to a major: the Python API is public but evolves faster than the
32
- # CLI. runner.py is the only module that touches it. The lower bound clears
33
- # every published Copier advisory -- the destination-escape set fixed
34
- # through 9.14.1, and one patch further to 9.15.2 for a clean rule. See
35
- # ADR 0038 and docs/engine-updates.md.
36
- "copier>=9.15.2,<10",
32
+ # CLI. runner.py is the only module that touches it. The lower bound was
33
+ # raised past the destination-escape advisories to 9.15.2 (ADR 0038), then
34
+ # to 9.16 (ADR 0039): 9.16 is the first release with the git-mirror cache
35
+ # and COPIER_CACHE_DIR that runner.py models and `doctor` reports -- a
36
+ # required-behaviour move, not an advisory one. The whole >=9.16 range is
37
+ # advisory-free. See docs/engine-updates.md.
38
+ "copier>=9.16,<10",
37
39
  # >=0.16: typer 0.15.0-0.15.3 declare an uncapped click and break against
38
40
  # click >=8.2 (TyperArgument.make_metavar), and 0.15.4 pins click <8.2.
39
41
  # 0.16.0 is the first release that works with current click. The CI
@@ -43,6 +45,11 @@ dependencies = [
43
45
  "pydantic>=2.10",
44
46
  "rich>=13.9",
45
47
  "pyyaml>=6.0",
48
+ # runner.py resolves Copier's cache directory by Copier's own documented
49
+ # rule (COPIER_CACHE_DIR, else the platformdirs user cache). Pinned to the
50
+ # bound Copier itself declares so it adds no resolution pressure and the
51
+ # CI `floor` job sees the version Copier's own floor would (ADR 0039).
52
+ "platformdirs>=4.3.6",
46
53
  ]
47
54
 
48
55
  [project.optional-dependencies]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  import shutil
7
8
  import subprocess
8
9
  import sys
@@ -42,7 +43,14 @@ from create_forge.prompts import (
42
43
  slugify,
43
44
  )
44
45
  from create_forge.registry import load_registry
45
- from create_forge.runner import ScaffoldError, ScaffoldRequest, scaffold, update
46
+ from create_forge.runner import (
47
+ ScaffoldError,
48
+ ScaffoldRequest,
49
+ cache_probe,
50
+ copier_cache_location,
51
+ scaffold,
52
+ update,
53
+ )
46
54
  from create_forge.sources import SourceError, display_source, validate_source
47
55
  from create_forge.spec import (
48
56
  DESCRIPTOR_KIND,
@@ -1050,6 +1058,30 @@ class ConfigSummary:
1050
1058
  keys: list[str]
1051
1059
 
1052
1060
 
1061
+ @dataclass(frozen=True, slots=True)
1062
+ class CopierCache:
1063
+ """Copier's git-mirror cache location and whether create-forge could use
1064
+ it -- see docs/engine-resolution.md's diagnostics contract. `writable` is
1065
+ the only field that can fail a `doctor` check; the rest are facts.
1066
+ """ # noqa: D205
1067
+
1068
+ path: str
1069
+ override: bool
1070
+ exists: bool
1071
+ writable: bool
1072
+
1073
+
1074
+ @dataclass(frozen=True, slots=True)
1075
+ class UvStatus:
1076
+ """The `uv` create-forge would actually run, and the one the `engine`
1077
+ extra declares -- distinct facts that can legitimately differ.
1078
+ """ # noqa: D205
1079
+
1080
+ path: str | None
1081
+ version: str | None
1082
+ package: str | None
1083
+
1084
+
1053
1085
  @dataclass(frozen=True, slots=True)
1054
1086
  class Diagnostics:
1055
1087
  """Everything `doctor` reports, gathered once so the table and `--json`
@@ -1061,6 +1093,8 @@ class Diagnostics:
1061
1093
  platform: str
1062
1094
  integration: Integration
1063
1095
  config: ConfigSummary
1096
+ copier_cache: CopierCache
1097
+ uv: UvStatus
1064
1098
  checks: list[Check]
1065
1099
 
1066
1100
  @property
@@ -1069,6 +1103,73 @@ class Diagnostics:
1069
1103
  return all(check.passed for check in self.checks if not check.informational)
1070
1104
 
1071
1105
 
1106
+ def _tooling_diagnostics(checks: list[Check]) -> tuple[CopierCache, UvStatus]:
1107
+ """Append the tooling rows and return the structured facts.
1108
+
1109
+ The git / uv / Copier-cache rows land in `checks`; the cache and uv facts
1110
+ `doctor --json` reports alongside them come back as dataclasses.
1111
+ """
1112
+ git_found = shutil.which("git")
1113
+ checks.append(
1114
+ Check(
1115
+ "git",
1116
+ bool(git_found),
1117
+ git_found or "not on PATH — required to clone templates",
1118
+ )
1119
+ )
1120
+
1121
+ uv_found = shutil.which("uv")
1122
+ uv_version = _uv_version(uv_found)
1123
+ checks.append(
1124
+ Check(
1125
+ "uv",
1126
+ bool(uv_found),
1127
+ f"{uv_version} ({uv_found})"
1128
+ if uv_found and uv_version
1129
+ else uv_found or "not on PATH — required by generated projects",
1130
+ )
1131
+ )
1132
+
1133
+ cache = copier_cache_location()
1134
+ probe = cache_probe(cache)
1135
+ checks.append(
1136
+ Check(
1137
+ "copier cache",
1138
+ True,
1139
+ f"{cache.path} "
1140
+ + (
1141
+ "(COPIER_CACHE_DIR override)"
1142
+ if cache.overridden
1143
+ else "(default location)"
1144
+ ),
1145
+ informational=True,
1146
+ )
1147
+ )
1148
+ checks.append(
1149
+ Check(
1150
+ "copier cache writable",
1151
+ probe.writable,
1152
+ "writable"
1153
+ if probe.writable
1154
+ else "not writable — set COPIER_CACHE_DIR to a writable directory",
1155
+ )
1156
+ )
1157
+
1158
+ return (
1159
+ CopierCache(
1160
+ path=str(cache.path),
1161
+ override=cache.overridden,
1162
+ exists=probe.exists,
1163
+ writable=probe.writable,
1164
+ ),
1165
+ UvStatus(
1166
+ path=uv_found,
1167
+ version=uv_version,
1168
+ package=_optional_dist_version("uv"),
1169
+ ),
1170
+ )
1171
+
1172
+
1072
1173
  def _gather_diagnostics() -> Diagnostics:
1073
1174
  """Run every doctor check and collect every reportable fact.
1074
1175
 
@@ -1091,12 +1192,7 @@ def _gather_diagnostics() -> Diagnostics:
1091
1192
  python_version = f"{py.major}.{py.minor}.{py.micro}"
1092
1193
  check(py >= (3, 11), "Python 3.11+", python_version)
1093
1194
 
1094
- for tool, why in (
1095
- ("git", "required to clone templates"),
1096
- ("uv", "required by generated projects"),
1097
- ):
1098
- found = shutil.which(tool)
1099
- check(bool(found), tool, found or f"not on PATH — {why}")
1195
+ copier_cache, uv_status = _tooling_diagnostics(checks)
1100
1196
 
1101
1197
  if shutil.which("git"):
1102
1198
  name = _git_config("user.name")
@@ -1171,10 +1267,38 @@ def _gather_diagnostics() -> Diagnostics:
1171
1267
  template_ref=None,
1172
1268
  ),
1173
1269
  config=ConfigSummary(path=str(config_path()), keys=config_keys),
1270
+ copier_cache=copier_cache,
1271
+ uv=uv_status,
1174
1272
  checks=checks,
1175
1273
  )
1176
1274
 
1177
1275
 
1276
+ def _uv_version(uv_path: str | None) -> str | None:
1277
+ """The version `uv --version` reports, or None when it can't be trusted.
1278
+
1279
+ `uv --version` prints e.g. `uv 0.12.10`. Only a token that looks like a
1280
+ version is returned, so nothing arbitrary from the subprocess can reach
1281
+ `doctor`'s output.
1282
+ """
1283
+ if not uv_path:
1284
+ return None
1285
+ try:
1286
+ result = subprocess.run( # noqa: S603
1287
+ [uv_path, "--version"],
1288
+ capture_output=True,
1289
+ text=True,
1290
+ check=False,
1291
+ timeout=5,
1292
+ )
1293
+ except (OSError, subprocess.TimeoutExpired): # pragma: no cover
1294
+ return None
1295
+ match result.stdout.split():
1296
+ case [_, token, *_] if re.fullmatch(r"[0-9][0-9A-Za-z.+-]*", token):
1297
+ return token
1298
+ case _:
1299
+ return None
1300
+
1301
+
1178
1302
  def _render_diagnostics_table(diagnostics: Diagnostics, target: Console) -> None:
1179
1303
  """Render `doctor`'s checks as the human-facing Rich table."""
1180
1304
  passed_marker, failed_marker = _markers(target)
@@ -1219,6 +1343,17 @@ def _diagnostics_payload(diagnostics: Diagnostics) -> dict[str, object]:
1219
1343
  "template_ref": integration.template_ref,
1220
1344
  },
1221
1345
  "config": {"path": diagnostics.config.path, "keys": diagnostics.config.keys},
1346
+ "copier_cache": {
1347
+ "path": diagnostics.copier_cache.path,
1348
+ "override": diagnostics.copier_cache.override,
1349
+ "exists": diagnostics.copier_cache.exists,
1350
+ "writable": diagnostics.copier_cache.writable,
1351
+ },
1352
+ "uv": {
1353
+ "path": diagnostics.uv.path,
1354
+ "version": diagnostics.uv.version,
1355
+ "package": diagnostics.uv.package,
1356
+ },
1222
1357
  "checks": [
1223
1358
  {"name": c.name, "ok": c.passed, "detail": c.detail}
1224
1359
  for c in diagnostics.checks
@@ -2,12 +2,20 @@
2
2
 
3
3
  Copier's Python API is public but not versioned as strictly as its CLI, so this
4
4
  module is the single place that touches it. Pin copier narrowly in
5
- pyproject.toml (`copier>=9.15.2,<10`) and this file is the only thing that
5
+ pyproject.toml (`copier>=9.16,<10`) and this file is the only thing that
6
6
  needs attention on a major bump.
7
+
8
+ `copier_cache_location` also models one piece of documented Copier behaviour --
9
+ where the git-mirror cache lives and how `COPIER_CACHE_DIR` overrides it, added
10
+ in Copier 9.16 -- so `doctor` can report it and a cache failure can be
11
+ explained instead of misattributed to the network. `tests/test_copier_cache.py`
12
+ pins that model against Copier's own private resolver so a drift fails CI.
7
13
  """
8
14
 
9
15
  from __future__ import annotations
10
16
 
17
+ import os
18
+ import tempfile
11
19
  from dataclasses import dataclass
12
20
  from pathlib import Path
13
21
  from typing import TYPE_CHECKING
@@ -15,6 +23,7 @@ from typing import TYPE_CHECKING
15
23
  import yaml
16
24
  from copier import run_copy, run_update
17
25
  from copier.errors import CopierError
26
+ from platformdirs import user_cache_dir
18
27
  from plumbum.commands.processes import ProcessExecutionError
19
28
 
20
29
  from create_forge import staging
@@ -34,6 +43,76 @@ _PROCESS_FAILURE_MESSAGE = (
34
43
  "access, and Git credentials, then retry."
35
44
  )
36
45
 
46
+ COPIER_CACHE_ENV_VAR = "COPIER_CACHE_DIR"
47
+
48
+ _CACHE_FAILURE_SIGNAL = "not a git repository"
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class CacheLocation:
53
+ """Where Copier stores its git-mirror cache, and how it was chosen."""
54
+
55
+ path: Path
56
+ overridden: bool
57
+ """True when COPIER_CACHE_DIR selected the path, not the default."""
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class CacheProbe:
62
+ """Whether Copier's cache directory exists and accepts writes."""
63
+
64
+ exists: bool
65
+ writable: bool
66
+
67
+
68
+ def copier_cache_location() -> CacheLocation:
69
+ """Resolve Copier's cache directory by its own documented rule.
70
+
71
+ Copier uses `COPIER_CACHE_DIR` verbatim when it is set and non-empty,
72
+ otherwise `<platformdirs user cache for "copier">/git`. This mirrors
73
+ `copier._vcs._get_cache_dir`, which is private and has moved between
74
+ supported releases; `tests/test_copier_cache.py` pins the two together so
75
+ a drift fails CI rather than silently misreporting in `doctor`.
76
+ """
77
+ override = os.environ.get(COPIER_CACHE_ENV_VAR)
78
+ if override:
79
+ return CacheLocation(path=Path(override), overridden=True)
80
+ default = Path(user_cache_dir("copier", appauthor=False)) / "git"
81
+ return CacheLocation(path=default, overridden=False)
82
+
83
+
84
+ def _write_probe(directory: Path) -> None:
85
+ """Create and immediately remove a uniquely named file in `directory`."""
86
+ fd, name = tempfile.mkstemp(dir=directory, prefix=".create-forge-probe-")
87
+ os.close(fd)
88
+ Path(name).unlink(missing_ok=True)
89
+
90
+
91
+ def cache_probe(location: CacheLocation) -> CacheProbe:
92
+ """Report whether Copier could use `location` without changing it.
93
+
94
+ Never creates the cache directory: when it is absent, the deepest existing
95
+ ancestor is probed instead, because that is what Copier's own
96
+ `mirror.parent.mkdir(parents=True)` writes into. The probe creates a
97
+ uniquely named file and deletes it immediately -- it never reads, writes,
98
+ or lists a `<sha>.git` mirror, and leaves nothing persistent behind.
99
+
100
+ `os.access` is deliberately not used: on Windows it ignores ACLs and
101
+ reports a locked-down corporate cache directory as writable, which is the
102
+ exact condition this probe exists to catch.
103
+ """
104
+ exists = location.path.is_dir()
105
+ probe_dir = location.path
106
+ while not probe_dir.is_dir():
107
+ if probe_dir.parent == probe_dir:
108
+ return CacheProbe(exists=exists, writable=False)
109
+ probe_dir = probe_dir.parent
110
+ try:
111
+ _write_probe(probe_dir)
112
+ except OSError:
113
+ return CacheProbe(exists=exists, writable=False)
114
+ return CacheProbe(exists=exists, writable=True)
115
+
37
116
 
38
117
  @dataclass(frozen=True, slots=True)
39
118
  class ScaffoldRequest:
@@ -89,6 +168,16 @@ def scaffold(request: ScaffoldRequest) -> None:
89
168
  )
90
169
  except (CopierError, ProcessExecutionError) as exc:
91
170
  raise ScaffoldError(_explain(exc)) from exc
171
+ except OSError as exc:
172
+ # ProcessExecutionError is itself an OSError subclass, so it stays
173
+ # matched by the clause above. This one is Copier failing to
174
+ # create or use its cache directory directly (Copier >=9.16) -- a
175
+ # redirected or ACL-restricted cache on a managed machine surfaces
176
+ # here, before git runs. Errors outside the cache path are not
177
+ # ours to translate.
178
+ if (message := _explain_cache_os_error(exc)) is None:
179
+ raise
180
+ raise ScaffoldError(message) from exc
92
181
 
93
182
 
94
183
  def update(project: Path, *, vcs_ref: str | None = None, dry_run: bool = False) -> None:
@@ -143,6 +232,60 @@ def update(project: Path, *, vcs_ref: str | None = None, dry_run: bool = False)
143
232
  )
144
233
  except (CopierError, ProcessExecutionError) as exc:
145
234
  raise ScaffoldError(_explain(exc)) from exc
235
+ except OSError as exc:
236
+ # See scaffold(): a cache-directory failure Copier raises directly,
237
+ # not through git. Everything else re-raises untouched.
238
+ if (message := _explain_cache_os_error(exc)) is None:
239
+ raise
240
+ raise ScaffoldError(message) from exc
241
+
242
+
243
+ def _cache_failure_message(cache_path: Path) -> str:
244
+ """Recovery guidance for an unusable Copier cache, naming only our own
245
+ resolved path -- never any part of the process output that revealed it.
246
+ """ # noqa: D205
247
+ return (
248
+ f"Copier's template cache at {cache_path} is not usable.\n"
249
+ " This is common on managed machines where the cache location is "
250
+ "redirected or restricted.\n"
251
+ " Point Copier at a fresh, writable directory and retry -- do not "
252
+ "delete the existing one:\n"
253
+ ' PowerShell: $env:COPIER_CACHE_DIR = "C:\\forge-cache"\n'
254
+ ' bash/zsh: export COPIER_CACHE_DIR="$HOME/.cache/forge-copier"'
255
+ )
256
+
257
+
258
+ def _under_cache_dir(candidate: object, cache_path: Path) -> bool:
259
+ """True when `candidate` is a string path at or below `cache_path`."""
260
+ if not isinstance(candidate, str):
261
+ return False
262
+ return os.path.normcase(candidate).startswith(os.path.normcase(str(cache_path)))
263
+
264
+
265
+ def _looks_like_cache_git_failure(exc: ProcessExecutionError, cache_path: Path) -> bool:
266
+ """True only for a git failure that reports a missing repository *and*
267
+ names the cache directory -- not for auth, network, or missing-source
268
+ failures that merely happen to run against a cache path in argv.
269
+ """ # noqa: D205
270
+ if _CACHE_FAILURE_SIGNAL not in (exc.stderr or "").lower():
271
+ return False
272
+ if os.path.normcase(str(cache_path)) in os.path.normcase(exc.stderr or ""):
273
+ return True
274
+ return any(_under_cache_dir(arg, cache_path) for arg in exc.argv or [])
275
+
276
+
277
+ def _explain_cache_os_error(exc: OSError) -> str | None:
278
+ """Explain an OS error Copier raised while preparing its cache directory.
279
+
280
+ Returns None for any error whose filename is not under the resolved cache
281
+ directory, so the caller re-raises it untouched -- create-forge only owns
282
+ a failure at the cache path it can name.
283
+ """
284
+ cache_path = copier_cache_location().path
285
+ names = (exc.filename, getattr(exc, "filename2", None))
286
+ if any(_under_cache_dir(name, cache_path) for name in names):
287
+ return _cache_failure_message(cache_path)
288
+ return None
146
289
 
147
290
 
148
291
  def _explain(exc: CopierError | ProcessExecutionError) -> str:
@@ -155,7 +298,14 @@ def _explain(exc: CopierError | ProcessExecutionError) -> str:
155
298
  # The raw exception contains argv, stdout and stderr. In addition to
156
299
  # being too implementation-specific for users, argv may contain
157
300
  # credentials embedded in a template URL, so none of it is displayed.
158
- return _PROCESS_FAILURE_MESSAGE
301
+ # Only fixed signals are read out of it, and only create-forge's own
302
+ # resolved cache path is ever shown.
303
+ cache = copier_cache_location()
304
+ return (
305
+ _cache_failure_message(cache.path)
306
+ if _looks_like_cache_git_failure(exc, cache.path)
307
+ else _PROCESS_FAILURE_MESSAGE
308
+ )
159
309
 
160
310
  text = str(exc)
161
311
  lowered = text.lower()
@@ -33,7 +33,7 @@ if TYPE_CHECKING:
33
33
 
34
34
  REPO_ROOT = Path(__file__).resolve().parent.parent
35
35
 
36
- CLIENT_VERSION = "0.3.1"
36
+ CLIENT_VERSION = "0.3.2"
37
37
  ENGINE_VERSION = "0.4.1"
38
38
  DEFAULT_PYTHON = "3.13"
39
39
  SUBPROCESS_TIMEOUT = 1800
@@ -66,6 +66,9 @@ def _isolated_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
66
66
  _clean_forge_env, extended to also isolate the file path.
67
67
  """
68
68
  monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
69
+ # doctor now probes Copier's cache directory; keep that off the real
70
+ # user cache the same way config is kept off the real config file.
71
+ monkeypatch.setenv("COPIER_CACHE_DIR", str(tmp_path / "copier-cache"))
69
72
  for field in UserConfig.model_fields:
70
73
  monkeypatch.delenv(f"FORGE_{field.upper()}", raising=False)
71
74
  return config_path()
@@ -235,6 +238,56 @@ def test_doctor_json_exits_1_when_a_check_fails(
235
238
  assert registry_check["ok"] is False
236
239
 
237
240
 
241
+ def test_doctor_reports_the_copier_cache_and_uv(
242
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
243
+ ) -> None:
244
+ """ADR 0039 / docs/engine-resolution.md diagnostics contract: `doctor`
245
+ names Copier's cache directory, whether COPIER_CACHE_DIR overrides it, and
246
+ whether it is writable, plus the `uv` binary it would actually run.
247
+ """
248
+ monkeypatch.setattr(cli_module, "_git_config", lambda _key: "test")
249
+ override = tmp_path / "cache dir"
250
+ override.mkdir()
251
+ monkeypatch.setenv("COPIER_CACHE_DIR", str(override))
252
+
253
+ result = runner.invoke(app, ["doctor", "--json"])
254
+
255
+ assert result.exit_code == 0, result.output
256
+ payload = json.loads(result.output)
257
+ assert payload["copier_cache"] == {
258
+ "path": str(override),
259
+ "override": True,
260
+ "exists": True,
261
+ "writable": True,
262
+ }
263
+ assert set(payload["uv"]) == {"path", "version", "package"}
264
+ assert "copier cache" in result.output # informational row in the table too
265
+
266
+
267
+ def test_doctor_fails_when_the_copier_cache_is_unwritable(
268
+ monkeypatch: pytest.MonkeyPatch,
269
+ ) -> None:
270
+ """An unwritable cache means the environment genuinely cannot scaffold, so
271
+ the check fails and `doctor` exits 1 -- the schema stays additive, only
272
+ the boolean flips.
273
+ """
274
+ monkeypatch.setattr(cli_module, "_git_config", lambda _key: "test")
275
+ monkeypatch.setattr(
276
+ cli_module,
277
+ "cache_probe",
278
+ lambda _location: runner_module.CacheProbe(exists=True, writable=False),
279
+ )
280
+
281
+ result = runner.invoke(app, ["doctor", "--json"])
282
+
283
+ assert result.exit_code == 1
284
+ payload = json.loads(result.output)
285
+ assert payload["ok"] is False
286
+ assert payload["copier_cache"]["writable"] is False
287
+ check = next(c for c in payload["checks"] if c["name"] == "copier cache writable")
288
+ assert check["ok"] is False
289
+
290
+
238
291
  def test_doctor_survives_a_console_that_cannot_encode_check_marks(
239
292
  monkeypatch: pytest.MonkeyPatch,
240
293
  ) -> None: