Skip to content

.gitignore generation

cobo generates the gitignore with a hash-sealed fence and lockfiles

Id: 3e3bc38e-bc1e-4e65-b74d-e91848262157 Type: decision Status: active Evidence: confirmed Source: ADR-012 (docs/adr/012-cobo-for-gitignore-generation.md) Revisit when: github/gitignore stops being the boilerplate source, or cobo changes its fence or lock format See: ci-topology.md#network-dependent-checks-run-on-a-weekly-cron-never-in-the-merge-gate — 3e5b9525-9087-4125-b19f-80f6c226c01f — as of 2026-10-11

cobo fetches the same github/gitignore boilerplates the previous gibo workflow stacked, but writes them inside a sha256-sealed fence and records the upstream commit and per-file blobs in cobo.lock. The repository root lock carries two fragments — the template source and this repository's own .gitignore (dogfooding) — and template/cobo.lock ships verbatim so a generated project's gitignore is drift-checkable. cobo is always-included tooling (a mise pipx: tool, Renovate-tracked), with no toggle. The project's own overrides (the freezer spec negation, the C-extension ignore) sit below the end fence, where cobo preserves them; the negation still lands after the fenced *.spec ignore, so it wins.

Reason: gibo was run by hand with no lockfile, no pin the tooling understood and no way to detect upstream drift, and the overrides were injected inside the boilerplate it generated.

Rejected alternative: keep gibo — the gaps above.

Consequence: the cobo-managed .gitignore is excluded from the trailing-whitespace hook, because upstream ships trailing whitespace on some comment lines and trimming inside the fence breaks the seal; cobo.lock is linguist-generated and carries its own 2-space EditorConfig stanza (taplo's *.toml glob does not cover .lock). Regeneration is cobo update followed by cobo sync; a hand edit inside the fence breaks the sha256.

The seal keeps the two macOS CR patterns; the render still strips them

Id: a5df0a9c-aa53-4a8c-add6-566326352a0e Type: decision Type: workaround Status: active Evidence: confirmed Source: ADR-012 §1 (docs/adr/012-cobo-for-gitignore-generation.md); cobo#120/#121 (the upstreamed --eol lf policy), cobo#124, issue #111, issue #393 Revisit when: the Copier render preserves a lone carriage return in this file (the cobo half — normalize terminators only, not a mid-line CR — already shipped in cobo 0.6.0)

The fence is sealed with cobo's --eol lf policy, persisted as eol = "lf" on the lock fragment. Since cobo 0.6.0 that policy normalizes terminators only (CRLF → LF) and keeps a mid-line lone CR — so the sealed template/.gitignore.jinja carries the macOS boilerplate's two carriage-return filenames (Icon␍, .HFS+ Private Directory Data␍) byte-intact, and the old "seal LF-normalizes them into Icon[/]" description is stale. The corruption moved one stage down the pipeline: Copier renders .gitignore.jinja through Jinja, whose lexer normalizes a lone \r to \n at scaffold time (verified by rendering a CR-bearing probe template with the pinned copier). Every rendered project therefore still checks out the split Icon[ (unterminated class, matches nothing, inert) / ] (a literal pattern matching a file or directory named ] at any depth — a non-issue in practice, and no post-fence !] override: it would stamp noise on every generated project for a filename that essentially never exists) pair — and, because the fence sha256 covers the CR-bearing bytes, additionally reports up to date; locally modified on cobo check. An up-to-date cobo sync still succeeds as a no-op, but a later sync that must apply upstream drift fails without --force on the locally-modified block — until fence verification tolerates the render-stripped CR (verified by rendering the template and running cobo check and cobo sync against the shipped lock).

Reason: the template enforces LF everywhere (.gitattributes, EditorConfig, ruff) and there is no LF-only pattern that matches Icon␍ precisely (Icon? over-matches), so fidelity genuinely requires the CR, and the CR cannot survive the Jinja render. These are legacy HFS resource-fork artifacts modern macOS rarely creates and almost never commits — an accepted tradeoff, not "harmless".

Rejected alternative: seal byte-exact and keep the CR — fights the LF invariant and is re-corrupted by the render anyway.

Consequence: the remaining fix is render-side or check-side, not seal-side: either a Copier/Jinja newline carve-out scoped to this file, or cobo fence verification that treats a render-stripped lone CR as clean under eol = "lf". Until then, refreshes come from a template-side cobo sync, never from a local cobo dump in a generated project (whose bytes copier update can never reproduce) — that rule is recorded in the NOTE below the fence in template/.gitignore.jinja.

Node and GitHubPages ride always-on in the seal

Id: 00d055c4-152a-4b0d-b0b6-a8ecf6bdc557 Type: decision Status: active Evidence: confirmed Source: issue #393 Revisit when: a fragment arrives that only a subset of generated projects can use without tripping their drift check

The seal pins seven github/gitignore boilerplates (macOS, Windows, Linux, VisualStudioCode, Python, Node, GitHubPages @ 0e5d690) — unconditionally, with no toggle. Downstream projects that publish docs to Pages or touch any JS tooling used to hand-add Node + GitHubPages ad hoc and diverge from the template until the next copier update clobbered them.

Reason: a fragment gated on a toggle (e.g. GitHubPages on include_docs) would need a Jinja-conditional seal and a Jinja-templated cobo.lock, with drift-check edge cases for every answers combination; the VisualStudioCode precedent already established that always-on is acceptable. Deciding once here beats every project hand-editing the seal.

Rejected alternative: gate GitHubPages on include_docs — leaner seal for non-docs projects, rejected for the lock-templating complexity above.

Consequence: the refresh re-pinned 57286c3 → 0e5d690 at the same time (only Python.gitignore had changed upstream: UV → uv, mkdocs/Zensical, a VSCode comment rewrap). Both root-lock fragments (template/.gitignore.jinja and the dogfooded repo .gitignore) carry the same seven files, and template/cobo.lock ships the same set to generated projects. The pin refresh also surfaced the seal-keeps/render-strips CR split above, whose check-side follow-up lives in the cobo tracker, not here.

Consequence: a sealed pattern can also match a template source file: copier honors the worktree gitignore when enumerating template/, so the sealed Node .env.* silently dropped .env.example from fresh renders (its !.env.example rescue misses the .jinja suffix). Such collisions get a below-fence negation in the repo-root .gitignore author section (never inside the fence, never in template/.gitignore.jinja, whose author section renders into generated projects) — here, !/template/.env.example.jinja.

copier update leaves the cobo-managed .gitignore and lock alone; adoption still renders them

Id: cecafff6-ea1b-4821-942f-30e9c5c6e489 Type: decision Status: active Evidence: inferred Source: maintainer conversation, 2026-10-24 (all copier-* templates seed the cobo-managed files once); copier/_main.py in copier 9.18.2 and tests/test_copier_exclude.py Revisit when: copier renders _skip_if_exists patterns with _copier_operation, or the generated cobo.lock tracks more than .gitignore See: gitignore-generation.md#cobo-generates-the-gitignore-with-a-hash-sealed-fence-and-lockfiles — 3e3bc38e-bc1e-4e65-b74d-e91848262157 — as of 2026-10-24

copier.yml lists .gitignore and cobo.lock in _exclude, each wrapped in {% if _copier_operation == 'update' %}…{% endif %}. A generated project owns both after the first render: cobo rewrites the sealed block and the lock, so a copier update three-way merge of the template's copies would conflict with it. Adoption (copier copy --overwrite) still renders both, as a matched pair. .gitattributes and .editorconfig are not listed: the generated cobo.lock tracks only .gitignore, so nothing else would refresh them.

Reason: the user's other copier templates seed the cobo files once, and the update flow is the only place the merge hurts.

Rejected alternative: an unconditional _skip_if_exists, as the other templates use. Copier renders its patterns without _copier_operation, so it cannot be limited to updates, and the adoption copy would keep an existing .gitignore beside the template's fresh cobo.lock (cobo check --strict fails).

Consequence: copier update does not restore a deleted file: the exclusion prevents rendering even when the file is absent. A project that deletes one of the two is left with an incomplete pair and must restore the missing file itself (cobo gitignore dump … --lock rebuilds both), but the two cannot be reseeded out of step. Template changes to the template's .gitignore reach existing projects only through cobo's own updates.