diff --git a/.claude/settings-hugo.json b/.claude/settings-hugo.json index 09c4dd72..337c904b 100644 --- a/.claude/settings-hugo.json +++ b/.claude/settings-hugo.json @@ -1,5 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", + "plansDirectory": ".claude/plans", "permissions": { "allow": [ "Bash(git status:*)", @@ -30,7 +31,9 @@ "Bash(touch:*)", "Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*)", "Bash(./config/pull)", - "Bash(./config/migrate)" + "Bash(./config/migrate)", + "Skill(pre-pr)", + "Skill(pre-pr:*)" ], "deny": [ "Bash(git push:*)", diff --git a/.claude/settings.json b/.claude/settings.json index 357650cf..3c7da68c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", + "plansDirectory": ".claude/plans", "permissions": { "allow": [ "Edit(version.gradle.kts)", @@ -32,8 +33,11 @@ "Bash(mkdir:*)", "Bash(touch:*)", "Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*)", + "Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)", "Bash(./config/pull)", - "Bash(./config/migrate)" + "Bash(./config/migrate)", + "Skill(pre-pr)", + "Skill(pre-pr:*)" ], "deny": [ "Bash(git reset --hard:*)", diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 602e6cea..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Skill(pre-pr)", - "Skill(pre-pr:*)", - "Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)", - "Bash(echo \"exit=$?\")" - ] - } -} diff --git a/.gitignore b/.gitignore index 14d9a3ae..3e1f89ad 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,12 @@ __pycache__/ # Claude working files /.claude/worktrees/ +# Ephemeral plan-mode scratch (durable task docs live in `.agents/tasks/`). +/.claude/plans/ + +# Personal, per-developer Claude Code settings overrides (never committed; +# the distributed `.claude/settings.json` is the shared, committed layer). +/.claude/settings.local.json # Auto-downloaded Lychee binary used by the `check-links` skill. /.agents/skills/check-links/.cache/ diff --git a/README.md b/README.md index 52ef1bfc..e340c03c 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,10 @@ The `pull` script also wires up AI-agent configuration: the submodule on the first run and floats it to the latest `agents@master` (checked out *on* `master`, not a detached `HEAD`) on every subsequent run — so shared skills update everywhere with **no file churn** in consumer pull requests. - * `.claude/settings.json` — the permission allowlist distributed by `config` (Hugo-only - repos receive a Hugo-tuned variant). JVM and mixed repos also get `settings.local.json`; - Hugo-only repos do not. + * `.claude/settings.json` — the shared, committed permission allowlist distributed by + `config` (Hugo-only repos receive a Hugo-tuned variant). `.claude/settings.local.json` + is **not** distributed: it is Claude Code's gitignored, per-developer personal-override + layer, so `pull` never creates, overwrites, or deletes it. * `.junie/guidelines.md` — JetBrains Junie guidelines. Per-repo content is never overwritten: `docs/project.md` (linked from `.agents/project.md`), diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt index cd11577f..c0cbea38 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt @@ -34,8 +34,8 @@ package io.spine.dependency.local @Suppress("ConstPropertyName", "unused") object ToolBase { const val group = Spine.toolsGroup - const val version = "2.0.0-SNAPSHOT.402" - const val dogfoodingVersion = "2.0.0-SNAPSHOT.402" + const val version = "2.0.0-SNAPSHOT.403" + const val dogfoodingVersion = "2.0.0-SNAPSHOT.403" const val lib = "$group:tool-base:$version" const val classicCodegen = "$group:classic-codegen:$version" diff --git a/migrate b/migrate index f20fca7d..c66229a6 100644 --- a/migrate +++ b/migrate @@ -207,6 +207,14 @@ fi # --------------------------------------------------------------------------- # Claude settings — the commands/agents/skills come from the submodule above; # only the permission settings are repo configuration distributed by `config`. +# +# `settings.json` is the SHARED, committed permission layer (org-wide defaults); +# for a Hugo-only repo the Hugo-tuned `settings-hugo.json` is applied under that +# name. `settings.local.json` is deliberately NOT distributed: in Claude Code it +# is the gitignored, per-developer personal-override layer (precedence +# user < project < local), so a `./config/pull` must never create, overwrite, or +# delete it — doing any of these wiped a developer's personal overrides or forced +# the file into Git. Org-wide permissions belong in the two shared templates above. # --------------------------------------------------------------------------- echo "Updating Claude settings" @@ -214,10 +222,8 @@ mkdir -p ../.claude if [ "$IS_HUGO_DOCS" = "true" ] && [ "$IS_JVM" = "false" ]; then # Hugo-only repos use the Hugo-tuned permission set. cp .claude/settings-hugo.json ../.claude/settings.json - rm -f ../.claude/settings.local.json else cp .claude/settings.json ../.claude/settings.json - cp .claude/settings.local.json ../.claude/settings.local.json fi # settings-hugo.json is a config-internal template (applied as settings.json for # Hugo-only repos), never distributed as-is; drop any stale copy an older migrate left. diff --git a/scripts/test-update-gitignore.sh b/scripts/test-update-gitignore.sh index f505be3c..c6bb2ae1 100755 --- a/scripts/test-update-gitignore.sh +++ b/scripts/test-update-gitignore.sh @@ -80,6 +80,9 @@ git -C "$consumer" config user.name test printf '%s\n' '!gradle-wrapper.jar' printf '%s\n' '!debug.log' printf '%s\n' '*.log' + # A retired baseline negation an earlier first-migration baked into repo-local; + # the steady-state merge must strip it so `.idea/misc.xml` stays ignored. + printf '%s\n' '!.idea/misc.xml' # A consumer comment that merely BEGINS like the legacy secret trailer, plus a # real entry after it. The legacy match is exact, so this comment is preserved # and the entry survives (regression guard for the start-anchored to-EOF drop). @@ -103,6 +106,7 @@ mkdir -p generated && : > generated/secret.gpg : > info.log : > creds.secret.properties.gpg mkdir -p keep-me-ignored && : > keep-me-ignored/x +mkdir -p .idea && : > .idea/misc.xml # (1) Every decrypted credential must be ignored despite the repo-local negations. for f in "${secrets[@]}"; do @@ -196,6 +200,21 @@ else f-ail "look-alike legacy comment triggered to-EOF drop; 'keep-me-ignored/' lost" fi +# (3h) A retired baseline negation baked into repo-local (`!.idea/misc.xml`) must be +# stripped so the baseline's `.idea/*.xml` ignore wins. The secret trailer does NOT +# cover it (misc.xml is not a secret glob), so without stripping it would re-expose the +# per-developer IDEA project file. +if git check-ignore -q .idea/misc.xml; then + pass "retired '!.idea/misc.xml' stripped from repo-local (.idea/misc.xml stays ignored)" +else + f-ail "retired '!.idea/misc.xml' survived in repo-local (.idea/misc.xml re-exposed)" +fi +if grep -qxF '!.idea/misc.xml' "$consumer/.gitignore"; then + f-ail "retired '!.idea/misc.xml' still present in the merged .gitignore" +else + pass "retired '!.idea/misc.xml' removed from the merged .gitignore" +fi + # --- (4) Idempotency: re-running yields byte-identical output. ----------------- cp "$consumer/.gitignore" "$work/first.gitignore" ( cd "$consumer/config" && bash "$script" ) @@ -312,6 +331,40 @@ else diff "$work/c3-first.gitignore" "$consumer3/.gitignore" >&2 || true fi +# --- (8) First-migration retired negation: a legacy raw copy carrying the OLD ------ +# baseline's `!.idea/misc.xml`. The bootstrap keeps consumer negations, so without the +# retired-negation filter this stale `!` lands in repo-local (after the managed block) +# and re-includes `.idea/misc.xml`. It must be stripped; a genuine consumer line beside +# it must still survive. +consumer4="$work/consumer4" +mkdir -p "$consumer4/config" +cp "$baseline" "$consumer4/config/.gitignore" +git -C "$consumer4" init -q +git -C "$consumer4" config user.email test@example.com +git -C "$consumer4" config user.name test +# Legacy raw copy of an OLDER baseline: current baseline + the retired negation it used +# to carry + a genuine consumer line. No managed markers. +{ cat "$baseline"; printf '%s\n' '!.idea/misc.xml' 'my-own-cache/'; } > "$consumer4/.gitignore" + +( cd "$consumer4/config" && bash "$script" ) + +mkdir -p "$consumer4/.idea" && : > "$consumer4/.idea/misc.xml" +if git -C "$consumer4" check-ignore -q .idea/misc.xml; then + pass "first-migration strips retired '!.idea/misc.xml' (.idea/misc.xml stays ignored)" +else + f-ail "first-migration kept retired '!.idea/misc.xml' (.idea/misc.xml re-exposed)" +fi +if git -C "$consumer4" check-ignore -q my-own-cache/x; then + pass "first-migration preserved the genuine consumer line beside the filtered negation" +else + f-ail "first-migration dropped the genuine consumer line" +fi +if grep -qxF '!.idea/misc.xml' "$consumer4/.gitignore"; then + f-ail "first-migration left the retired '!.idea/misc.xml' in the merged file" +else + pass "first-migration removed the retired '!.idea/misc.xml' from the merged file" +fi + echo if [ "$fail" -eq 0 ]; then echo "OK: all update_gitignore regression checks passed." diff --git a/scripts/update-gitignore.sh b/scripts/update-gitignore.sh index e6f177c8..3f56dd10 100755 --- a/scripts/update-gitignore.sh +++ b/scripts/update-gitignore.sh @@ -88,6 +88,16 @@ secret_end='# <<< secret ignores <<<' legacy_local_label='# --- repo-local entries (preserved across ./config/pull) ---' legacy_secret_label='# --- secret ignores re-asserted last so a repo-local negation cannot un-ignore a credential ---' +# Negations config once shipped as baseline `!`-re-inclusions but has since retired. +# A legacy raw-copy consumer (first-migration) — or an already-migrated consumer whose +# repo-local was written by an earlier first-migration — still carries them. Left in the +# repo-local region they sit AFTER the managed baseline and, gitignore being +# last-match-wins, re-include a path the current baseline now ignores. Strip them so the +# baseline's ignore wins. First entry: `!.idea/misc.xml` (the current baseline dropped its +# `!.idea/misc.xml` re-inclusion to keep the per-machine IDEA project file untracked). +# Newline-separated; extend as further negations are retired. +retired_negations='!.idea/misc.xml' + # Positive secret patterns from the baseline's Secrets section: the span from the # `# Secrets` header to the closing `!*.gpg`, excluding comments and negations. # (The `# Secrets` header and the trailing `!*.gpg` are asserted as invariants by @@ -131,16 +141,20 @@ fi if grep -qxF "$base_begin" "$dest"; then # STEADY STATE — the file already carries our markers. Repo-local entries are # everything OUTSIDE the managed blocks, preserved VERBATIM and IN ORDER (no - # de-duplication, no filtering). Stripped here: the baseline block, the secrets - # block, the repo-local markers themselves (their CONTENT is what we keep), and - # any legacy pre-marker scaffolding written by intermediate script versions (the + # de-duplication, no reordering). Stripped here: the baseline block, the secrets + # block, the repo-local markers themselves (their CONTENT is what we keep), any + # legacy pre-marker scaffolding written by intermediate script versions (the # `legacy_local_label` and a `legacy_secret_label` trailer that always ran to - # end-of-file), matched EXACTLY — so an already-migrated consumer converts to the - # marker format exactly once, and a look-alike consumer comment is never dropped. + # end-of-file), matched EXACTLY, and any retired baseline negation (see + # `retired_negations`) that would otherwise re-include a now-ignored path — so an + # already-migrated consumer converts to the marker format exactly once, and a + # look-alike consumer comment is never dropped. awk -v bb="$base_begin" -v eb="$base_end" \ -v sb="$secret_begin" -v se="$secret_end" \ -v lb="$local_begin" -v le="$local_end" \ - -v lll="$legacy_local_label" -v lsl="$legacy_secret_label" ' + -v lll="$legacy_local_label" -v lsl="$legacy_secret_label" \ + -v rn="$retired_negations" ' + BEGIN { k = split(rn, r, "\n"); for (i = 1; i <= k; i++) if (r[i] != "") retired[r[i]] = 1 } $0 == bb { inb = 1; next } inb { if ($0 == eb) inb = 0; next } $0 == sb { ins = 1; next } @@ -149,6 +163,7 @@ if grep -qxF "$base_begin" "$dest"; then legacy { next } $0 == lb || $0 == le { next } $0 == lll { next } + $0 in retired { next } { print } ' "$dest" | trim_blank_edges > "$locals" else @@ -156,16 +171,22 @@ else # with the consumer's own lines appended). One-time best-effort bootstrap: drop # lines duplicating a baseline POSITIVE pattern (already in the managed block), # keep every negation (a consumer may re-state an exception such as - # `!gradle-wrapper.jar` after their own broad ignore) and every custom line. - # Keeping negations means a raw baseline copy carries the baseline's OWN - # negations (e.g. `!*.gpg`) into the repo-local block on this one pull — cosmetic - # and harmless (they re-include already-included paths), and dropping them would - # reintroduce the order-sensitive de-dup this rewrite removed, so we don't. - # After this run writes the markers, the exact steady-state path above takes - # over. `|| true`: a pure raw copy leaves no custom lines, and `grep -v` exits 1 - # when it selects nothing — not an error here. + # `!gradle-wrapper.jar` after their own broad ignore) and every custom line — + # EXCEPT a retired baseline negation (see `retired_negations`), which a raw copy + # of an older baseline still carries and which, kept in repo-local, would + # re-include a path the current baseline now ignores (e.g. `!.idea/misc.xml`). + # Keeping the other negations means a raw baseline copy also carries the baseline's + # still-current OWN negations (e.g. `!*.gpg`) into the repo-local block on this one + # pull — cosmetic and harmless (they re-include already-included paths), and dropping + # them wholesale would reintroduce the order-sensitive de-dup this rewrite removed, + # so we drop only the retired set. After this run writes the markers, the exact + # steady-state path above takes over. `|| true`: a pure raw copy leaves no custom + # lines, and `grep -v` exits 1 when it selects nothing — not an error here. { grep -vxF -f <(grep -v '^!' "$src") "$dest" || true; } \ - | awk '!seen[$0]++' | trim_blank_edges > "$locals" + | awk -v rn="$retired_negations" \ + 'BEGIN { k = split(rn, r, "\n"); for (i = 1; i <= k; i++) if (r[i] != "") retired[r[i]] = 1 } + $0 in retired { next } !seen[$0]++' \ + | trim_blank_edges > "$locals" fi # Fail closed: re-asserting secrets only matters when there ARE repo-local lines