From 44b456e11b52aa7bf4918b93517fceb8421f24aa Mon Sep 17 00:00:00 2001 From: daymade Date: Sat, 29 Aug 2026 22:06:30 +0800 Subject: [PATCH] fix(repo): activate versioned mainline hook dispatchers (#395) Wire the repository guard into real commit/push dispatchers, preserve the shared PII guard with exact stdin replay, cover extensionless hooks in CI, and pin linked worktrees to the canonical primary hook path. --- .githooks/pre-commit | 19 +++++++- .githooks/pre-push | 26 +++++++++++ CHANGELOG.md | 1 + CLAUDE.md | 14 ++++-- scripts/ci/check_shell_syntax.sh | 14 +++--- tests/test_git_mainline_guard.py | 76 ++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 12 deletions(-) create mode 100755 .githooks/pre-push diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 9bfad8a..9220828 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,9 +1,24 @@ #!/bin/bash -# Pre-commit hook: scan staged changes for sensitive data -# Install: git config core.hooksPath .githooks +# Repository dispatcher: block direct/stale mainline work, then preserve the +# maintainer's shared PII guard when installed. Contributors without that shared +# guard still receive the local fallback scan below. set -euo pipefail +REPO_ROOT="$(git rev-parse --show-toplevel)" +HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$REPO_ROOT" + +node "$REPO_ROOT/scripts/git-mainline-guard.mjs" pre-commit + +GLOBAL_GUARD_DIR="${GIT_PII_GUARD_DIR:-${HOME:-}/scripts/git-pii-guard}" +if [ -x "$GLOBAL_GUARD_DIR/pre-commit" ]; then + GLOBAL_HOOK_DIR="$(cd "$(dirname "$GLOBAL_GUARD_DIR/pre-commit")" && pwd)" + if [ "$GLOBAL_HOOK_DIR" != "$HOOK_DIR" ]; then + exec "$GLOBAL_GUARD_DIR/pre-commit" + fi +fi + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..5dbd802 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,26 @@ +#!/bin/bash +# Repository dispatcher: replay the exact pre-push update set to both the +# mainline/version guard and the maintainer's shared PII guard. + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$REPO_ROOT" + +HOOK_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/claude-code-skills-pre-push.XXXXXX")" +trap 'rm -rf -- "$HOOK_TMP_DIR"' EXIT +UPDATE_INPUT="$HOOK_TMP_DIR/updates" +cat >"$UPDATE_INPUT" + +node "$REPO_ROOT/scripts/git-mainline-guard.mjs" pre-push "$@" <"$UPDATE_INPUT" + +GLOBAL_GUARD_DIR="${GIT_PII_GUARD_DIR:-${HOME:-}/scripts/git-pii-guard}" +if [ -x "$GLOBAL_GUARD_DIR/pre-push" ]; then + GLOBAL_HOOK_DIR="$(cd "$(dirname "$GLOBAL_GUARD_DIR/pre-push")" && pwd)" + if [ "$GLOBAL_HOOK_DIR" != "$HOOK_DIR" ]; then + "$GLOBAL_GUARD_DIR/pre-push" "$@" <"$UPDATE_INPUT" + fi +else + echo "WARNING: shared PII pre-push guard is unavailable; only the repository mainline guard ran." >&2 +fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 675d6b7..65f526c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Local history skills** (`daymade-claude-code` v2.0.0; marketplace v2.0.0): replace three overlapping/ambiguous entry points with four provider-and-action-specific Skills: `claude-code-history-files-finder` → `read-claude-code-history`, `local-conversation-history` → `read-codex-history`, `continue-claude-work` → `continue-claude-code-work`, while `continue-codex-work` keeps its name. The two readers now own all parsing and remain evidence-only; the two continuation Skills consume verified read receipts, rebuild the original business outcome / unfulfilled requests / user corrections / proven prior assets, and only then execute a next action that directly reduces the outstanding result. `read-codex-history` keeps prompt ledger, state DB and rollout JSONL as separate evidence surfaces, adds exact raw-input tables, Codex-only search, verified selected identity and exact fork/compaction lineage, and rejects fused rollouts containing multiple `session_meta` IDs. `read-claude-code-history` adds recent inventory, chronological Session evidence with queued human prompts in place, original-word export grouped by Session, full-event search, hybrid recall, triage and deleted-file recovery. Exact readers now parse every physical record rather than a resume-oriented tail, retain every selected human and assistant text turn so a middle successful asset cannot disappear, cover registered Claude archives, require one matching record-level identity instead of trusting a filename, reject fused identities and divergent physical copies, choose a strict append-only live/archive superset without traversal-order last-wins, skip healthy blank separators, and fail visibly on malformed JSONL. The parser implementation and format references moved from the continuation bundles into their owning read bundles; all old runtime instructions remain directly reachable in migration references, including the legacy Kimi branch, rather than being silently deleted. Existing users should run `claude plugin marketplace update daymade-skills`, update/reinstall `daymade-claude-code@daymade-skills`, and replace old slash invocations with the mapping above. Verification: strict validation for all four Skills; Claude-reader and Codex-reader regression counts are recorded by the current test run; a real Claude active/archive replay retains the pre-compaction chronology, one real Codex continuation replay recovers the corrected second-attachment referent, and a separate misbound Session replay fails explicitly as a fused two-identity rollout instead of attributing another Session's events to it. ### Fixed +- **Repository mainline guard:** make the local enforcement path real instead of documentary. The versioned pre-commit/pre-push dispatchers now invoke the mainline/version guard, replay the exact pre-push update set to the existing shared PII guard, and retain a local pre-commit PII fallback for contributors without the maintainer setup. Deterministic fixtures prove direct commits and pushes to `main` are blocked and that the shared pre-push scanner receives byte-identical input. The shared worktree config must point at the canonical primary checkout's **absolute** `.githooks` path; a relative path would let each stale worktree select its own stale dispatcher. - **claude-switch-models-setup** (`daymade-claude-code` v3.6.1; marketplace v3.4.1): finish the documentation side of the managed-source expansion. `CLAUDE.md` now names the implementation and `--print-watch-paths` as the authority instead of inviting another copied inventory; the architecture reference removes hand-maintained marketplace, checkout, and watcher-manifest lists; and the Skill, troubleshooting guide, and both READMEs stop persisting script/alias counts that are already derivable from their explicit lists. No runtime behavior changes. - **git-safety-net** (v1.12.0): make independent-clone retirement preserve more than a clean working tree. Checkout discovery now exposes `objects/info/alternates` borrowing instead of presenting a `git clone --shared` checkout as ordinary owned storage. A new non-destructive `git_prepare_clone_retirement.sh` refuses tracked/untracked/ignored bytes, stashes, shallow history, reflog-only commits, every clone-only unreachable Git object, partial/promisor clones, attached linked worktrees, local submodule repositories, known clone-private Git LFS/annex object stores, tracked content filters, unresolved repository-local config includes/custom hook paths, and mismatched survivor identity before creating a backup. It disables lazy fetch, repository fsmonitor, optional index refresh, and untracked-cache writes; freezes every ref tip plus symbolic-ref topology (including stale remote-tracking refs and `HEAD`); builds a no-prerequisite all-refs bundle; preserves reflog identities plus config/hooks/info bytes/types/modes; and binds bundle and metadata archives to SHA-256 receipts. `--verify-current` rechecks refs/symrefs, reflog, metadata, physical state, linked-worktree/submodule/promisor/extension-store inventory, unreachable objects, and artifact digests. The retirement contract defaults to an explicitly authorized recoverable quarantine/OS Trash move, keeps permanent deletion separate, and freezes the absent destination plus process occupancy before the final verification so the no-clobber quarantine can be the next operation instead of leaving an avoidable post-verification probe. Thirty-six deterministic regressions cover shared-object discovery, empty-repository restore plus symbolic-ref replay, dirty/ignored/stash gates, shallow and unreachable history/object types, promisor non-mutation, extension-store/filter refusal, fsmonitor non-execution, linked-worktree/submodule refusal, config/hook indirection, hook-mode/ref/metadata races, and archive tampering. - **skill-creator** (`daymade-skill` v1.32.0): replace the high-frequency `uv run --with` overlays used by bundled tooling with one locked, project-local uv environment. `pyproject.toml` and `uv.lock` pin PyYAML, tiktoken, and pytest; all normal tool invocations now use `uv run --frozen`, so caller projects remain isolated while package data still comes from uv's shared global cache. The runtime contract explicitly rejects a cross-project `UV_PROJECT_ENVIRONMENT`, project-specific cache roots, and cache cleanup as part of ordinary Skill execution. The full local suite passes (`181 passed, 7 subtests`) and the validator self-test passes 27/27. diff --git a/CLAUDE.md b/CLAUDE.md index 9579091..695c0d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,9 +205,15 @@ Squash-merged PRs rewrite commits under new SHAs, so every direct commit to local `main` guarantees divergence the moment its PR merges. Two rules keep `main` clean: -`scripts/git-mainline-guard.mjs` enforces this at commit/push time and also -rejects stale marketplace manifests or reused plugin versions against current -`origin/main`; CI runs the same version-progression check on every PR. +`.githooks/pre-commit` and `.githooks/pre-push` dispatch to +`scripts/git-mainline-guard.mjs`, which rejects direct local-main work and stale +marketplace manifests or reused plugin versions against current main. The +dispatchers preserve the shared PII guard when it is installed. Activate this +repository **from the canonical primary main checkout** with +`git config core.hooksPath "$(pwd -P)/.githooks"`. The absolute path matters: +`core.hooksPath` is shared by linked worktrees, so a relative path would let a +stale feature worktree select its own stale dispatcher. CI and the GitHub main +ruleset independently require the same release checks on every PR. 1. **Never commit directly to local `main`.** All work starts on a feature branch (`git checkout -b `), ships via PR, and lands by squash merge. @@ -268,7 +274,7 @@ Skills for public distribution must NOT contain: 4. **gitleaks** (`.gitleaks.toml`) — deep scan with custom rules for this repo 5. **AI semantic read-through** (the gate the other four structurally cannot be) — layers 1-4 are keyword/regex/gitleaks: they only match patterns someone listed, and are blind to private content with **no keyword** — a real name in another language (gitleaks doesn't cover CJK), a verbatim line from a real transcript, a real example dropped into an illustration. Before publishing, **read the whole skill yourself and judge each concrete name/example/snippet semantically** ("generic placeholder / public entity, or lifted from a real project / person / transcript?"). A green scan is **not** a clean bill of health; "grep found nothing" only means your word list didn't fire. Method: [`daymade-skill/skill-creator/references/sanitization_checklist.md`](./daymade-skill/skill-creator/references/sanitization_checklist.md). -PII Guard is enabled via `~/scripts/git-pii-guard/manage.sh enable `, which sets `core.hooksPath` to `~/scripts/git-pii-guard`. +Most repositories enable PII Guard via `~/scripts/git-pii-guard/manage.sh enable `. This repository instead points `core.hooksPath` at the canonical primary checkout's absolute `.githooks` directory: its versioned dispatchers run the repository mainline guard and then delegate to the same shared PII guard when installed. For repo-specific additions: - `.pii-patterns` — extra content regexes - `.pii-path-patterns` — extra forbidden path regexes diff --git a/scripts/ci/check_shell_syntax.sh b/scripts/ci/check_shell_syntax.sh index 17a648c..5e3b3b1 100755 --- a/scripts/ci/check_shell_syntax.sh +++ b/scripts/ci/check_shell_syntax.sh @@ -26,12 +26,14 @@ while IFS= read -r script; do printf '%s\n' "$output" | sed 's/^/ /' fi done < <( - find . -name '*.sh' -type f \ - -not -path './.git/*' \ - -not -path '*/node_modules/*' \ - -not -path '*/.venv/*' \ - -not -path '*/vendor/*' \ - | sort + { + find . -name '*.sh' -type f \ + -not -path './.git/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/.venv/*' \ + -not -path '*/vendor/*' + find .githooks -maxdepth 1 -type f 2>/dev/null + } | sort -u ) if [ "$failed" -gt 0 ]; then diff --git a/tests/test_git_mainline_guard.py b/tests/test_git_mainline_guard.py index db37833..831ea77 100644 --- a/tests/test_git_mainline_guard.py +++ b/tests/test_git_mainline_guard.py @@ -12,6 +12,8 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] GUARD_SOURCE = REPO_ROOT / "scripts/git-mainline-guard.mjs" CHECKER_SOURCE = REPO_ROOT / "scripts/ci/check_version_progression.py" +PRE_COMMIT_SOURCE = REPO_ROOT / ".githooks/pre-commit" +PRE_PUSH_SOURCE = REPO_ROOT / ".githooks/pre-push" def run( @@ -49,10 +51,13 @@ class MainlineGuardTests(unittest.TestCase): run(self.repo, "git", "config", "user.email", "test@example.invalid") run(self.repo, "git", "config", "user.name", "Test") (self.repo / "scripts/ci").mkdir(parents=True) + (self.repo / ".githooks").mkdir() (self.repo / ".claude-plugin").mkdir() (self.repo / "daymade-audio/transcript-fixer").mkdir(parents=True) shutil.copy2(GUARD_SOURCE, self.repo / "scripts/git-mainline-guard.mjs") shutil.copy2(CHECKER_SOURCE, self.repo / "scripts/ci/check_version_progression.py") + shutil.copy2(PRE_COMMIT_SOURCE, self.repo / ".githooks/pre-commit") + shutil.copy2(PRE_PUSH_SOURCE, self.repo / ".githooks/pre-push") (self.repo / ".claude-plugin/marketplace.json").write_text( json.dumps( { @@ -104,6 +109,22 @@ class MainlineGuardTests(unittest.TestCase): check=False, ) + def hook( + self, + name: str, + input_text: str | None = None, + *extra: str, + env: dict[str, str] | None = None, + ): + return run( + self.repo, + str(self.repo / ".githooks" / name), + *extra, + input_text=input_text, + env=env or self.env, + check=False, + ) + def test_pre_commit_blocks_main(self) -> None: result = self.guard("pre-commit") self.assertEqual(result.returncode, 1) @@ -200,6 +221,61 @@ class MainlineGuardTests(unittest.TestCase): self.assertEqual(result.returncode, 1) self.assertIn("version did not strictly increase", result.stderr) + def test_versioned_pre_commit_dispatcher_blocks_local_main(self) -> None: + env = self.env.copy() + env["GIT_PII_GUARD_DIR"] = str(self.root / "missing-global-guard") + result = self.hook("pre-commit", env=env) + self.assertEqual(result.returncode, 1) + self.assertIn("read-only runtime mirror", result.stderr) + + def test_versioned_pre_push_dispatcher_blocks_direct_main(self) -> None: + env = self.env.copy() + env["GIT_PII_GUARD_DIR"] = str(self.root / "missing-global-guard") + sha = run(self.repo, "git", "rev-parse", "HEAD").stdout.strip() + line = f"refs/heads/main {sha} refs/heads/main {sha}\n" + result = self.hook("pre-push", line, "origin", str(self.remote), env=env) + self.assertEqual(result.returncode, 1) + self.assertIn("direct pushes to main are forbidden", result.stderr) + + def test_pre_push_dispatcher_replays_input_to_global_pii_guard(self) -> None: + run(self.repo, "git", "switch", "-qc", "feature") + (self.repo / "README.md").write_text("docs\n", encoding="utf-8") + run(self.repo, "git", "add", "README.md") + run(self.repo, "git", "commit", "-qm", "docs") + sha = run(self.repo, "git", "rev-parse", "HEAD").stdout.strip() + line = f"refs/heads/feature {sha} refs/heads/feature {'0' * 40}\n" + + fake_guard = self.root / "fake-global-guard" + fake_guard.mkdir() + capture = self.root / "captured-push-input" + fake_hook = fake_guard / "pre-push" + fake_hook.write_text( + "#!/bin/sh\nset -eu\ncat >\"$HOOK_CAPTURE_PATH\"\n", + encoding="utf-8", + ) + fake_hook.chmod(0o755) + env = self.env.copy() + env["GIT_PII_GUARD_DIR"] = str(fake_guard) + env["HOOK_CAPTURE_PATH"] = str(capture) + + result = self.hook("pre-push", line, "origin", str(self.remote), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(capture.read_text(encoding="utf-8"), line) + + def test_pre_push_dispatcher_warns_when_shared_pii_guard_is_missing(self) -> None: + run(self.repo, "git", "switch", "-qc", "feature") + (self.repo / "README.md").write_text("docs\n", encoding="utf-8") + run(self.repo, "git", "add", "README.md") + run(self.repo, "git", "commit", "-qm", "docs") + sha = run(self.repo, "git", "rev-parse", "HEAD").stdout.strip() + line = f"refs/heads/feature {sha} refs/heads/feature {'0' * 40}\n" + env = self.env.copy() + env["GIT_PII_GUARD_DIR"] = str(self.root / "missing-global-guard") + + result = self.hook("pre-push", line, "origin", str(self.remote), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("shared PII pre-push guard is unavailable", result.stderr) + if __name__ == "__main__": unittest.main()