2026-08-04 The Copier Crash Was the Dry Run, Not the Subdirectory
What I set out to do
Run copier update across the eight copier-generated uv projects in ~/.config/nix/projects/. Template python-project-template had cut v1.8.1 that morning; everything was still on v1.8.0.
What actually happened
v1.8.1 was the good kind of release: it upstreams the exact two fixes every generated project had been carrying as local hand-patches. .envrc now emits # shellcheck source=scripts/bootstrap.sh disable=SC1091 (the bare source= directive never silenced SC1091, because shellcheck refuses to follow external sources without -x), and flake.nix drops the unused self that deadnix flagged. Those two were the only merge conflicts, both resolved with git checkout --theirs, so the divergence is gone rather than reintroduced. The hook-revision bumps in the same release were already applied locally by prek auto-update, so they produced no diff. Committed as f408126.
The interesting part was a crash I then misdiagnosed. My first invocation was a dry run, and it died: _apply_update hands the repo-root-relative subdir to git diff-tree as a bare pathspec and gets fatal: ambiguous argument 'nix/projects/parse-history': unknown revision or path not in the working tree. I read that as “copier cannot update a project inside a larger git repo” and built a workaround: rebuild each project as a standalone one-commit repo via git archive HEAD, update it there where subproject_subdir is just ., and rsync the result back. Scripted over all eight with two guards, since the point of a scripted sweep is that it fails loudly rather than half-succeeding: refuse to continue on any conflict set other than the expected .envrc flake.nix, and compare the tracked file list before and after instead of using rsync --delete, which would have wiped the untracked .venv and .direnv in each project.
The workaround was unnecessary. Asked afterwards whether an upstream issue existed, I built a minimal repro to check, and it would not crash. Filling in the 2x2 found the real trigger: --pretend, not the subdirectory. --pretend makes the old_copy render write nothing, so old_copy/<subdir> never exists and git rejects the pathspec. At a repo root the same pathspec is ., which exists unconditionally, which is why the bug is invisible there and why nobody has filed it. Subdirectory alone, root with --pretend, and root alone all pass; only the pair fails. A plain copier update in place would have worked on all eight from the start.
Nearest upstream issues, none of them this: #2300 (open, monorepo support, framed as a feature request with no traceback), #1069 (merged, subdirectory updates), #2362 (closed, subproject dirty-check). The Make sure Git >= 2.24 is installed line printed on every run is a separate known bug, #2759: git >= 2.55 rejects the --inter-hunk-context=-1 probe copier uses.
What was striking
Two formatters and a generator, none of which agree. copier’s PyYAML emitter writes 'single' quotes, unindented list items, and wraps any value past ~80 chars. prettier autofixes all of that. For a wrapped value, prettier’s rewrap is then something yamlfmt rejects, and neither will yield. Only claude-skill-sync has a project_description long enough to trip it, and the fix was to collapse the wrap by hand, which is the single-line form both accept. The practical consequence is that just check has to run twice after any copier operation: pass one lets prettier autofix, pass two has to be green.
The reusable rule: when a generator writes a file that formatters also own, the generator’s output is an intermediate, not the artifact. Budget for a normalization pass and check that the two formatters agree on the result.
The sharper lesson is the misdiagnosis. A dry run is supposed to be the safe way to look before acting, so when it crashed I attributed the failure to the thing I was about to do rather than to the looking. One control run without --pretend would have collapsed the whole detour, and it cost nothing to run. The reusable rule: when a diagnostic mode fails, suspect the diagnostic before you suspect the operation. This is feedback_null_result_needs_a_control in a new costume, and I did not recognize it in time.
I also deleted a flake.lock I had created myself. Running nix flake check inside parse-history to verify its dev flake still evaluated generated one, and parse-history is one of three projects that deliberately does not track a lock. Caught it by auditing HEAD versus worktree per project rather than trusting git ls-files, which had listed it because something intent-to-added it.
Updated the copier-template-local-divergences project memory. Its old content was the list of two hand-fixes, which v1.8.1 made obsolete; it now carries the monorepo workaround and the formatter conflict instead.