@arbiterforge/ca-pi 0.6.0
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.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — installs the git-level enforcement hooks (#161).
|
|
3
|
+
#
|
|
4
|
+
# The PreToolUse Bash hook (pre-bash.py) gates git operations by matching the
|
|
5
|
+
# literal command string, so shell indirection (`g=git; c=commit; $g $c`) walks
|
|
6
|
+
# past it. There is no enforcement below that layer. This module installs
|
|
7
|
+
# repo-level .git/hooks/pre-commit and pre-push that invoke git-enforce.py at the
|
|
8
|
+
# git operation itself, where spelling no longer matters.
|
|
9
|
+
#
|
|
10
|
+
# Design decisions (ADR-0014, resolves #265 / tribunal reliability-009):
|
|
11
|
+
# * The shim is a tiny POSIX `sh` script that detects the interpreter ONCE
|
|
12
|
+
# (python3 else python) and runs the enforcer EXACTLY once — never
|
|
13
|
+
# `python3 X || python X`, which would (a) swallow a BLOCK when python3 both
|
|
14
|
+
# exists and blocks, and (b) drain stdin before the fallback (pre-push feeds
|
|
15
|
+
# the ref list on stdin). Same hazard hooks.json avoids via two entries; a
|
|
16
|
+
# single hook file must guard it inline.
|
|
17
|
+
# * The shim itself is HOST-NEUTRAL: it embeds no absolute enforcer path at
|
|
18
|
+
# all. It instead points at a shared, non-versioned drop-in directory
|
|
19
|
+
# inside the repo's OWN `.git/`:
|
|
20
|
+
#
|
|
21
|
+
# .git/codearbiter-hooksd/<plugin>.path # e.g. ca.path, ca-codex.path
|
|
22
|
+
#
|
|
23
|
+
# Each installed host writes its OWN current `_enforcer_path()` into its
|
|
24
|
+
# own `<plugin>.path` file every SessionStart (install() below) — a live
|
|
25
|
+
# host self-heals a stale entry on its very next session. The shim runs
|
|
26
|
+
# every resolving enforcer until one blocks; all must allow for Git to
|
|
27
|
+
# proceed. A dead entry from an uninstalled plugin is skipped.
|
|
28
|
+
# `uninstall()` removes only ITS OWN
|
|
29
|
+
# `.path` file — never the shared shim, which a sibling plugin may still
|
|
30
|
+
# depend on.
|
|
31
|
+
# * FAIL CLOSED, not fail-open: if the directory is empty, absent, or every
|
|
32
|
+
# entry it contains names a file that no longer exists, the shim prints a
|
|
33
|
+
# diagnostic to stderr and exits non-zero — it BLOCKS the git operation
|
|
34
|
+
# rather than silently allowing it. This is a deliberate reversal of the
|
|
35
|
+
# single-plugin fail-open era: ADR-0014 records why. Before this drop-in dir
|
|
36
|
+
# existed, the shim embedded ONE absolute enforcer path (whichever plugin's
|
|
37
|
+
# SessionStart ran last), so uninstalling that plugin — or even the OTHER
|
|
38
|
+
# plugin, if IT never got a chance to write its own copy afterward — could
|
|
39
|
+
# silently unwire the git-level backstop for every host. The drop-in dir
|
|
40
|
+
# removes the reason fail-open existed (a plugin no longer has to derive a
|
|
41
|
+
# SIBLING's path — each writes only its own), so the residual failure mode
|
|
42
|
+
# (truly nothing resolves) can safely — and must — fail closed instead.
|
|
43
|
+
# * The drop-in directory itself is resolved via the repo's git COMMON dir
|
|
44
|
+
# (mirrors `git rev-parse --git-common-dir`, resolved without a git spawn
|
|
45
|
+
# when possible — see `_git_common_dir`), never `--git-dir` and never a
|
|
46
|
+
# per-worktree path: a linked worktree's `.git` is a FILE pointing at
|
|
47
|
+
# `<main>/.git/worktrees/<name>`, and the shared hooks/backstop must resolve
|
|
48
|
+
# to the ONE drop-in dir inside the MAIN repo's `.git/`, so every worktree
|
|
49
|
+
# and every host agree on the same directory — a per-worktree drop-in dir
|
|
50
|
+
# would defeat the entire cross-host purpose.
|
|
51
|
+
# * A pre-existing NON-ours hook is NEVER clobbered — we warn loudly and skip,
|
|
52
|
+
# so an existing husky / pre-commit-framework setup is preserved.
|
|
53
|
+
# * Idempotent: an up-to-date ours-hook is left untouched (no churn); a stale
|
|
54
|
+
# ours-hook is refreshed. Because the shim no longer embeds any
|
|
55
|
+
# plugin-specific path, an enforcer-path change (e.g. a version bump moving
|
|
56
|
+
# the install dir) does NOT by itself require rewriting the shim file — only
|
|
57
|
+
# this plugin's own `<plugin>.path` drop-in entry, which install() refreshes
|
|
58
|
+
# unconditionally every session regardless of whether the shim itself needed
|
|
59
|
+
# a rewrite.
|
|
60
|
+
# * A pre-existing NON-ours hook is NEVER clobbered — we warn loudly and skip,
|
|
61
|
+
# so an existing husky / pre-commit-framework setup is preserved.
|
|
62
|
+
# * Idempotent: an up-to-date ours-hook is left untouched (no churn); a stale
|
|
63
|
+
# ours-hook is refreshed.
|
|
64
|
+
# * performance-002 (#194): re-resolving hooks_dir() every SessionStart costs
|
|
65
|
+
# up to two blocking `git` subprocess spawns (config --get core.hooksPath,
|
|
66
|
+
# rev-parse --git-path hooks) even on the common steady-state call where
|
|
67
|
+
# nothing changed. install() first checks a cheap on-disk cache (a single
|
|
68
|
+
# small file read, no git spawn) recording the hooks_dir a prior successful
|
|
69
|
+
# resolution used; if BOTH phase shims at that cached location already
|
|
70
|
+
# match what we'd install right now, it returns immediately. Any mismatch
|
|
71
|
+
# or absence (including a genuinely fresh/cold repo) falls through to the
|
|
72
|
+
# full git-based probe unchanged — the cache is a pure latency optimization,
|
|
73
|
+
# never load-bearing for correctness.
|
|
74
|
+
#
|
|
75
|
+
# CRITICAL fix (security review, post-#194): the fast path must NEVER trust
|
|
76
|
+
# a cached hooks_dir without CHEAPLY (no git spawn) proving the EFFECTIVE
|
|
77
|
+
# hooks dir has not moved since that cache was written. The original cut
|
|
78
|
+
# only re-checked that the shims AT the cached location were current — it
|
|
79
|
+
# never re-checked that git would still read hooks FROM that location. A
|
|
80
|
+
# LOCAL core.hooksPath change after the cache was written (the realistic
|
|
81
|
+
# case: the user later adopts husky / pre-commit-framework, which set
|
|
82
|
+
# `core.hooksPath` in `.git/config`) left the fast path returning `[]`
|
|
83
|
+
# (success) while the NEW hooks dir got no codeArbiter shim at all — the
|
|
84
|
+
# #161 backstop silently unwired. Fixed: the fast path now ALSO requires
|
|
85
|
+
# (a) the cached dir be exactly the DEFAULT `<root>/.git/hooks` (never a
|
|
86
|
+
# cached custom hooksPath — those must always re-confirm via git, since a
|
|
87
|
+
# custom path is exactly the kind of thing that gets repointed), and (b) a
|
|
88
|
+
# direct read of `.git/config` (and `.git/config.worktree`, for
|
|
89
|
+
# extensions.worktreeConfig repos) positively CONFIRMS no local
|
|
90
|
+
# core.hooksPath key is set. Any read failure, parse ambiguity, or a
|
|
91
|
+
# detected key falls through to the full git-based probe — fail direction
|
|
92
|
+
# is "install when unsure," never "skip when unsure."
|
|
93
|
+
#
|
|
94
|
+
# Documented residual (accepted, not cheaply closable): a GLOBAL/SYSTEM
|
|
95
|
+
# core.hooksPath set AFTER a default-location install is not caught by the
|
|
96
|
+
# `.git/config` read alone. This covers ALL of git's global/system config
|
|
97
|
+
# locations, not just `~/.gitconfig`: `~/.config/git/config` (or
|
|
98
|
+
# `$XDG_CONFIG_HOME/git/config`), and a `$GIT_CONFIG_GLOBAL`/
|
|
99
|
+
# `$GIT_CONFIG_SYSTEM` env override repointing the file entirely. The cache
|
|
100
|
+
# is keyed on the mtime of `~/.gitconfig` AND the XDG path (below), which
|
|
101
|
+
# closes the common case of a later edit to either of those two files; a
|
|
102
|
+
# `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` env override, or an edit to
|
|
103
|
+
# `/etc/gitconfig`, is not cheaply detectable from a fixed path and remains
|
|
104
|
+
# residual. This is rare (those overrides predating a later default-location
|
|
105
|
+
# install is the unusual order), and a cold/first install always resolves
|
|
106
|
+
# it correctly via the full git-based probe regardless.
|
|
107
|
+
|
|
108
|
+
import json
|
|
109
|
+
import os
|
|
110
|
+
import re
|
|
111
|
+
import stat
|
|
112
|
+
import subprocess
|
|
113
|
+
import sys
|
|
114
|
+
|
|
115
|
+
import _hooklib
|
|
116
|
+
from _durabilitylib import is_ephemeral_path
|
|
117
|
+
from _gitexec import git_executable, trusted_git_executable, trusted_python_executable
|
|
118
|
+
|
|
119
|
+
SENTINEL = (
|
|
120
|
+
"# codeArbiter-managed git hook (#161) — this SHIM is refreshed by any live "
|
|
121
|
+
"host's session (it is host-neutral, ADR-0014); the plugin-specific enforcer "
|
|
122
|
+
"entries it dispatches to (.git/codearbiter-hooksd/*.path) each self-heal "
|
|
123
|
+
"only on THAT plugin's own next session (#556) — edits here are overwritten."
|
|
124
|
+
)
|
|
125
|
+
PHASES = ("pre-commit", "pre-push")
|
|
126
|
+
# The hooks_dir() resolution cache lives INSIDE .git/ itself (never under
|
|
127
|
+
# .codearbiter/): a linked worktree's `.git` is a FILE (not a directory)
|
|
128
|
+
# pointing at the real gitdir elsewhere, so os.path.isdir(...) on it is
|
|
129
|
+
# naturally False there — the cache silently declines to engage and every call
|
|
130
|
+
# falls through to the full probe, rather than ever risking a wrong-repo guess.
|
|
131
|
+
_HOOKSDIR_CACHE_NAME = "codearbiter-hooksdir-cache"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _warn(msg):
|
|
135
|
+
print(f"codeArbiter git-hooks: {msg}", file=sys.stderr)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _git(args, cwd):
|
|
139
|
+
try:
|
|
140
|
+
return subprocess.run(
|
|
141
|
+
[git_executable()] + args, cwd=cwd, capture_output=True, text=True,
|
|
142
|
+
encoding="utf-8", errors="replace", timeout=5,
|
|
143
|
+
)
|
|
144
|
+
except Exception: # noqa: BLE001
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def hooks_dir(root):
|
|
149
|
+
"""The directory git actually reads hooks from for `root`, or None.
|
|
150
|
+
|
|
151
|
+
Honors core.hooksPath (when set, git IGNORES .git/hooks entirely), and
|
|
152
|
+
resolves the real git dir via `rev-parse --git-path hooks` so linked
|
|
153
|
+
worktrees and submodules land in the right place. Falls back to
|
|
154
|
+
<root>/.git/hooks only if git can't answer."""
|
|
155
|
+
cfg = _git(["config", "--get", "core.hooksPath"], root)
|
|
156
|
+
if cfg is not None and cfg.returncode == 0 and cfg.stdout.strip():
|
|
157
|
+
hp = cfg.stdout.strip()
|
|
158
|
+
return hp if os.path.isabs(hp) else os.path.join(root, hp)
|
|
159
|
+
gp = _git(["rev-parse", "--git-path", "hooks"], root)
|
|
160
|
+
if gp is not None and gp.returncode == 0 and gp.stdout.strip():
|
|
161
|
+
hp = gp.stdout.strip()
|
|
162
|
+
return hp if os.path.isabs(hp) else os.path.join(root, hp)
|
|
163
|
+
default = os.path.join(root, ".git", "hooks")
|
|
164
|
+
return default if os.path.isdir(os.path.join(root, ".git")) else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _enforcer_path():
|
|
168
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "git-enforce.py")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _plugin_name():
|
|
172
|
+
"""A stable per-plugin identifier for THIS install's drop-in `.path`
|
|
173
|
+
filename (ADR-0014). Host caches insert a version directory between the
|
|
174
|
+
plugin name and `hooks/`, so the package-directory basename is not stable.
|
|
175
|
+
Prefer the shipped host manifest. A damaged versioned cache falls back to
|
|
176
|
+
its parent plugin directory; a source-tree install falls back to its
|
|
177
|
+
package-directory basename. Every returned key is filename-safe."""
|
|
178
|
+
hooks_dir_path = os.path.dirname(os.path.abspath(__file__))
|
|
179
|
+
package_dir = os.path.dirname(hooks_dir_path)
|
|
180
|
+
manifests = (
|
|
181
|
+
os.path.join(package_dir, ".claude-plugin", "plugin.json"),
|
|
182
|
+
os.path.join(package_dir, ".codex-plugin", "plugin.json"),
|
|
183
|
+
os.path.join(package_dir, "package.json"),
|
|
184
|
+
)
|
|
185
|
+
safe_name = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
186
|
+
for manifest in manifests:
|
|
187
|
+
try:
|
|
188
|
+
with open(manifest, encoding="utf-8") as f:
|
|
189
|
+
name = json.load(f).get("name")
|
|
190
|
+
except (OSError, UnicodeError, json.JSONDecodeError, AttributeError):
|
|
191
|
+
continue
|
|
192
|
+
if isinstance(name, str) and safe_name.fullmatch(name):
|
|
193
|
+
return name
|
|
194
|
+
|
|
195
|
+
package_name = os.path.basename(package_dir)
|
|
196
|
+
if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9._-]+)?", package_name):
|
|
197
|
+
package_name = os.path.basename(os.path.dirname(package_dir))
|
|
198
|
+
return package_name if safe_name.fullmatch(package_name or "") else "plugin"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_DROPIN_DIRNAME = "codearbiter-hooksd"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _git_common_dir(root):
|
|
205
|
+
"""The directory `git rev-parse --git-common-dir` would report for
|
|
206
|
+
`root` — resolved WITHOUT a git spawn whenever the on-disk layout is
|
|
207
|
+
cheaply readable, falling back to a real git spawn only when it isn't.
|
|
208
|
+
|
|
209
|
+
Deliberately mirrors --git-common-dir, NOT --git-dir: a linked worktree's
|
|
210
|
+
`.git` is a FILE (not a directory) holding a `gitdir: <path>` pointer into
|
|
211
|
+
`<main>/.git/worktrees/<name>`, and THAT directory in turn holds a
|
|
212
|
+
`commondir` file naming the real, SHARED main `.git`. Every worktree of a
|
|
213
|
+
repo must resolve to the SAME common dir here, or the #265 drop-in dir
|
|
214
|
+
would fork per-worktree and defeat the entire cross-host purpose (a shim
|
|
215
|
+
installed from worktree A would never see an entry written from
|
|
216
|
+
worktree B). The main-repo case (`.git` is a directory) needs no spawn at
|
|
217
|
+
all: it IS its own common dir. Returns None if nothing resolves — callers
|
|
218
|
+
must treat that as "can't place the drop-in dir right now" and never
|
|
219
|
+
invent a per-worktree fallback."""
|
|
220
|
+
git_path = os.path.join(root, ".git")
|
|
221
|
+
if os.path.isdir(git_path):
|
|
222
|
+
return os.path.abspath(git_path)
|
|
223
|
+
if os.path.isfile(git_path):
|
|
224
|
+
text = _read(git_path)
|
|
225
|
+
if text:
|
|
226
|
+
for line in text.splitlines():
|
|
227
|
+
line = line.strip()
|
|
228
|
+
if line.lower().startswith("gitdir:"):
|
|
229
|
+
wt_gitdir = line.split(":", 1)[1].strip()
|
|
230
|
+
if not os.path.isabs(wt_gitdir):
|
|
231
|
+
wt_gitdir = os.path.normpath(os.path.join(root, wt_gitdir))
|
|
232
|
+
cd_text = _read(os.path.join(wt_gitdir, "commondir"))
|
|
233
|
+
if cd_text:
|
|
234
|
+
cd = cd_text.strip()
|
|
235
|
+
common = (cd if os.path.isabs(cd)
|
|
236
|
+
else os.path.normpath(os.path.join(wt_gitdir, cd)))
|
|
237
|
+
return os.path.abspath(common)
|
|
238
|
+
break
|
|
239
|
+
r = _git(["rev-parse", "--git-common-dir"], root)
|
|
240
|
+
if r is not None and r.returncode == 0 and r.stdout.strip():
|
|
241
|
+
out = r.stdout.strip()
|
|
242
|
+
return os.path.abspath(out if os.path.isabs(out) else os.path.join(root, out))
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _dropin_dir(root):
|
|
247
|
+
"""The shared, non-versioned drop-in directory (ADR-0014) each installed
|
|
248
|
+
host writes its own `<plugin>.path` entry into. Lives inside the repo's
|
|
249
|
+
git COMMON dir (never a per-worktree one — see `_git_common_dir`) so
|
|
250
|
+
every worktree and every host share exactly ONE directory. Returns None
|
|
251
|
+
when the common dir itself can't be resolved (no git dir at all)."""
|
|
252
|
+
common = _git_common_dir(root)
|
|
253
|
+
return os.path.join(common, _DROPIN_DIRNAME) if common else None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _path_entry_file(dropin_dir, plugin):
|
|
257
|
+
return os.path.join(dropin_dir, f"{plugin}.path")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _shell_path(path):
|
|
261
|
+
"""Render an absolute native path for the POSIX sh git-hook boundary.
|
|
262
|
+
|
|
263
|
+
Git for Windows executes these hooks through its POSIX shell. Native
|
|
264
|
+
backslashes are ordinary characters there, so both globbing the drop-in
|
|
265
|
+
directory and testing an enforcer entry would fail closed even though the
|
|
266
|
+
Windows files exist. Forward slashes remain valid to Windows Python and
|
|
267
|
+
Git while also being unambiguous to the shell.
|
|
268
|
+
"""
|
|
269
|
+
return path.replace("\\", "/")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _path_entry_current(dropin_dir, plugin, enforcer):
|
|
273
|
+
"""True iff this plugin's own drop-in entry already names `enforcer`."""
|
|
274
|
+
existing = _read(_path_entry_file(dropin_dir, plugin))
|
|
275
|
+
return existing is not None and existing.strip() == enforcer
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _write_path_entry(dropin_dir, plugin, enforcer):
|
|
279
|
+
"""Best-effort (never fatal) write/refresh of this plugin's OWN
|
|
280
|
+
`<plugin>.path` drop-in entry — the self-heal half of ADR-0014: a live
|
|
281
|
+
host rewrites its own entry every SessionStart regardless of whether the
|
|
282
|
+
shared shim itself needed any change, so a stale entry from a version
|
|
283
|
+
bump never outlives one session on a live install.
|
|
284
|
+
|
|
285
|
+
#441: that self-heal must never pin an EPHEMERAL enforcer. The drop-in dir
|
|
286
|
+
lives in the git COMMON dir, so every linked worktree writes the MAIN
|
|
287
|
+
repository's entry (`TestDropInSharedDir` proves the sharing, and it is the
|
|
288
|
+
point of ADR-0014). A session started inside a worktree — subagents do this
|
|
289
|
+
routinely — therefore repoints the main repo's enforcer at a directory that
|
|
290
|
+
vanishes the moment that worktree is pruned, which is what worktrees are
|
|
291
|
+
for.
|
|
292
|
+
|
|
293
|
+
Losing this entry is SILENT gate loss, not a visible break like #438's
|
|
294
|
+
statusline: H-01, H-03, H-05, H-09b, H-10b, H-11 and H-19 all run through
|
|
295
|
+
the enforcer named here, and a repo that loses it keeps looking governed.
|
|
296
|
+
So an ephemeral enforcer is refused outright, leaving whatever durable entry
|
|
297
|
+
is already there untouched — a stale-but-durable enforcer still enforces, an
|
|
298
|
+
absent one does not.
|
|
299
|
+
|
|
300
|
+
A stale but DURABLE path is still refreshed exactly as before; the guard is
|
|
301
|
+
not a kill-switch. `is_ephemeral_path` fails toward "durable", so an
|
|
302
|
+
unreadable or unrecognised layout keeps the old behaviour rather than
|
|
303
|
+
silently disabling the self-heal."""
|
|
304
|
+
if is_ephemeral_path(enforcer):
|
|
305
|
+
_warn(f"'{plugin}' was loaded from a path that will not outlive this session "
|
|
306
|
+
f"({enforcer}); leaving the shared enforcer entry as it is. Git-level "
|
|
307
|
+
f"enforcement keeps using the previously registered install. Start a "
|
|
308
|
+
f"session from the main checkout to refresh it.")
|
|
309
|
+
return False
|
|
310
|
+
shell_enforcer = _shell_path(enforcer)
|
|
311
|
+
if _path_entry_current(dropin_dir, plugin, shell_enforcer):
|
|
312
|
+
return True
|
|
313
|
+
try:
|
|
314
|
+
os.makedirs(dropin_dir, exist_ok=True)
|
|
315
|
+
_hooklib.write_text_atomic(
|
|
316
|
+
_path_entry_file(dropin_dir, plugin), shell_enforcer + "\n", newline="\n")
|
|
317
|
+
return True
|
|
318
|
+
except Exception as e: # noqa: BLE001
|
|
319
|
+
_warn(f"could not write drop-in enforcer entry for '{plugin}' at {dropin_dir}: {e}")
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _seen_marker_file(dropin_dir, plugin):
|
|
324
|
+
return os.path.join(dropin_dir, f"{plugin}.seen")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _touch_seen_marker(dropin_dir, plugin, enforcer):
|
|
328
|
+
"""Best-effort (never fatal) freshness heartbeat for `plugin` (#556).
|
|
329
|
+
|
|
330
|
+
Records the SAME enforcer value `_write_path_entry` just confirmed in
|
|
331
|
+
`<plugin>.path` — content-addressed on purpose. The freshness guard below
|
|
332
|
+
only trusts this heartbeat's mtime when its recorded content still
|
|
333
|
+
matches `<plugin>.path`'s CURRENT content; a raw mtime-only heartbeat
|
|
334
|
+
(compared only against `.path`'s own mtime) is a sub-millisecond race on
|
|
335
|
+
some filesystems whenever a `.path` entry is rewritten by something other
|
|
336
|
+
than `install()` shortly after a real install (exactly what several
|
|
337
|
+
existing drop-in tests simulate to probe unrelated behavior) — content
|
|
338
|
+
equality has no such timing dependency.
|
|
339
|
+
|
|
340
|
+
Unlike `<plugin>.path` (which `_write_path_entry` deliberately leaves
|
|
341
|
+
untouched when its content hasn't changed, to avoid churn), THIS file is
|
|
342
|
+
rewritten every live session regardless of whether the `.path` entry
|
|
343
|
+
itself changed — it is the signal a sibling plugin's cache has gone
|
|
344
|
+
stale. A plugin whose host never runs a session again simply stops
|
|
345
|
+
updating its `.seen` file, which is exactly the staleness #556 needs
|
|
346
|
+
surfaced.
|
|
347
|
+
|
|
348
|
+
Callers must skip this for an ephemeral enforcer (mirroring
|
|
349
|
+
`_write_path_entry`'s own refusal) — an ephemeral session confirming
|
|
350
|
+
freshness would be exactly the wrong direction: it would make a
|
|
351
|
+
sibling's genuinely durable, still-correct entry look stale by
|
|
352
|
+
comparison."""
|
|
353
|
+
try:
|
|
354
|
+
os.makedirs(dropin_dir, exist_ok=True)
|
|
355
|
+
_hooklib.write_text_atomic(
|
|
356
|
+
_seen_marker_file(dropin_dir, plugin), _shell_path(enforcer) + "\n", newline="\n")
|
|
357
|
+
except Exception: # noqa: BLE001
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# The freshness probe embedded VERBATIM into every generated shim (via a
|
|
362
|
+
# stdin heredoc, see `_shim()`) AND run identically by `stale_registered_plugins`
|
|
363
|
+
# below for `/ca:doctor` (#556, AC-3). Deliberately a single string constant
|
|
364
|
+
# run in BOTH places rather than two hand-kept implementations: the shim
|
|
365
|
+
# cannot import any plugin's `_githooks.py` to get this logic (whichever
|
|
366
|
+
# plugin's copy it picked could itself be the stale one this guard exists to
|
|
367
|
+
# distrust — the exact #556 failure, one level up), so it must be entirely
|
|
368
|
+
# self-contained text that the CURRENTLY installing (never stale — the shim
|
|
369
|
+
# file itself is regenerated by whatever live host runs `install()`, see
|
|
370
|
+
# SENTINEL) session bakes in. `/ca:doctor` runs the SAME text as a real
|
|
371
|
+
# subprocess instead of a parallel port, so the two can never drift.
|
|
372
|
+
#
|
|
373
|
+
# Algorithm: a plugin's registered entry is "stale" — printed to stdout, one
|
|
374
|
+
# per line — iff (a) at least one OTHER registered entry in the same
|
|
375
|
+
# drop-in dir has recorded a `.seen` heartbeat, AND (b) this plugin's own
|
|
376
|
+
# heartbeat is either absent or strictly older than the freshest one seen.
|
|
377
|
+
# When NOBODY has ever recorded a heartbeat (a repo that predates #556, or
|
|
378
|
+
# every registered plugin genuinely dormant), nothing is printed — the
|
|
379
|
+
# caller then treats every entry as before this fix (the original, safe
|
|
380
|
+
# fail-closed "run everything" default), never silently disabling
|
|
381
|
+
# enforcement outright.
|
|
382
|
+
_FRESHNESS_PY = (
|
|
383
|
+
"import os, re, sys\n"
|
|
384
|
+
"d = sys.argv[1] if len(sys.argv) > 1 else ''\n"
|
|
385
|
+
"try:\n"
|
|
386
|
+
" names = os.listdir(d)\n"
|
|
387
|
+
"except OSError:\n"
|
|
388
|
+
" names = []\n"
|
|
389
|
+
"legacy = re.compile(r'^[0-9]+\\.[0-9]+\\.[0-9]+$')\n"
|
|
390
|
+
"def _rd(p):\n"
|
|
391
|
+
" try:\n"
|
|
392
|
+
" with open(p, encoding='utf-8', errors='replace') as f:\n"
|
|
393
|
+
" return f.read().strip()\n"
|
|
394
|
+
" except OSError:\n"
|
|
395
|
+
" return None\n"
|
|
396
|
+
"entries = []\n"
|
|
397
|
+
"for n in sorted(names):\n"
|
|
398
|
+
" if not n.endswith('.path'):\n"
|
|
399
|
+
" continue\n"
|
|
400
|
+
" plugin = n[:-len('.path')]\n"
|
|
401
|
+
" if legacy.fullmatch(plugin):\n"
|
|
402
|
+
" continue\n"
|
|
403
|
+
" path_val = _rd(os.path.join(d, n))\n"
|
|
404
|
+
" if path_val is None:\n"
|
|
405
|
+
" continue\n"
|
|
406
|
+
" seen_file = os.path.join(d, plugin + '.seen')\n"
|
|
407
|
+
# `.seen` only counts as a confirmation of what's registered RIGHT NOW when
|
|
408
|
+
# its recorded value still matches `.path`'s CURRENT content -- content
|
|
409
|
+
# equality, never a raw mtime-ordering guess. A `.path` entry rewritten by
|
|
410
|
+
# something other than install() (a version bump landing between two
|
|
411
|
+
# sessions, or -- in this suite's own drop-in fixtures -- a direct
|
|
412
|
+
# overwrite that never re-confirms) leaves a `.seen` file whose value no
|
|
413
|
+
# longer matches, which must NOT count as evidence for the new content: a
|
|
414
|
+
# sub-millisecond mtime race between two nearly-simultaneous writes is not
|
|
415
|
+
# a reliable ordering signal on every filesystem, but string equality has
|
|
416
|
+
# no timing dependency at all.
|
|
417
|
+
" confirmed = None\n"
|
|
418
|
+
" if _rd(seen_file) == path_val:\n"
|
|
419
|
+
" try:\n"
|
|
420
|
+
" confirmed = os.stat(seen_file).st_mtime\n"
|
|
421
|
+
" except OSError:\n"
|
|
422
|
+
" confirmed = None\n"
|
|
423
|
+
" entries.append((plugin, confirmed))\n"
|
|
424
|
+
"known = [m for _, m in entries if m is not None]\n"
|
|
425
|
+
"if known:\n"
|
|
426
|
+
" mx = max(known)\n"
|
|
427
|
+
" for plugin, m in entries:\n"
|
|
428
|
+
" if m is None or m < mx:\n"
|
|
429
|
+
" print(plugin)\n"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def stale_registered_plugins(dropin_dir):
|
|
434
|
+
"""Plugin names whose drop-in `.path` entry the generated shim's
|
|
435
|
+
freshness guard (#556) will SKIP at the next commit/push, because a
|
|
436
|
+
fresher registered sibling exists. Runs `_FRESHNESS_PY` as a real
|
|
437
|
+
subprocess of THIS interpreter — never a hand-kept parallel
|
|
438
|
+
implementation — so `/ca:doctor` (AC-3) and the shim can never disagree.
|
|
439
|
+
|
|
440
|
+
Returns [] when `dropin_dir` doesn't exist, nothing is registered, or
|
|
441
|
+
the probe fails for any reason — a diagnostic must never be able to
|
|
442
|
+
raise into its caller."""
|
|
443
|
+
if not dropin_dir or not os.path.isdir(dropin_dir):
|
|
444
|
+
return []
|
|
445
|
+
try:
|
|
446
|
+
r = subprocess.run(
|
|
447
|
+
[sys.executable, "-", dropin_dir], input=_FRESHNESS_PY,
|
|
448
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
449
|
+
timeout=5,
|
|
450
|
+
)
|
|
451
|
+
except Exception: # noqa: BLE001
|
|
452
|
+
return []
|
|
453
|
+
if r.returncode != 0:
|
|
454
|
+
return []
|
|
455
|
+
return [ln for ln in r.stdout.splitlines() if ln.strip()]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
_TRUSTED_IDENTITY_FILE = "trusted-executables.identity"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _identity_file(dropin_dir):
|
|
462
|
+
return os.path.join(dropin_dir, _TRUSTED_IDENTITY_FILE)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _read_trusted_identity(dropin_dir):
|
|
466
|
+
text = _read(_identity_file(dropin_dir))
|
|
467
|
+
if text is None:
|
|
468
|
+
return None
|
|
469
|
+
lines = text.splitlines()
|
|
470
|
+
if len(lines) != 3 or not lines[2]:
|
|
471
|
+
return None
|
|
472
|
+
python_path, git_path, owner = lines
|
|
473
|
+
if not os.path.isfile(python_path) or not os.path.isfile(git_path):
|
|
474
|
+
return None
|
|
475
|
+
return python_path, git_path, owner
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _refresh_trusted_identity(dropin_dir, plugin):
|
|
479
|
+
"""Persist trusted executables without permitting an identity-less host
|
|
480
|
+
session to downgrade the shared shim back to PATH resolution."""
|
|
481
|
+
trusted_git = trusted_git_executable()
|
|
482
|
+
trusted_python = trusted_python_executable()
|
|
483
|
+
existing = _read_trusted_identity(dropin_dir)
|
|
484
|
+
if trusted_git is None and trusted_python is None:
|
|
485
|
+
return True
|
|
486
|
+
if trusted_git is None or trusted_python is None:
|
|
487
|
+
if existing is not None:
|
|
488
|
+
_warn("trusted executable refresh is incomplete; preserving the prior complete identity")
|
|
489
|
+
return True
|
|
490
|
+
raise RuntimeError("codeArbiter executable identity channel is incomplete")
|
|
491
|
+
values = (_shell_path(trusted_python), _shell_path(trusted_git), plugin)
|
|
492
|
+
if any("\n" in value or "\r" in value for value in values):
|
|
493
|
+
raise RuntimeError("trusted executable identity contains a newline")
|
|
494
|
+
payload = "\n".join(values) + "\n"
|
|
495
|
+
try:
|
|
496
|
+
os.makedirs(dropin_dir, exist_ok=True)
|
|
497
|
+
_hooklib.write_text_atomic(_identity_file(dropin_dir), payload, newline="\n")
|
|
498
|
+
return True
|
|
499
|
+
except Exception as e: # noqa: BLE001
|
|
500
|
+
if existing is not None:
|
|
501
|
+
_warn(f"could not refresh trusted identity; preserving prior complete identity: {e}")
|
|
502
|
+
return True
|
|
503
|
+
raise RuntimeError(
|
|
504
|
+
f"could not persist trusted executable identity at {dropin_dir}: {e}") from e
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _shim(dropin_dir, phase):
|
|
508
|
+
# Single-interpreter selection preserves stdin (pre-push) and the BLOCK
|
|
509
|
+
# exit code. The shim is HOST-NEUTRAL (ADR-0014): it embeds no plugin-
|
|
510
|
+
# specific enforcer path, only the shared drop-in directory. It iterates
|
|
511
|
+
# every "*.path" entry there and runs every enforcer that resolves AND is
|
|
512
|
+
# not recognized as stale (#556, below) — any non-zero verdict from one of
|
|
513
|
+
# those blocks. A dead entry from an uninstalled plugin is skipped. An
|
|
514
|
+
# unmatched glob (dir absent or
|
|
515
|
+
# empty) leaves `c` as the literal, un-expanded "$D/*.path" string in
|
|
516
|
+
# POSIX `sh` — `[ -f "$c" ]` on that literal correctly fails too, so the
|
|
517
|
+
# loop falls straight through to the same fail-closed tail with no special
|
|
518
|
+
# case needed. When NOTHING resolves, this now FAILS CLOSED: a loud
|
|
519
|
+
# stderr diagnostic and a non-zero exit, blocking the git operation,
|
|
520
|
+
# rather than the old single-plugin era's `exit 0`. When the Pi bridge
|
|
521
|
+
# provides trusted executable identities, install() persists them beside
|
|
522
|
+
# the registry. Identity-less hosts preserve that set, so a later Claude or
|
|
523
|
+
# Codex session cannot downgrade Pi's absolute executable boundary.
|
|
524
|
+
#
|
|
525
|
+
# #556 (AC-1): "any non-zero verdict blocks" used to mean an entry that
|
|
526
|
+
# nobody has refreshed in months — a host cache that predates a fix THIS
|
|
527
|
+
# checkout already carries, e.g. the #279 sensitive-scan exemption — could
|
|
528
|
+
# resurrect an already-closed false positive with no in-session exit but
|
|
529
|
+
# an override. Before running the loop, `$SKIP` is populated (via
|
|
530
|
+
# `_FRESHNESS_PY`, run once here from a heredoc so this is never delegated
|
|
531
|
+
# to any specific plugin's own — possibly stale — `_githooks.py`) with the
|
|
532
|
+
# plugin names whose `.seen` heartbeat (written every live session,
|
|
533
|
+
# unconditionally, by `install()`) is missing or older than a sibling's.
|
|
534
|
+
# Those entries are skipped WITHOUT running their python at all, deferring
|
|
535
|
+
# to whichever registered sibling a live session confirmed more recently.
|
|
536
|
+
# When NO entry anywhere has ever recorded a heartbeat (a repo that
|
|
537
|
+
# predates this fix, or a wholly dormant install), `$SKIP` is empty and
|
|
538
|
+
# every entry runs exactly as before — this can only ever narrow which
|
|
539
|
+
# entries run, never widen it, so a genuine `SEEN=0` fail-closed case is
|
|
540
|
+
# unaffected.
|
|
541
|
+
def quote(value):
|
|
542
|
+
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
543
|
+
|
|
544
|
+
capture = ""
|
|
545
|
+
invoke = f'"$PY" "$E" {phase}\n'
|
|
546
|
+
if phase == "pre-push":
|
|
547
|
+
capture = (
|
|
548
|
+
"PUSH_INPUT=''\n"
|
|
549
|
+
"while IFS= read -r L; do\n"
|
|
550
|
+
" PUSH_INPUT=\"${PUSH_INPUT}${L}\n\"\n"
|
|
551
|
+
"done\n"
|
|
552
|
+
)
|
|
553
|
+
invoke = f'printf \'%s\' "$PUSH_INPUT" | "$PY" "$E" {phase}\n'
|
|
554
|
+
return (
|
|
555
|
+
"#!/bin/sh\n"
|
|
556
|
+
f"{SENTINEL}\n"
|
|
557
|
+
f"D={quote(_shell_path(dropin_dir))}\n"
|
|
558
|
+
f'if [ -e "$D/{_TRUSTED_IDENTITY_FILE}" ] || [ -L "$D/{_TRUSTED_IDENTITY_FILE}" ]; then\n'
|
|
559
|
+
f' [ -f "$D/{_TRUSTED_IDENTITY_FILE}" ] || exit 1\n'
|
|
560
|
+
f' exec 3< "$D/{_TRUSTED_IDENTITY_FILE}" || exit 1\n'
|
|
561
|
+
' IFS= read -r PY <&3 || exit 1\n'
|
|
562
|
+
' IFS= read -r G <&3 || exit 1\n'
|
|
563
|
+
' IFS= read -r IDENTITY_OWNER <&3 || exit 1\n'
|
|
564
|
+
" IDENTITY_EXTRA=''\n"
|
|
565
|
+
' if IFS= read -r IDENTITY_EXTRA <&3 || [ -n "$IDENTITY_EXTRA" ]; then exit 1; fi\n'
|
|
566
|
+
' exec 3<&-\n'
|
|
567
|
+
' [ -n "$IDENTITY_OWNER" ] && [ -f "$PY" ] && [ -f "$G" ] || exit 1\n'
|
|
568
|
+
' export CODEARBITER_GIT_EXECUTABLE="$G"\n'
|
|
569
|
+
' export CODEARBITER_PYTHON_EXECUTABLE="$PY"\n'
|
|
570
|
+
"else\n"
|
|
571
|
+
' if python3 -c "" 2>/dev/null; then PY=python3; else PY=python; fi\n'
|
|
572
|
+
"fi\n"
|
|
573
|
+
f"{capture}"
|
|
574
|
+
# #556: computed once per hook firing, from a literal heredoc (never
|
|
575
|
+
# an `import` of any plugin's own `_githooks.py`) so this stays
|
|
576
|
+
# correct even when every REGISTERED enforcer is stale — only the
|
|
577
|
+
# currently-installing session's freshly generated shim needs to be
|
|
578
|
+
# current for this to work. A crash/empty result here just leaves
|
|
579
|
+
# $SKIP empty (see `[ "$RC" -eq 0 ] || exit "$RC"` below — command
|
|
580
|
+
# substitution failure doesn't abort `sh`), the original run-everything
|
|
581
|
+
# behavior.
|
|
582
|
+
"SKIP=$(\"$PY\" - \"$D\" <<'CODEARBITER_556_FRESHNESS'\n"
|
|
583
|
+
f"{_FRESHNESS_PY}"
|
|
584
|
+
"CODEARBITER_556_FRESHNESS\n"
|
|
585
|
+
")\n"
|
|
586
|
+
"SEEN=0\n"
|
|
587
|
+
'for c in "$D"/*.path; do\n'
|
|
588
|
+
' [ -f "$c" ] || continue\n'
|
|
589
|
+
' N=${c##*/}\n'
|
|
590
|
+
' case "$N" in [0-9]*.[0-9]*.[0-9]*.path) continue ;; esac\n'
|
|
591
|
+
' case " $SKIP " in *" ${N%.path} "*) continue ;; esac\n'
|
|
592
|
+
' IFS= read -r E < "$c" || continue\n'
|
|
593
|
+
' [ -f "$E" ] || continue\n'
|
|
594
|
+
' SEEN=1\n'
|
|
595
|
+
f' {invoke}'
|
|
596
|
+
' RC=$?\n'
|
|
597
|
+
' [ "$RC" -eq 0 ] || exit "$RC"\n'
|
|
598
|
+
'done\n'
|
|
599
|
+
'[ "$SEEN" -eq 0 ] || exit 0\n'
|
|
600
|
+
'echo "codeArbiter: no registered git-enforce.py could be resolved from '
|
|
601
|
+
'\\"$D\\" -- failing CLOSED (#161/#265 git backstop, ADR-0014). Reinstall '
|
|
602
|
+
'codeArbiter, or check .git/codearbiter-hooksd/*.path entries." >&2\n'
|
|
603
|
+
'exit 1\n'
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _read(path):
|
|
608
|
+
try:
|
|
609
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
610
|
+
return f.read()
|
|
611
|
+
except Exception: # noqa: BLE001
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _local_config_paths(root):
|
|
616
|
+
"""git-config files that could define a LOCAL core.hooksPath override for
|
|
617
|
+
`root` — `.git/config` (always checked, even if the file happens to be
|
|
618
|
+
missing — see _confirmed_no_local_hooks_path) plus `.git/config.worktree`
|
|
619
|
+
(extensions.worktreeConfig repos), when it exists. Deliberately excludes
|
|
620
|
+
global/system config — see the module header's documented residual."""
|
|
621
|
+
git_dir = os.path.join(root, ".git")
|
|
622
|
+
return [os.path.join(git_dir, "config"), os.path.join(git_dir, "config.worktree")]
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _confirmed_no_local_hooks_path(root):
|
|
626
|
+
"""True ONLY if a direct, no-git-spawn read of the config file(s) that
|
|
627
|
+
could set a LOCAL core.hooksPath for `root` positively confirms NONE of
|
|
628
|
+
them could possibly do so.
|
|
629
|
+
|
|
630
|
+
GRAMMAR-FREE by design (HIGH-severity fix, second spelling of the same
|
|
631
|
+
skip -> backstop-unwire class): git's config grammar honors a variable on
|
|
632
|
+
the SAME line as its section header (`[core] hooksPath = x`,
|
|
633
|
+
`[core]hooksPath=x`, `[CORE]HooksPath=x` are all valid and honored by real
|
|
634
|
+
git), plus quoting/continuation/case variations — a hand-rolled
|
|
635
|
+
line-oriented section/key parser reliably misses some of these spellings.
|
|
636
|
+
Rather than chase git's config grammar (an unbounded set of spellings),
|
|
637
|
+
this check is a single case-insensitive SUBSTRING scan for `hookspath`
|
|
638
|
+
anywhere in the file, plus a substring scan for an `[include`/`[includeif`
|
|
639
|
+
directive (which could pull a hooksPath in from elsewhere, unfollowed by
|
|
640
|
+
this check). Any occurrence of either substring — even inside a comment —
|
|
641
|
+
or any read failure, returns False (not confirmed). This can never
|
|
642
|
+
UNDER-detect a real hooksPath key (a real key always contains the
|
|
643
|
+
substring "hookspath" case-insensitively, by definition of the git-config
|
|
644
|
+
keyword), so it can only ever be OVER-cautious (an extra, harmless
|
|
645
|
+
git-spawn fall-through on a false positive, e.g. a stray comment
|
|
646
|
+
mentioning the word) — never falsely confirm an override is absent when
|
|
647
|
+
one is actually present. That asymmetry is exactly the fail-direction the
|
|
648
|
+
fast path requires: "install when unsure," never "skip when unsure." A
|
|
649
|
+
simply-ABSENT `config.worktree` is not an error (most repos don't have
|
|
650
|
+
one) and contributes no override, exactly like git itself.
|
|
651
|
+
|
|
652
|
+
This is the fail-direction-critical check (CRITICAL/HIGH fix, post-#194):
|
|
653
|
+
the fast path in install() must never trust a cached hooks_dir without
|
|
654
|
+
this positive confirmation, or a later `core.hooksPath` change (husky /
|
|
655
|
+
pre-commit-framework) would silently leave the NEW hooks dir unwired."""
|
|
656
|
+
for path in _local_config_paths(root):
|
|
657
|
+
if not os.path.isfile(path):
|
|
658
|
+
continue # absent -> no override possible from this file
|
|
659
|
+
text = _read(path)
|
|
660
|
+
if text is None:
|
|
661
|
+
return False # exists but unreadable -> can't confirm -> unsafe to skip
|
|
662
|
+
lowered = text.lower()
|
|
663
|
+
if "hookspath" in lowered:
|
|
664
|
+
return False # ANY spelling/placement/casing -> can't confirm absent
|
|
665
|
+
if "[include" in lowered:
|
|
666
|
+
return False # could pull in a hooksPath from elsewhere -> can't confirm
|
|
667
|
+
return True
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _xdg_git_config_path():
|
|
671
|
+
"""The XDG git global-config path git ALSO reads (lower precedence than
|
|
672
|
+
~/.gitconfig, but still consulted): `$XDG_CONFIG_HOME/git/config`, or
|
|
673
|
+
`~/.config/git/config` when XDG_CONFIG_HOME is unset — matching git's own
|
|
674
|
+
fallback."""
|
|
675
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
676
|
+
return os.path.join(base, "git", "config")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _file_mtime_token(path):
|
|
680
|
+
"""A cheap cache-invalidation token for `path`: its mtime, or the literal
|
|
681
|
+
'absent' if it doesn't exist."""
|
|
682
|
+
try:
|
|
683
|
+
return repr(os.stat(path).st_mtime)
|
|
684
|
+
except OSError:
|
|
685
|
+
return "absent"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _global_gitconfig_mtime_token():
|
|
689
|
+
"""A cheap cache-invalidation token covering BOTH global git-config
|
|
690
|
+
locations codeArbiter can cheaply stat by a fixed path: `~/.gitconfig` and
|
|
691
|
+
the XDG `~/.config/git/config` (or `$XDG_CONFIG_HOME/git/config`).
|
|
692
|
+
Included in the on-disk cache so a LATER edit to either (e.g. adding a
|
|
693
|
+
global core.hooksPath) invalidates a previously-fast-pathable cache
|
|
694
|
+
instead of silently going unnoticed. Does NOT cover a `$GIT_CONFIG_GLOBAL`/
|
|
695
|
+
`$GIT_CONFIG_SYSTEM` env override repointing the file entirely, nor
|
|
696
|
+
`/etc/gitconfig` — see the module header's documented residual."""
|
|
697
|
+
return f"{_file_mtime_token(os.path.join(os.path.expanduser('~'), '.gitconfig'))}|" \
|
|
698
|
+
f"{_file_mtime_token(_xdg_git_config_path())}"
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _cached_hooks_dir(root):
|
|
702
|
+
"""The last hooks_dir() a successful resolution used for `root`, read from
|
|
703
|
+
the on-disk cache — NO git spawn. Returns None (cache miss) if the cache
|
|
704
|
+
file is absent/unreadable/blank/malformed, if it names a directory that no
|
|
705
|
+
longer exists (e.g. deleted between sessions), or if either global
|
|
706
|
+
git-config location (~/.gitconfig, the XDG git config) has changed since
|
|
707
|
+
the cache was written (see _global_gitconfig_mtime_token). A None return
|
|
708
|
+
always falls the caller through to the real git-based hooks_dir() probe."""
|
|
709
|
+
git_dir = os.path.join(root, ".git")
|
|
710
|
+
if not os.path.isdir(git_dir):
|
|
711
|
+
return None
|
|
712
|
+
text = _read(os.path.join(git_dir, _HOOKSDIR_CACHE_NAME))
|
|
713
|
+
if not text:
|
|
714
|
+
return None
|
|
715
|
+
lines = text.splitlines()
|
|
716
|
+
if len(lines) < 2:
|
|
717
|
+
return None # malformed/legacy cache shape -> treat as a miss
|
|
718
|
+
hd, stored_token = lines[0].strip(), lines[1].strip()
|
|
719
|
+
if not hd or not os.path.isdir(hd):
|
|
720
|
+
return None
|
|
721
|
+
if stored_token != _global_gitconfig_mtime_token():
|
|
722
|
+
return None # a covered global git-config location changed since this cache was written
|
|
723
|
+
return hd
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _write_hooks_dir_cache(root, hd):
|
|
727
|
+
"""Best-effort persistence of the resolved hooks_dir (+ the global
|
|
728
|
+
git-config invalidation token) so a LATER session can skip the
|
|
729
|
+
git-config/rev-parse re-probe (performance-002) when nothing has changed.
|
|
730
|
+
Any failure — including `.git` being a FILE, not a directory, for a linked
|
|
731
|
+
worktree — is swallowed: this cache is a pure optimization and is never
|
|
732
|
+
allowed to affect whether hooks actually get installed."""
|
|
733
|
+
git_dir = os.path.join(root, ".git")
|
|
734
|
+
if not os.path.isdir(git_dir):
|
|
735
|
+
return
|
|
736
|
+
try:
|
|
737
|
+
payload = f"{hd}\n{_global_gitconfig_mtime_token()}\n"
|
|
738
|
+
_hooklib.write_text_atomic(
|
|
739
|
+
os.path.join(git_dir, _HOOKSDIR_CACHE_NAME), payload, newline="\n")
|
|
740
|
+
except Exception: # noqa: BLE001 — best-effort cache, never fatal
|
|
741
|
+
pass
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _hooks_current(hd, dropin_dir):
|
|
745
|
+
"""True iff BOTH phase shims at `hd` already match what install() would
|
|
746
|
+
write right now for `dropin_dir` — i.e. install() would be a complete
|
|
747
|
+
no-op for the SHIM files themselves. Filesystem-only (no git spawn): this
|
|
748
|
+
is exactly the check that lets install() skip the git-config/rev-parse
|
|
749
|
+
re-probe when a prior session already installed current hooks. A foreign
|
|
750
|
+
(non-sentinel) hook, a stale shim, or a missing file all correctly return
|
|
751
|
+
False here, falling the caller through to the full probe (which then
|
|
752
|
+
re-derives the right action: refresh, warn-and-preserve, or install
|
|
753
|
+
fresh).
|
|
754
|
+
|
|
755
|
+
Note (ADR-0014): the shim is host-neutral — it depends only on
|
|
756
|
+
`dropin_dir` (repo-derived, stable across plugin versions), never on this
|
|
757
|
+
plugin's own enforcer path. So a plugin-version bump that only changes
|
|
758
|
+
`_enforcer_path()` does NOT make this return False; install() refreshes
|
|
759
|
+
the plugin's OWN drop-in `.path` entry unconditionally every call,
|
|
760
|
+
independent of whether this check short-circuits the shim-file rewrite."""
|
|
761
|
+
for phase in PHASES:
|
|
762
|
+
existing = _read(os.path.join(hd, phase))
|
|
763
|
+
if existing is None or existing != _shim(dropin_dir, phase):
|
|
764
|
+
return False
|
|
765
|
+
return True
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _default_hooks_dir(root):
|
|
769
|
+
return os.path.join(root, ".git", "hooks")
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def install(root):
|
|
773
|
+
"""Ensure the git-level enforcement hooks are installed for `root`.
|
|
774
|
+
Idempotent and safe to call every session. Returns a list of human-readable
|
|
775
|
+
actions taken (possibly empty). Never raises for an expected condition
|
|
776
|
+
(no git dir, foreign hook) — those are reported, not fatal.
|
|
777
|
+
|
|
778
|
+
performance-002 (#194): before doing any git spawn, checks a cheap on-disk
|
|
779
|
+
cache of the last resolved hooks_dir. The fast path (zero git subprocess
|
|
780
|
+
calls) fires ONLY when ALL of the following hold — every one of them is a
|
|
781
|
+
cheap, no-git-spawn check:
|
|
782
|
+
1. a cached hooks_dir exists and still exists on disk;
|
|
783
|
+
2. that cached dir is EXACTLY the default `<root>/.git/hooks` — a cached
|
|
784
|
+
CUSTOM hooksPath is never fast-pathed, since a custom path is exactly
|
|
785
|
+
the kind of value that gets repointed later;
|
|
786
|
+
3. a direct read of `.git/config` (+ `.git/config.worktree`) positively
|
|
787
|
+
CONFIRMS no local core.hooksPath override is set right now (see
|
|
788
|
+
_confirmed_no_local_hooks_path — CRITICAL fix, post-#194: the
|
|
789
|
+
original cut skipped this check entirely, so a LOCAL hooksPath added
|
|
790
|
+
after the cache was written — e.g. adopting husky / pre-commit-
|
|
791
|
+
framework — silently left the NEW hooks dir unwired while returning
|
|
792
|
+
`[]`);
|
|
793
|
+
4. the shims at that dir are already current for the drop-in dir
|
|
794
|
+
(_hooks_current — ADR-0014: the shim depends only on `dropin_dir`,
|
|
795
|
+
never on this plugin's own enforcer path).
|
|
796
|
+
Any single miss/mismatch — including a genuine cold install, a foreign
|
|
797
|
+
hook, a changed core.hooksPath, or ambiguity in the config read — falls
|
|
798
|
+
through to the original git-based probe below, unchanged. Fail direction
|
|
799
|
+
is "install when unsure," never "skip when unsure".
|
|
800
|
+
|
|
801
|
+
ADR-0014: regardless of which path this function takes (fast path or full
|
|
802
|
+
probe), THIS plugin's own drop-in `<plugin>.path` entry is refreshed
|
|
803
|
+
every single call — a live host self-heals a stale entry (e.g. after a
|
|
804
|
+
version bump moved `_enforcer_path()`) every SessionStart, independent of
|
|
805
|
+
whether the shared shim FILE itself needed any rewrite."""
|
|
806
|
+
plugin = _plugin_name()
|
|
807
|
+
enforcer = _enforcer_path()
|
|
808
|
+
dropin_dir = _dropin_dir(root)
|
|
809
|
+
if dropin_dir is None:
|
|
810
|
+
return [] # no resolvable git dir at all — nothing to install against
|
|
811
|
+
_refresh_trusted_identity(dropin_dir, plugin)
|
|
812
|
+
cached_hd = _cached_hooks_dir(root)
|
|
813
|
+
if cached_hd is not None:
|
|
814
|
+
default_hd = os.path.normcase(os.path.abspath(_default_hooks_dir(root)))
|
|
815
|
+
cached_norm = os.path.normcase(os.path.abspath(cached_hd))
|
|
816
|
+
if (cached_norm == default_hd
|
|
817
|
+
and _confirmed_no_local_hooks_path(root)
|
|
818
|
+
and _hooks_current(cached_hd, dropin_dir)):
|
|
819
|
+
if _write_path_entry(dropin_dir, plugin, enforcer):
|
|
820
|
+
_touch_seen_marker(dropin_dir, plugin, enforcer) # #556 freshness heartbeat
|
|
821
|
+
return []
|
|
822
|
+
hd = hooks_dir(root)
|
|
823
|
+
if not hd:
|
|
824
|
+
return []
|
|
825
|
+
try:
|
|
826
|
+
os.makedirs(hd, exist_ok=True)
|
|
827
|
+
except Exception: # noqa: BLE001
|
|
828
|
+
_warn(f"could not create hooks dir {hd}; skipping git-hook install")
|
|
829
|
+
return []
|
|
830
|
+
actions = []
|
|
831
|
+
for phase in PHASES:
|
|
832
|
+
dest = os.path.join(hd, phase)
|
|
833
|
+
desired = _shim(dropin_dir, phase)
|
|
834
|
+
if os.path.exists(dest):
|
|
835
|
+
existing = _read(dest)
|
|
836
|
+
if existing is not None and SENTINEL not in existing:
|
|
837
|
+
_warn(f"an existing {phase} hook is not codeArbiter-managed — leaving it "
|
|
838
|
+
f"untouched. For git-level enforcement, call "
|
|
839
|
+
f"'{os.path.basename(enforcer)} {phase}' from it (see includes docs).")
|
|
840
|
+
actions.append(f"{phase}: foreign hook preserved (not installed)")
|
|
841
|
+
continue
|
|
842
|
+
if existing == desired:
|
|
843
|
+
continue # already current — no churn
|
|
844
|
+
try:
|
|
845
|
+
# reliability-010: atomic sibling-temp + os.replace (mirrors
|
|
846
|
+
# write_provenance/save_state). A crash mid-write with a plain
|
|
847
|
+
# open('w') could leave a sentinel-less partial shim that the
|
|
848
|
+
# foreign-hook guard above then preserves forever; os.replace
|
|
849
|
+
# guarantees `dest` is either the complete new shim or the prior
|
|
850
|
+
# (sentinel-bearing, or absent) file — never a torn write.
|
|
851
|
+
_hooklib.write_text_atomic(dest, desired, newline="\n")
|
|
852
|
+
st = os.stat(dest)
|
|
853
|
+
os.chmod(dest, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
854
|
+
actions.append(f"{phase}: installed")
|
|
855
|
+
except Exception as e: # noqa: BLE001
|
|
856
|
+
_warn(f"could not write {dest}: {e}")
|
|
857
|
+
# Cache the resolved location so the NEXT call can skip the git-config/
|
|
858
|
+
# rev-parse re-probe entirely (performance-002) — best-effort, never fatal.
|
|
859
|
+
_write_hooks_dir_cache(root, hd)
|
|
860
|
+
# ADR-0014: refresh THIS plugin's own drop-in entry every call, whether or
|
|
861
|
+
# not the shim files above needed a rewrite. #556: the `.seen` heartbeat
|
|
862
|
+
# is touched on every successful confirmation too (never skipped for
|
|
863
|
+
# "no churn" the way the `.path` entry itself is) — it is the freshness
|
|
864
|
+
# guard's only signal that a LIVE session confirmed this entry today.
|
|
865
|
+
if _write_path_entry(dropin_dir, plugin, enforcer):
|
|
866
|
+
_touch_seen_marker(dropin_dir, plugin, enforcer)
|
|
867
|
+
return actions
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def uninstall(root):
|
|
871
|
+
"""Remove ONLY this plugin's OWN drop-in `<plugin>.path` entry (ADR-0014).
|
|
872
|
+
|
|
873
|
+
Deliberately does NOT touch the shared shim file (.git/hooks/pre-commit /
|
|
874
|
+
pre-push) — that shim is host-neutral and a sibling plugin may still
|
|
875
|
+
depend on it. Leaving a genuinely EMPTY drop-in dir behind (every plugin
|
|
876
|
+
uninstalled) is the intended fail-closed contract, not a bug: the next
|
|
877
|
+
commit finds no resolvable enforcer and blocks with a clear diagnostic
|
|
878
|
+
(see `_shim`'s tail), rather than the old single-plugin era silently
|
|
879
|
+
passing. Returns the actions taken."""
|
|
880
|
+
plugin = _plugin_name()
|
|
881
|
+
dropin_dir = _dropin_dir(root)
|
|
882
|
+
if dropin_dir is None:
|
|
883
|
+
return []
|
|
884
|
+
actions = []
|
|
885
|
+
entry = _path_entry_file(dropin_dir, plugin)
|
|
886
|
+
if os.path.isfile(entry):
|
|
887
|
+
try:
|
|
888
|
+
os.remove(entry)
|
|
889
|
+
actions.append(f"{plugin}.path: removed")
|
|
890
|
+
except Exception as e: # noqa: BLE001
|
|
891
|
+
_warn(f"could not remove {entry}: {e}")
|
|
892
|
+
# #556: drop this plugin's OWN freshness heartbeat alongside its `.path`
|
|
893
|
+
# entry — a genuinely uninstalled plugin must not keep looking "live" to
|
|
894
|
+
# the freshness guard above (it would otherwise sit there, forever
|
|
895
|
+
# confirmed-fresh at its last mtime, potentially outranking a sibling
|
|
896
|
+
# that IS still being maintained).
|
|
897
|
+
seen = _seen_marker_file(dropin_dir, plugin)
|
|
898
|
+
if os.path.isfile(seen):
|
|
899
|
+
try:
|
|
900
|
+
os.remove(seen)
|
|
901
|
+
actions.append(f"{plugin}.seen: removed")
|
|
902
|
+
except Exception as e: # noqa: BLE001
|
|
903
|
+
_warn(f"could not remove {seen}: {e}")
|
|
904
|
+
identity = _read_trusted_identity(dropin_dir)
|
|
905
|
+
if identity is not None and identity[2] == plugin:
|
|
906
|
+
path = _identity_file(dropin_dir)
|
|
907
|
+
try:
|
|
908
|
+
os.remove(path)
|
|
909
|
+
actions.append(f"{plugin} trusted identity: removed")
|
|
910
|
+
except Exception as e: # noqa: BLE001
|
|
911
|
+
_warn(f"could not remove {path}: {e}")
|
|
912
|
+
return actions
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
if __name__ == "__main__":
|
|
916
|
+
# Manual install/uninstall: `python _githooks.py [install|uninstall] [root]`.
|
|
917
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else "install"
|
|
918
|
+
where = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
|
|
919
|
+
done = uninstall(where) if cmd == "uninstall" else install(where)
|
|
920
|
+
print(f"{cmd}: " + (", ".join(done) if done else "no changes"))
|