From 6d41819882be955998b74b3be58cc98d79d91105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Urb=C3=A1nek?= Date: Wed, 3 Jun 2026 18:18:12 +0200 Subject: [PATCH] feat(hooks): robust git-hook registration + lefthook support (2.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git hooks could silently stop firing after a plugin update or under lefthook — most visibly the post-commit local_evidence.py emitter, so contribution evidence stopped landing on items. install_hooks now marks hooks with an EDPA-MANAGED-HOOK sentinel and decides per slot (install / refresh / never-clobber-foreign), detects lefthook and prints a paste-ready snippet (use_stdin:true on pre-push), and update_engine.sh self-heals registration after a version bump. Adds --check-hooks and --refresh-hooks, reconciles the stale core.hooksPath installer into a thin delegator, removes the dead generic pre-commit, and makes ANSI TTY-aware. Docs + web (CZ/EN) updated; new test_project_setup_hooks.py + self-heal tests (618 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 41 +++ README.md | 2 +- docs/E2E-TEST-PLAN.md | 2 +- docs/RUNBOOK.md | 72 +++++- docs/methodology.md | 2 +- docs/playbook.md | 33 ++- docs/quick-start.md | 6 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/README.md | 4 +- .../scripts/hooks/commit-msg-ticket-attached | 1 + plugin/edpa/scripts/hooks/install.sh | 25 +- .../edpa/scripts/hooks/post-commit-evidence | 1 + plugin/edpa/scripts/hooks/pre-commit | 31 --- .../edpa/scripts/hooks/pre-commit-id-safety | 5 +- plugin/edpa/scripts/hooks/pre-push-id-safety | 4 +- plugin/edpa/scripts/hooks/update_engine.sh | 20 ++ plugin/edpa/scripts/project_setup.py | 234 ++++++++++++++---- plugin/edpa/templates/people.yaml.tmpl | 2 +- plugin/skills/setup/SKILL.md | 21 +- tests/test_project_setup_hooks.py | 161 ++++++++++++ tests/test_update_engine_hook.py | 53 ++++ web/package-lock.json | 4 +- web/package.json | 2 +- web/src/pages/en/guide.astro | 3 + web/src/pages/en/methodology.astro | 2 +- web/src/pages/en/playbook.astro | 21 ++ web/src/pages/en/setup.astro | 21 ++ web/src/pages/guide.astro | 3 + web/src/pages/methodology.astro | 2 +- web/src/pages/playbook.astro | 21 ++ web/src/pages/setup.astro | 21 ++ 31 files changed, 712 insertions(+), 110 deletions(-) delete mode 100755 plugin/edpa/scripts/hooks/pre-commit create mode 100644 tests/test_project_setup_hooks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce63de..1b9ac5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## 2.3.0 — 2026-06-03 — Robust git-hook registration + lefthook support + +Git hooks could silently stop firing — most visibly the `post-commit` +`local_evidence.py` emitter, so **contribution evidence stopped landing on +items** — whenever a hook slot was already occupied (typically by lefthook, +which owns `.git/hooks/`) or after a plugin update left a stale snapshot. +Registration is now robust and hook-manager-aware. + +### Fixed +- `project_setup.install_hooks` no longer skips a slot with a blunt + `not dst.exists()` guard. EDPA marks its hooks with an `EDPA-MANAGED-HOOK` + sentinel and decides per slot: install when missing, refresh when EDPA-owned, + and **never clobber a foreign hook** (it warns + prints the exact chain-in + line instead). +- The SessionStart auto-update (`update_engine.sh`) now **re-registers EDPA git + hooks after a version bump** when the project already uses them — fixing + "hooks gone / contribution stopped after update". Opt-out repos stay untouched. + +### Added +- **lefthook support.** When a `lefthook.yml` (or other lefthook config) is + present, `--with-hooks` detects it and prints a paste-ready lefthook snippet + (with `use_stdin: true` on `pre-push` — required, or lefthook hangs the push) + instead of writing `.git/hooks/`. EDPA never edits your lefthook config. +- `project_setup.py --check-hooks` — a read-only hooks doctor (active / missing + / foreign / lefthook) — and `--refresh-hooks` — register/refresh only. + +### Changed +- `scripts/hooks/install.sh` is now a thin delegator to + `project_setup.py --refresh-hooks`; the old conflicting `core.hooksPath` + mechanism (and its stale `.claude/edpa/...` path) is gone. +- ANSI colour codes in `project_setup.py` are suppressed when stdout is not a + TTY (no escape-code leak into captured SessionStart output). +- Removed the dead generic `pre-commit` hook (superseded by `pre-commit-id-safety`). +- Docs + website (CZ/EN) now document hook registration, the lefthook snippet, + foreign-hook behavior, and `--check-hooks` verification. + +### Tests +- New `tests/test_project_setup_hooks.py` (fresh install, refresh, foreign-skip, + lefthook snippet validity) plus self-heal cases in + `tests/test_update_engine_hook.py`. Full suite green. + ## 2.2.1 — 2026-06-01 — Skill names: drop the redundant `edpa-` prefix; server + create-pi are command-only Plugin skill invocations were doubly namespaced — `/edpa:edpa-setup`, diff --git a/README.md b/README.md index d915871..30f4843 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ All invariants passed: YES ## Key Features -- **Zero manual input** — hours derived from **local git evidence**: post-commit hook emits `commit_author` + `/contribute` signals; engine reads `yaml_edit`, gate-event, and in-flight Story activity directly from `git log`. +- **Zero manual input** — hours derived from **local git evidence**: post-commit hook emits `commit_author` + `/contribute` signals; engine reads `yaml_edit`, gate-event, and in-flight Story activity directly from `git log`. Hooks register into `.git/hooks/` (or, under lefthook, via a printed snippet) and can be verified with `project_setup.py --check-hooks`. - **Mathematical guarantee** — derived hours always sum to declared capacity - **Gates mode (default)** — credits each Initiative/Epic/Feature status transition as a mini-deliverable, so prep work (LBC, decomposition, design) gets credited as it happens, not only at final Done. Validated to ±0.35 pp stability under ±20 % CW perturbation across 100 Monte Carlo runs. - **C7.5 in-flight Story credit** — Stories with `yaml_edit` activity in the iteration window receive partial credit (`js × credit_factor`, default 0.40) even before they reach Done; the `story_activity_events[]` audit log in `edpa_results.json` records what was credited and why. diff --git a/docs/E2E-TEST-PLAN.md b/docs/E2E-TEST-PLAN.md index 86b4fbc..de0fb05 100644 --- a/docs/E2E-TEST-PLAN.md +++ b/docs/E2E-TEST-PLAN.md @@ -785,7 +785,7 @@ Plán je úspěšně provedený, pokud: | `.edpa/engine/scripts/calibrate_signals.py` | auto-kalibrace CW vah (Monte Carlo + coord descent) | | `.edpa/engine/scripts/hooks/commit-msg-ticket-attached` | commit-msg hook — vyžaduje item ref / escape | | `.edpa/engine/scripts/hooks/post-commit-evidence` | post-commit hook — emituje evidence | -| `.edpa/engine/scripts/hooks/pre-commit` | pre-commit hook — schema + ID safety | +| `.edpa/engine/scripts/hooks/pre-commit-id-safety` | pre-commit hook — ID safety | | `plugin/edpa/templates/github-workflows/edpa-contribution-sync.yml` | (volitelně) jediný V2 GH Action | | `plugin/hooks/hooks.json` | Claude Code hooks (validate, commit info) | | `plugin/.claude-plugin/plugin.json` | plugin manifest (single source of truth verze) | diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index ef86114..68728d6 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -76,7 +76,9 @@ engine + `.edpa/` tree. **Flags (all recommended for team workflows):** - `--with-hooks` — pre-commit + commit-msg + post-commit + pre-push git hooks - (ID safety, ticket-attached, local `commit_author` evidence emission). + (ID safety, ticket-attached, local `commit_author` evidence emission). Detects + lefthook and prints a paste-ready snippet instead of touching `.git/hooks/`; + never clobbers a foreign hook. See **Git hooks** below. - `--with-ci` — copies `edpa-contribution-sync.yml`; materializes PR-thread signals (`pr_reviewer`, `issue_comment`) into `evidence[]` after merge. Optional, GitHub-only — local commit evidence flows without it. @@ -86,7 +88,7 @@ engine + `.edpa/` tree. **Expected output (last steps):** ``` - [1] Vendor engine ✓ Vendored engine → .edpa/engine/ (37 scripts, VERSION 2.1.9) + [1] Vendor engine ✓ Vendored engine → .edpa/engine/ (37 scripts, VERSION 2.3.0) [2] Directory tree ✓ Directory tree at .edpa/ [3] Config templates ✓ Seeded people.yaml, edpa.yaml, cw_heuristics.yaml [4] ID counter ✓ id_counters.yaml seeded @@ -101,8 +103,61 @@ EDPA setup complete. hand-edit; the SessionStart hook re-syncs it on plugin update). - `.edpa/config/{edpa.yaml,people.yaml,cw_heuristics.yaml,id_counters.yaml}`. - `.edpa/{backlog,iterations,reports,snapshots}/` tree. -- (flags) `.git/hooks/*`, `.github/workflows/edpa-contribution-sync.yml`, - `.claude/rules/`. +- (flags) `.git/hooks/*` (or a lefthook snippet), + `.github/workflows/edpa-contribution-sync.yml`, `.claude/rules/`. + +**Git hooks — registration, lefthook, verification:** + +`--with-hooks` writes four hooks into `.git/hooks/` (`pre-commit`, `pre-push`, +`commit-msg`, `post-commit`). The `post-commit` one runs `local_evidence.py` — +**this is what records contribution evidence onto items**, so if it isn't +registered, contributions silently never appear. The registration is +deliberately careful: + +- **Re-running is safe and self-refreshing.** Re-run `/edpa:setup --with-hooks` + (or `python3 .edpa/engine/scripts/project_setup.py --refresh-hooks`) any time: + EDPA-owned hooks are overwritten with the current version, missing ones + reinstalled. EDPA marks its hooks with an `EDPA-MANAGED-HOOK` sentinel. +- **Foreign hooks are never clobbered.** If a non-EDPA file already occupies a + slot, EDPA skips it and prints a loud warning with the exact line to chain + EDPA in by hand (`sh .edpa/engine/scripts/hooks/ "$@"`). +- **lefthook (or any tool that owns `.git/hooks/`).** lefthook generates its own + dispatcher shims into `.git/hooks/` (and can set `core.hooksPath`), so a plain + copy would be ignored or clobbered — this is the usual cause of "contribution + stopped working after an update". EDPA detects `lefthook.yml` and, instead of + writing `.git/hooks/`, prints a paste-ready block. Add it to your + `lefthook.yml`, then run `lefthook install`: + + ```yaml + pre-commit: + commands: + edpa-id-safety: + run: sh .edpa/engine/scripts/hooks/pre-commit-id-safety + commit-msg: + commands: + edpa-ticket-attached: + run: sh .edpa/engine/scripts/hooks/commit-msg-ticket-attached {1} + post-commit: + commands: + edpa-evidence: + run: sh .edpa/engine/scripts/hooks/post-commit-evidence + pre-push: + commands: + edpa-id-safety: + run: sh .edpa/engine/scripts/hooks/pre-push-id-safety {1} {2} + use_stdin: true # pre-push refs arrive on stdin — without this lefthook hangs + ``` + +- **After a plugin update**, the SessionStart auto-update re-registers EDPA hooks + automatically when the project already uses them (and, under lefthook, reminds + you to verify). No manual step needed for the plain `.git/hooks/` case. +- **Verify any time** (read-only doctor — no changes): + + ```bash + python3 .edpa/engine/scripts/project_setup.py --check-hooks + ``` + Reports each hook as active / missing / foreign, or flags lefthook so you know + to register via the snippet. **Next:** edit `people.yaml` (your team) + `edpa.yaml` (`project.name`), then create items locally: @@ -367,11 +422,16 @@ git add .github/workflows/edpa-collision-check.yml git commit -m "ci: add EDPA collision check" ``` -Verify hooks are installed: +Verify hooks are installed (read-only doctor — works for `.git/hooks/` and +flags lefthook): ```bash -ls -la .git/hooks/pre-commit .git/hooks/pre-push # must be -rwxr-xr-x +python3 .edpa/engine/scripts/project_setup.py --check-hooks ``` +If your repo uses **lefthook**, `--with-hooks` prints a paste-ready snippet +instead of writing `.git/hooks/`; add it to `lefthook.yml` and run +`lefthook install` (see §1 → *Git hooks — registration, lefthook, verification*). + See [`docs/dev-collisions.md`](dev-collisions.md) for decision tree, common collision shapes (single / multi / parent-chain / cascading), troubleshooting, and the `--target develop` flag for Git Flow projects. --- diff --git a/docs/methodology.md b/docs/methodology.md index 96cf63f..8851884 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -2,7 +2,7 @@ *Capacity derivation from delivery evidence* -**Version 2.2.1 — June 2026 — Jaroslav Urbanek, Lead Architect** +**Version 2.3.0 — June 2026 — Jaroslav Urbanek, Lead Architect** --- diff --git a/docs/playbook.md b/docs/playbook.md index 7ca362a..cd0c1c3 100644 --- a/docs/playbook.md +++ b/docs/playbook.md @@ -78,7 +78,7 @@ V terminalu s Claude Code nainstalovanym: /edpa:setup --with-ci --with-hooks --with-rules ``` -Claude Code (skill `/edpa:setup`) provede kroky 1.1-1.4 automaticky -- vendoruje engine do `.edpa/engine/`, naseje konfiguraci a `id_counters.yaml`, a volitelne nainstaluje git hooky, PR-signal CI workflow a `.claude/rules/`. Idempotentni -- opakovane spusteni nic nerozbije. +Claude Code (skill `/edpa:setup`) provede kroky 1.1-1.4 automaticky -- vendoruje engine do `.edpa/engine/`, naseje konfiguraci a `id_counters.yaml`, a volitelne nainstaluje git hooky, PR-signal CI workflow a `.claude/rules/`. Idempotentni -- opakovane spusteni nic nerozbije. `--with-hooks` je lefthook-aware (pri pritomnem `lefthook.yml` vypise snippet misto zapisu do `.git/hooks/`); stav overis pres `--check-hooks`. ### Cesta B: Manualni CLI @@ -101,6 +101,8 @@ curl -fsSL https://edpa.technomaton.com/install.sh | sh python3 .edpa/engine/scripts/project_setup.py --with-ci --with-hooks --with-rules ``` +> `--with-hooks` je lefthook-aware (pri pritomnem `lefthook.yml` vypise paste-ready snippet misto zapisu do `.git/hooks/`); stav hooku overis pres `--check-hooks`. + Vysledna struktura: ``` @@ -446,7 +448,30 @@ Povolene prefixy: `S` (Story), `F` (Feature), `E` (Epic), `T` (Task), `D` (Defec | pre-commit | ID safety -- kontrola referenci | | commit-msg | Vyzaduje referenci itemu nebo `no-ticket:` | | post-commit | Zaznamenava `commit_author` evidence | -| pre-push | Kontrola ID kolizi | +| pre-push | Kontrola ID kolizi vuci remote | + +Registrace je idempotentni a sebe-obnovujici: EDPA znacka sve hooky sentinelem `EDPA-MANAGED-HOOK`, opakovany `--with-hooks` (nebo `--refresh-hooks`) je osvezi a cizi (ne-EDPA) hook v dane pozici **nikdy nepreplacne** -- vypise hlasku + radek k rucnimu zaretezeni (`sh .edpa/engine/scripts/hooks/ "$@"`). Pokud je v repu **lefthook** (`lefthook.yml`), `.git/hooks/` vlastni lefthook, takze EDPA tam nezapisuje a misto toho vypise hotovy snippet do `lefthook.yml`; pote spust `lefthook install`. Stav hooku overis read-only pres `--check-hooks` (kazdy hook jako active / missing / foreign, pripadne flag lefthook). + +```yaml +# lefthook.yml -- EDPA hooky (pre-push MUSI mit use_stdin: true, jinak push zatuhne) +pre-commit: + commands: + edpa-id-safety: + run: sh .edpa/engine/scripts/hooks/pre-commit-id-safety +commit-msg: + commands: + edpa-ticket-attached: + run: sh .edpa/engine/scripts/hooks/commit-msg-ticket-attached {1} +post-commit: + commands: + edpa-evidence: + run: sh .edpa/engine/scripts/hooks/post-commit-evidence +pre-push: + commands: + edpa-id-safety: + run: sh .edpa/engine/scripts/hooks/pre-push-id-safety {1} {2} + use_stdin: true +``` **Commit konvence:** @@ -782,13 +807,13 @@ V2 ma **jediny** volitelny workflow (jen s `--with-ci`): - [ ] Backlog naplneny pres `backlog.py add` (alespon 1 Epic, 3 Features, 10 Stories) - [ ] `backlog.py validate` projde bez chyb - [ ] `engine.py --demo` projde uspesne -- [ ] (volitelne) git hooky nainstalovany (`--with-hooks`), contribution-sync CI (`--with-ci`) +- [ ] (volitelne) git hooky nainstalovany (`--with-hooks`) a overeny (`--check-hooks` -> vsechny active; pri lefthooku snippet v `lefthook.yml` + `lefthook install`), contribution-sync CI (`--with-ci`) ### Tyden 1 - [ ] Tym pracuje s branch naming konvenci (`feature/S-XXX-popis`) - [ ] Commity referuji work items (`feat(S-XXX): ...`) -- [ ] post-commit hook zaznamenava `commit_author` evidenci +- [ ] post-commit hook zaznamenava `commit_author` evidenci (overeno `--check-hooks`; je-li post-commit `missing`/`foreign`, contribution evidence se tise nezapisuji -- u lefthooku doplnit snippet + `lefthook install`) - [ ] (volitelne) PR reviews probihaji a contribution-sync je materializuje ### Konec iterace 1 diff --git a/docs/quick-start.md b/docs/quick-start.md index e04eaf3..4323443 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -129,8 +129,12 @@ If your team has more than one person creating backlog items in parallel, you'll **Setup** (one-time per project): ```bash -# 1. Install pre-commit + pre-push hooks +# 1. Install git hooks (pre-commit, pre-push, commit-msg, post-commit). +# Under lefthook this prints a snippet to paste into lefthook.yml + run +# `lefthook install` instead of writing .git/hooks/. Foreign hooks are +# never overwritten; re-run any time to refresh. python3 .edpa/engine/scripts/project_setup.py --with-hooks +python3 .edpa/engine/scripts/project_setup.py --check-hooks # verify (read-only) # 2. Copy CI workflow template cp .edpa/engine/templates/github-workflows/edpa-collision-check.yml \ diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 9f9d68d..6268359 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "edpa", - "version": "2.2.1", + "version": "2.3.0", "description": "EDPA — Evidence-Driven Proportional Allocation. Derive hours from local git evidence (commits, yaml edits, status transitions). Zero timesheets, mathematical guarantee, Monte Carlo calibrated CW weights. Local-first: .edpa/backlog/ YAML as source of truth, git as the audit trail. GitHub Projects sync optional.", "author": { "name": "TECHNOMATON", diff --git a/plugin/README.md b/plugin/README.md index 07b151f..3dd8ac1 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -89,7 +89,7 @@ plugin/ │ ├── validate_on_save.sh # PostToolUse Edit|Write — YAML/JSON syntax check │ ├── edpa_post_commit.sh # PostToolUse Bash — commit info │ ├── pre-commit # Git pre-commit (user-installed, not auto-wired) - │ └── install.sh # Helper to install pre-commit into .git/hooks/ + │ └── install.sh # Thin delegator → project_setup.py --refresh-hooks (lefthook-aware; old core.hooksPath mechanism removed) ├── schemas/ │ └── edpa_commit_info.schema.json ├── templates/ @@ -141,6 +141,8 @@ When teams have multiple devs creating backlog items in parallel branches, ID co | 7 — CI workflow | server | PR open/sync | `edpa-collision-check.yml` (comments on PR + fails check) | | Recovery | local | after conflict | `renumber_collisions.py --apply` (renames + updates parents + bumps counter) | +Hook registration (`--with-hooks` / `--refresh-hooks`) is idempotent and lefthook-aware: EDPA tags its own hooks with an `EDPA-MANAGED-HOOK` sentinel, never clobbers a foreign hook already in a slot, and — if a `lefthook.yml` is present — prints a paste-ready snippet instead of writing to `.git/hooks/`. Verify any time with the read-only doctor `project_setup.py --check-hooks` (reports each hook as active / missing / foreign, or flags lefthook). + **Quick setup** (one-time per project): ```bash diff --git a/plugin/edpa/scripts/hooks/commit-msg-ticket-attached b/plugin/edpa/scripts/hooks/commit-msg-ticket-attached index 23313b5..75c8de3 100755 --- a/plugin/edpa/scripts/hooks/commit-msg-ticket-attached +++ b/plugin/edpa/scripts/hooks/commit-msg-ticket-attached @@ -1,4 +1,5 @@ #!/bin/sh +# EDPA-MANAGED-HOOK # EDPA V2.1 commit-msg hook — require an EDPA item reference (or escape). # # Git invokes this with $1 = path to COMMIT_EDITMSG. Exit non-zero diff --git a/plugin/edpa/scripts/hooks/install.sh b/plugin/edpa/scripts/hooks/install.sh index e44f461..81f71ac 100755 --- a/plugin/edpa/scripts/hooks/install.sh +++ b/plugin/edpa/scripts/hooks/install.sh @@ -1,13 +1,20 @@ -#!/bin/bash -# Install EDPA git hooks -ROOT="$(git rev-parse --show-toplevel)" -HOOKS_DIR="$ROOT/.claude/edpa/scripts/hooks" +#!/bin/sh +# Thin delegator — the real EDPA git-hook registration lives in +# project_setup.py (script-first: one implementation, thin callers). Kept for +# the documented `sh .edpa/engine/scripts/hooks/install.sh` path. +# +# It installs into .git/hooks/ (ownership-tracked, foreign hooks left alone) +# or prints a paste-ready snippet when lefthook is detected. The old +# `git config core.hooksPath` mechanism is gone: it pointed at a stale path +# and silently fought lefthook / the .git/hooks/ copy path. +ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" +[ -z "$ROOT" ] && { echo "ERROR: not a git repository." >&2; exit 1; } -if [ ! -d "$HOOKS_DIR" ]; then - echo "ERROR: Hooks directory not found at $HOOKS_DIR" - echo "Make sure the EDPA plugin is installed." +SETUP="$ROOT/.edpa/engine/scripts/project_setup.py" +if [ ! -f "$SETUP" ]; then + echo "ERROR: EDPA engine not found at $SETUP" >&2 + echo "Run /edpa:setup (or the curl|sh installer) to vendor the engine first." >&2 exit 1 fi -git config core.hooksPath "$HOOKS_DIR" -echo "Git hooks installed from $HOOKS_DIR" +exec python3 "$SETUP" --refresh-hooks --root "$ROOT" diff --git a/plugin/edpa/scripts/hooks/post-commit-evidence b/plugin/edpa/scripts/hooks/post-commit-evidence index f1a3811..4bb3f93 100755 --- a/plugin/edpa/scripts/hooks/post-commit-evidence +++ b/plugin/edpa/scripts/hooks/post-commit-evidence @@ -1,4 +1,5 @@ #!/bin/sh +# EDPA-MANAGED-HOOK # EDPA V2.1 local-evidence post-commit hook. # # Runs after every commit. Detects item refs in the commit (msg + paths), diff --git a/plugin/edpa/scripts/hooks/pre-commit b/plugin/edpa/scripts/hooks/pre-commit deleted file mode 100755 index 529b80d..0000000 --- a/plugin/edpa/scripts/hooks/pre-commit +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# EDPA pre-commit hook — validates staged YAML and Python files before commit. -# Exit 1 to block commit on syntax errors. - -# Get the repo root -REPO_ROOT="$(git rev-parse --show-toplevel)" - -# Find the validate_syntax.py script -VALIDATOR="$REPO_ROOT/.claude/edpa/scripts/validate_syntax.py" - -if [ ! -f "$VALIDATOR" ]; then - # Validator not found — skip silently (don't block commits) - exit 0 -fi - -# Collect staged YAML and Python files -STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(yaml|yml|py)$' || true) - -if [ -z "$STAGED_FILES" ]; then - exit 0 -fi - -# Build file list with full paths -FILE_LIST="" -for f in $STAGED_FILES; do - FILE_LIST="$FILE_LIST $REPO_ROOT/$f" -done - -# Run validator -python3 "$VALIDATOR" $FILE_LIST -exit $? diff --git a/plugin/edpa/scripts/hooks/pre-commit-id-safety b/plugin/edpa/scripts/hooks/pre-commit-id-safety index 089732e..cbc75e9 100755 --- a/plugin/edpa/scripts/hooks/pre-commit-id-safety +++ b/plugin/edpa/scripts/hooks/pre-commit-id-safety @@ -1,9 +1,10 @@ #!/bin/sh +# EDPA-MANAGED-HOOK # EDPA V2 ID safety pre-commit hook — validates staged .edpa/backlog/ files. # Layer 5 from docs/v2/plan.md. # -# Install: symlink to .git/hooks/pre-commit-id-safety, then chain from -# .git/hooks/pre-commit (or use install.sh's --with-id-hooks flag). +# Install: /edpa:setup --with-hooks (or project_setup.py --with-hooks). Detects +# lefthook and prints a paste-ready snippet instead of writing .git/hooks/. # # Exit 1 to block commit on any ID safety violation. diff --git a/plugin/edpa/scripts/hooks/pre-push-id-safety b/plugin/edpa/scripts/hooks/pre-push-id-safety index 726a2cb..6a6127c 100755 --- a/plugin/edpa/scripts/hooks/pre-push-id-safety +++ b/plugin/edpa/scripts/hooks/pre-push-id-safety @@ -1,8 +1,10 @@ #!/bin/sh +# EDPA-MANAGED-HOOK # EDPA V2 ID safety pre-push hook — verifies no ID collisions with upstream. # Layer 6 from docs/v2/plan.md. # -# Install: symlink to .git/hooks/pre-push. +# Install: /edpa:setup --with-hooks. Under lefthook, the command needs +# `use_stdin: true` (push refs arrive on stdin, read below). # # Git invokes this with two args: remote_name and remote_url, and pipes # the push refs on stdin (local_ref local_sha remote_ref remote_sha). diff --git a/plugin/edpa/scripts/hooks/update_engine.sh b/plugin/edpa/scripts/hooks/update_engine.sh index 1e9c505..0481bc1 100755 --- a/plugin/edpa/scripts/hooks/update_engine.sh +++ b/plugin/edpa/scripts/hooks/update_engine.sh @@ -109,6 +109,26 @@ VENDOR templates echo "$PLUGIN_VERSION" > "$TARGET/VERSION" chmod +x "$TARGET/scripts/hooks/"* 2>/dev/null || true +# Self-heal git hooks after an engine update. A version bump can leave +# .git/hooks/ holding a stale snapshot, or another tool (e.g. lefthook) may +# have clobbered EDPA's hooks — which silently stops contribution evidence +# from firing. Re-register ONLY when the project already opted into hooks +# (an EDPA-owned hook is present, or lefthook is in use), so a version bump +# never forces hooks on a repo that deliberately skipped them. +_has_lefthook() { + for _lf in lefthook.yml lefthook.yaml .lefthook.yml .lefthook.yaml lefthook.toml lefthook.json; do + [ -f "$PROJECT/$_lf" ] && return 0 + done + return 1 +} +if _has_lefthook; then + echo "EDPA: lefthook detected — verify EDPA hooks are registered with:" >&2 + echo " python3 $TARGET/scripts/project_setup.py --check-hooks" >&2 +elif grep -q "EDPA-MANAGED-HOOK" "$PROJECT"/.git/hooks/* 2>/dev/null; then + echo "EDPA: re-registering git hooks after update..." >&2 + python3 "$TARGET/scripts/project_setup.py" --refresh-hooks --root "$PROJECT" 1>&2 || true +fi + echo "EDPA: engine updated. $(find "$TARGET/scripts" -maxdepth 1 -name '*.py' | wc -l | tr -d ' ') Python modules, $(find "$TARGET/templates" -maxdepth 1 -name '*.tmpl' | wc -l | tr -d ' ') templates." >&2 # Legacy backlog format check after update — the .md migration arrived diff --git a/plugin/edpa/scripts/project_setup.py b/plugin/edpa/scripts/project_setup.py index fbaaffa..6ccc584 100755 --- a/plugin/edpa/scripts/project_setup.py +++ b/plugin/edpa/scripts/project_setup.py @@ -16,8 +16,11 @@ 5. Optionally copy the CI workflow template (``--with-ci``) to ``.github/workflows/edpa-contribution-sync.yml`` so the engine can read PR signals materialized from PR events. - 6. Optionally install git hooks (``--with-hooks``) — pre-commit + - pre-push ID safety validators. + 6. Optionally install git hooks (``--with-hooks``) — pre-commit + pre-push + ID safety, commit-msg ticket-attached, post-commit local-evidence. Detects + lefthook (prints a paste-ready snippet instead of clobbering .git/hooks/), + warns on foreign hooks, and is idempotent. ``--refresh-hooks`` re-registers + only; ``--check-hooks`` is a read-only doctor. What it does NOT do (V2.0 hard cut from V1): - No ``gh project`` calls @@ -56,13 +59,16 @@ class C: - RESET = "\033[0m" - BOLD = "\033[1m" - DIM = "\033[2m" - GREEN = "\033[32m" - YELLOW = "\033[33m" - CYAN = "\033[36m" - RED = "\033[31m" + # Disable ANSI when stdout isn't a TTY (CI, the SessionStart auto-update + # self-heal path) so escape codes don't leak into captured output. + _tty = bool(getattr(sys.stdout, "isatty", lambda: False)()) + RESET = "\033[0m" if _tty else "" + BOLD = "\033[1m" if _tty else "" + DIM = "\033[2m" if _tty else "" + GREEN = "\033[32m" if _tty else "" + YELLOW = "\033[33m" if _tty else "" + CYAN = "\033[36m" if _tty else "" + RED = "\033[31m" if _tty else "" def step(n: int, text: str) -> None: @@ -283,45 +289,168 @@ def install_rules(root: Path) -> bool: return True -def install_hooks(root: Path) -> bool: - git_hooks = root / ".git" / "hooks" - if not git_hooks.exists(): - warn("Not a git repo (no .git/hooks) — skipping hooks install") - return False +# ─── Git hook registration ─────────────────────────────────────────────────── + +EDPA_HOOK_SENTINEL = "EDPA-MANAGED-HOOK" + +# git hook name → (vendored source filename, one-line purpose) +_HOOK_SPECS: tuple[tuple[str, str, str], ...] = ( + ("pre-commit", "pre-commit-id-safety", + "ID safety: staged backlog filename≡id consistency"), + ("pre-push", "pre-push-id-safety", + "ID safety: no ID collisions with the remote tip"), + ("commit-msg", "commit-msg-ticket-attached", + "require an EDPA item ref (or a 'no-ticket:' escape)"), + ("post-commit", "post-commit-evidence", + "emit commit_author evidence into the item's evidence[]"), +) +_HOOK_SRC = {hook: name for hook, name, _ in _HOOK_SPECS} +_HOOK_PURPOSE = {hook: purpose for hook, _, purpose in _HOOK_SPECS} + +# Lefthook owns .git/hooks/ — it writes dispatcher shims there (and can set +# core.hooksPath), so any plain copy EDPA drops into .git/hooks/ is ignored or +# clobbered. Presence of a lefthook config is the canonical "managed" signal. +_LEFTHOOK_CONFIGS = ( + "lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml", + "lefthook.toml", "lefthook.json", +) + +# Paste-ready lefthook config. pre-push reads its refs on stdin, so its command +# MUST set ``use_stdin: true`` — without it lefthook keeps a pseudo-TTY open and +# hangs the push. {1}/{2} are the args git passes the hook (commit-msg: the +# message file; pre-push: remote name + URL). +LEFTHOOK_SNIPPET = """\ +# --- EDPA-managed hooks: paste into lefthook.yml, then run `lefthook install` --- +# Merge these `commands:` under any matching hook keys you already have; +# do not duplicate the top-level hook name. +pre-commit: + commands: + edpa-id-safety: + run: sh .edpa/engine/scripts/hooks/pre-commit-id-safety +commit-msg: + commands: + edpa-ticket-attached: + run: sh .edpa/engine/scripts/hooks/commit-msg-ticket-attached {1} +post-commit: + commands: + edpa-evidence: + run: sh .edpa/engine/scripts/hooks/post-commit-evidence +pre-push: + commands: + edpa-id-safety: + run: sh .edpa/engine/scripts/hooks/pre-push-id-safety {1} {2} + use_stdin: true +""" + + +def detect_lefthook(root: Path) -> Path | None: + """Return the lefthook config path if this repo is managed by lefthook.""" + for name in _LEFTHOOK_CONFIGS: + p = root / name + if p.exists(): + return p + return None + + +def _hook_src_dir(root: Path) -> Path: + """Hook sources — plugin layout when running from source, else vendored.""" src_dir = HERE / "hooks" if not (src_dir / "pre-commit-id-safety").exists(): src_dir = root / ".edpa" / "engine" / "scripts" / "hooks" + return src_dir + + +def _is_edpa_owned(path: Path) -> bool: + """True if an installed hook carries EDPA's sentinel (vs. a foreign hook).""" + try: + return EDPA_HOOK_SENTINEL in path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + + +def install_hooks(root: Path, *, refresh: bool = False, + check_only: bool = False) -> bool: + """Register EDPA's git hooks robustly. + + Per-hook decision (replaces the old blunt ``not dst.exists()`` guard, which + silently dropped EDPA hooks whenever any file already held the slot — the + lefthook collision that stopped contribution evidence from firing): + + * lefthook detected → print the lefthook snippet, leave .git/hooks/ alone + (lefthook owns it); the user pastes it + runs ``lefthook install``. + * dst missing → install (copy + chmod 0755). + * dst EDPA-owned → ``refresh`` overwrites with the current version so a + plugin update propagates hook fixes (update_engine.sh self-heal path); + otherwise reported as already active. + * dst foreign → never touched; loud warning with manual chain-in + instructions. - installed = [] - # Pre-commit + pre-push: ID safety - for hook in ("pre-commit", "pre-push"): - src = src_dir / f"{hook}-id-safety" + ``check_only`` reports status and writes nothing (the ``--check-hooks`` + doctor). Returns False only when there is no .git/hooks at all. + """ + git_hooks = root / ".git" / "hooks" + if not git_hooks.exists(): + warn("Not a git repo (no .git/hooks) — skipping hooks") + return False + + lefthook_cfg = detect_lefthook(root) + if lefthook_cfg: + warn(f"lefthook detected ({lefthook_cfg.name}) — it owns .git/hooks/, " + f"so EDPA registers via lefthook, not by copying hooks.") + if not check_only: + info("EDPA does not edit your lefthook config. Add this block, " + "then run `lefthook install`:") + print() + print(LEFTHOOK_SNIPPET) + info("Re-check anytime: python3 .edpa/engine/scripts/project_setup.py " + "--check-hooks") + return True + + src_dir = _hook_src_dir(root) + installed: list[str] = [] + refreshed: list[str] = [] + active: list[str] = [] + missing: list[str] = [] + foreign: list[str] = [] + for hook, src_name, _ in _HOOK_SPECS: + src = src_dir / src_name dst = git_hooks / hook - if src.exists() and not dst.exists(): - shutil.copy(src, dst) - dst.chmod(0o755) - installed.append(hook) - # Commit-msg: ticket-attached check (V2.1) - cm_src = src_dir / "commit-msg-ticket-attached" - cm_dst = git_hooks / "commit-msg" - if cm_src.exists() and not cm_dst.exists(): - shutil.copy(cm_src, cm_dst) - cm_dst.chmod(0o755) - installed.append("commit-msg") - # Post-commit: local evidence emitter (V2.1) - pc_src = src_dir / "post-commit-evidence" - pc_dst = git_hooks / "post-commit" - if pc_src.exists() and not pc_dst.exists(): - shutil.copy(pc_src, pc_dst) - pc_dst.chmod(0o755) - installed.append("post-commit") + if not src.exists(): + continue + if not dst.exists(): + if check_only: + missing.append(hook) + else: + shutil.copy(src, dst) + dst.chmod(0o755) + installed.append(hook) + elif _is_edpa_owned(dst): + if not check_only and refresh: + shutil.copy(src, dst) + dst.chmod(0o755) + refreshed.append(hook) + else: + active.append(hook) + else: + foreign.append(hook) + if installed: ok(f"Installed git hooks: {', '.join(installed)}") - info("pre-commit/pre-push: filename≡id, no upstream collisions") - info("commit-msg: require EDPA item ref (or 'no-ticket:' escape)") - info("post-commit: emit commit_author signals into item evidence[]") - else: - info("Hooks already installed or sources missing — no changes") + if refreshed: + ok(f"Refreshed git hooks: {', '.join(refreshed)}") + if active: + (ok if check_only else info)(f"Active EDPA hooks: {', '.join(active)}") + if missing: + warn(f"Missing EDPA hooks: {', '.join(missing)} — run " + f"/edpa:setup --with-hooks") + for hook in foreign: + warn(f"{hook}: .git/hooks/{hook} already exists and is NOT EDPA-managed " + f"— EDPA's hook was NOT installed (skipped, your file untouched).") + info(f" purpose not wired: {_HOOK_PURPOSE[hook]}") + info(f" chain EDPA in by adding to .git/hooks/{hook}: " + f"sh .edpa/engine/scripts/hooks/{_HOOK_SRC[hook]} \"$@\"") + if not (installed or refreshed or active or missing or foreign): + info("No EDPA hook sources found (engine not vendored?) — nothing to do") return True @@ -347,6 +476,17 @@ def main() -> int: help="Copy plugin's architectural rules to .claude/rules/ so they " "auto-load into every Claude Code session in this repo.", ) + parser.add_argument( + "--refresh-hooks", action="store_true", + help="Re-register EDPA git hooks only (install missing, refresh " + "EDPA-owned, warn on foreign / print lefthook snippet). Skips " + "vendor/seed — used by the auto-update self-heal path.", + ) + parser.add_argument( + "--check-hooks", action="store_true", + help="Report EDPA git hook status (active/missing/foreign/lefthook) " + "without changing anything — the hooks doctor.", + ) parser.add_argument( "--root", type=Path, default=None, help="Project root (default: walk up from CWD to .git/)", @@ -354,6 +494,16 @@ def main() -> int: args = parser.parse_args() root = args.root.resolve() if args.root else find_repo_root(Path.cwd()) + + # Hook-only fast paths. They skip vendor/seed so they are cheap and safe to + # call on every session start (update_engine.sh self-heal) or on demand + # (the --check-hooks doctor). check_only wins if both are passed. + if args.check_hooks or args.refresh_hooks: + print(f"{C.BOLD}EDPA git hooks{C.RESET} (root: {root})") + install_hooks(root, refresh=args.refresh_hooks, + check_only=args.check_hooks) + return 0 + print(f"{C.BOLD}EDPA V2 project bootstrap{C.RESET}") print(f"Root: {root}") @@ -377,7 +527,7 @@ def main() -> int: if args.with_hooks: step(next_step, "Git hooks (--with-hooks)") - install_hooks(root) + install_hooks(root, refresh=True) next_step += 1 if args.with_rules: diff --git a/plugin/edpa/templates/people.yaml.tmpl b/plugin/edpa/templates/people.yaml.tmpl index 6e6ed0d..75a8619 100644 --- a/plugin/edpa/templates/people.yaml.tmpl +++ b/plugin/edpa/templates/people.yaml.tmpl @@ -134,7 +134,7 @@ people: # See docs/contribute-directive.md for full usage patterns. # # Schema validation: -# `python3 .claude/edpa/scripts/validate_syntax.py --strict .edpa/backlog/` +# `python3 .edpa/engine/scripts/validate_syntax.py --strict .edpa/backlog/` # blocks commits that don't match v1.11 schema. Legacy v1.7-v1.10 # `as:` field is rejected with migration breadcrumb pointing at # detect_contributors.py to regenerate. diff --git a/plugin/skills/setup/SKILL.md b/plugin/skills/setup/SKILL.md index 8ea4163..d7b0996 100644 --- a/plugin/skills/setup/SKILL.md +++ b/plugin/skills/setup/SKILL.md @@ -5,7 +5,8 @@ description: > Initialize EDPA V2 governance for a project. Vendors the engine (scripts + schemas + templates) into `.edpa/engine/`, creates `.edpa/config/{edpa.yaml,people.yaml}`, seeds id_counters.yaml, and - optionally copies the PR-signal CI workflow + installs git hooks. + optionally copies the PR-signal CI workflow + registers git hooks + (lefthook-aware; prints a snippet instead of clobbering managed hooks). No GitHub Project provisioning (V1 path removed in 2.0.0). license: MIT compatibility: Python 3.10+, MCP edpa server @@ -67,6 +68,18 @@ Resulting layout: separate manual step — copy from `.edpa/engine/templates/github-workflows/` to `.github/workflows/` after running setup. + - **Registration is robust + lefthook-aware:** + - Under **lefthook** (`lefthook.yml` present, which owns `.git/hooks/`), + EDPA does **not** write `.git/hooks/` — it prints a paste-ready snippet + (with `use_stdin: true` on pre-push, or the push hangs) to add to + `lefthook.yml`, then run `lefthook install`. + - A **foreign** hook already occupying a slot is never clobbered — EDPA + skips it and prints the exact line to chain itself in by hand. + - **Idempotent + self-refreshing:** re-running `--with-hooks` (or + `--refresh-hooks`) refreshes EDPA-owned hooks to the current version; + the SessionStart auto-update re-registers them after a plugin update. + - **Verify:** `python3 .edpa/engine/scripts/project_setup.py --check-hooks` + (read-only doctor — active / missing / foreign / lefthook). - `--with-rules` — copy `plugin/rules/*.md` to `.claude/rules/`. Claude Code auto-loads these into every agent session, so AI assistants in this repo follow the same ticket-first workflow @@ -87,7 +100,8 @@ The script first **vendors the engine** (`scripts` + `schemas` + `templates` + `VERSION`) into `.edpa/engine/` from `${CLAUDE_PLUGIN_ROOT}`, then seeds the configs + `id_counters.yaml`. It is idempotent — safe to re-run when adding hooks/CI after the initial -setup. +setup; re-running also refreshes EDPA-owned git hooks to the current +version (see `--check-hooks` to verify, `--refresh-hooks` to register only). ### 2. Edit the seeded configs @@ -180,4 +194,5 @@ python3 .edpa/engine/scripts/migrate_v1_to_v2.py # apply It seeds the counter from existing IDs, backfills timestamps from git log, archives `issue_map.yaml`, and strips the `sync:` block from `edpa.yaml`. After migration, run `project_setup.py --with-ci ---with-hooks` to opt into the CI workflow. +--with-hooks` to opt into the CI workflow + git hooks (verify with +`--check-hooks`). diff --git a/tests/test_project_setup_hooks.py b/tests/test_project_setup_hooks.py new file mode 100644 index 0000000..caf9ba0 --- /dev/null +++ b/tests/test_project_setup_hooks.py @@ -0,0 +1,161 @@ +"""Regression guard for project_setup.install_hooks() — robust git-hook +registration + lefthook coexistence (EDPA 2.3.0). + +Before 2.3.0, install_hooks used a blunt ``not dst.exists()`` guard: if any +file already occupied a hook slot (typically a lefthook dispatcher shim, since +lefthook owns .git/hooks/), EDPA silently skipped installing its hook — which +stopped the post-commit ``local_evidence.py`` contribution emitter from ever +firing. It also never refreshed a stale snapshot after a plugin update. + +These tests pin the new decision tree: + * lefthook detected → print snippet, leave .git/hooks/ untouched + * dst missing → install + * dst EDPA-owned → refresh on demand, else report active + * dst foreign → never clobber; warn loudly +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "plugin" / "edpa" / "scripts")) + +import project_setup as ps # noqa: E402 + +HOOK_NAMES = ("pre-commit", "pre-push", "commit-msg", "post-commit") + + +def _git_hooks(project: Path) -> Path: + """install_hooks only needs .git/hooks to exist — no real repo required.""" + hooks = project / ".git" / "hooks" + hooks.mkdir(parents=True, exist_ok=True) + return hooks + + +# ─── Fresh install ─────────────────────────────────────────────────────────── + + +def test_fresh_install_writes_all_four_hooks(tmp_path: Path) -> None: + hooks = _git_hooks(tmp_path) + assert ps.install_hooks(tmp_path, refresh=True) is True + for name in HOOK_NAMES: + dst = hooks / name + assert dst.exists(), f"{name} not installed" + assert dst.stat().st_mode & 0o111, f"{name} not executable" + assert ps.EDPA_HOOK_SENTINEL in dst.read_text(), f"{name} missing sentinel" + + +def test_no_git_hooks_dir_returns_false(tmp_path: Path) -> None: + # No .git/ at all → cannot install, returns False (does not crash). + assert ps.install_hooks(tmp_path) is False + + +# ─── check_only doctor ───────────────────────────────────────────────────────── + + +def test_check_only_writes_nothing(tmp_path: Path) -> None: + hooks = _git_hooks(tmp_path) + assert ps.install_hooks(tmp_path, check_only=True) is True + # Doctor mode must not create any hook files on an empty repo. + for name in HOOK_NAMES: + assert not (hooks / name).exists(), f"check-only created {name}" + + +def test_check_only_reports_active_after_install(tmp_path: Path, capsys) -> None: + _git_hooks(tmp_path) + ps.install_hooks(tmp_path, refresh=True) + capsys.readouterr() # drop install output + ps.install_hooks(tmp_path, check_only=True) + out = capsys.readouterr().out + assert "Active EDPA hooks" in out + for name in HOOK_NAMES: + assert name in out + + +# ─── Refresh semantics ───────────────────────────────────────────────────────── + + +def test_refresh_overwrites_edpa_owned(tmp_path: Path) -> None: + hooks = _git_hooks(tmp_path) + ps.install_hooks(tmp_path, refresh=True) + pc = hooks / "post-commit" + pc.write_text(pc.read_text() + "\n# STALE-MARKER\n") # tamper, keep sentinel + ps.install_hooks(tmp_path, refresh=True) + assert "STALE-MARKER" not in pc.read_text(), "refresh did not overwrite" + assert ps.EDPA_HOOK_SENTINEL in pc.read_text() + + +def test_no_refresh_leaves_edpa_owned_untouched(tmp_path: Path) -> None: + hooks = _git_hooks(tmp_path) + ps.install_hooks(tmp_path, refresh=True) + pc = hooks / "post-commit" + pc.write_text(pc.read_text() + "\n# KEEP-ME\n") + ps.install_hooks(tmp_path, refresh=False) # plain re-run, no refresh + assert "KEEP-ME" in pc.read_text(), "non-refresh run clobbered an EDPA hook" + + +# ─── Foreign hook protection ─────────────────────────────────────────────────── + + +def test_foreign_hook_never_clobbered(tmp_path: Path, capsys) -> None: + hooks = _git_hooks(tmp_path) + foreign = hooks / "post-commit" + foreign.write_text("#!/bin/sh\necho 'my own hook'\n") + ps.install_hooks(tmp_path, refresh=True) + # Foreign file is byte-for-byte preserved; EDPA sentinel never leaked in. + assert foreign.read_text() == "#!/bin/sh\necho 'my own hook'\n" + assert ps.EDPA_HOOK_SENTINEL not in foreign.read_text() + # The other three slots were free → installed. + for name in ("pre-commit", "pre-push", "commit-msg"): + assert ps.EDPA_HOOK_SENTINEL in (hooks / name).read_text() + # Loud warning + actionable chain-in instructions printed. + out = capsys.readouterr().out + assert "NOT EDPA-managed" in out + assert "post-commit-evidence" in out # the manual chain-in source path + + +# ─── Lefthook coexistence ────────────────────────────────────────────────────── + + +def test_lefthook_detected_prints_snippet_and_skips_git_hooks( + tmp_path: Path, capsys +) -> None: + hooks = _git_hooks(tmp_path) + (tmp_path / "lefthook.yml").write_text("# user config\n") + assert ps.install_hooks(tmp_path, refresh=True) is True + # Nothing written into .git/hooks/ — lefthook owns it. + for name in HOOK_NAMES: + assert not (hooks / name).exists(), f"{name} leaked into .git/hooks" + out = capsys.readouterr().out + assert "lefthook detected" in out + assert "use_stdin: true" in out # the critical pre-push correctness flag + + +@pytest.mark.parametrize( + "cfg", + ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", + ".lefthook.yaml", "lefthook.toml", "lefthook.json"], +) +def test_detect_lefthook_recognizes_all_config_names(tmp_path: Path, cfg: str) -> None: + assert ps.detect_lefthook(tmp_path) is None + (tmp_path / cfg).write_text("\n") + assert ps.detect_lefthook(tmp_path) == tmp_path / cfg + + +def test_lefthook_snippet_is_valid_yaml() -> None: + yaml = pytest.importorskip("yaml") + cfg = yaml.safe_load(ps.LEFTHOOK_SNIPPET) + # All four git hooks present, each with at least one command. + for hook in HOOK_NAMES: + assert hook in cfg, f"snippet missing {hook}" + assert cfg[hook]["commands"], f"{hook} has no commands" + # pre-push reads refs on stdin → command MUST set use_stdin, or lefthook + # hangs the push. This is the correctness flag the verification turned up. + pre_push_cmd = next(iter(cfg["pre-push"]["commands"].values())) + assert pre_push_cmd.get("use_stdin") is True + # commit-msg passes the message file as the first positional arg. + commit_msg_cmd = next(iter(cfg["commit-msg"]["commands"].values())) + assert "{1}" in commit_msg_cmd["run"] diff --git a/tests/test_update_engine_hook.py b/tests/test_update_engine_hook.py index a918d08..8decd38 100644 --- a/tests/test_update_engine_hook.py +++ b/tests/test_update_engine_hook.py @@ -169,3 +169,56 @@ def test_finds_edpa_root_from_subdirectory(tmp_path): assert result.returncode == 0, result.stderr assert "updating engine" in result.stderr assert (tmp_path / ".edpa/engine/VERSION").read_text().strip() == _current_plugin_version() + + +# ─── Git-hook self-heal after update (2.3.0) ──────────────────────────────── + +SENTINEL = "EDPA-MANAGED-HOOK" + + +def _git_hooks(project: Path) -> Path: + hooks = project / ".git" / "hooks" + hooks.mkdir(parents=True, exist_ok=True) + return hooks + + +def test_self_heal_reregisters_when_edpa_hooks_present(tmp_path): + """A prior EDPA install (sentinel in .git/hooks/) signals opt-in; after an + engine update the hook re-registers — reinstalling a clobbered hook and + refreshing the rest. This is the user's "hooks gone after update" fix.""" + _seed_engine(tmp_path, version="1.0.0") + hooks = _git_hooks(tmp_path) + # Simulate: post-commit survived (EDPA-owned), pre-commit got clobbered/removed. + (hooks / "post-commit").write_text(f"#!/bin/sh\n# {SENTINEL}\nexit 0\n") + result = _run(tmp_path) + assert result.returncode == 0, result.stderr + assert "re-registering git hooks" in result.stderr + # The missing pre-commit hook is now installed with the sentinel. + assert (hooks / "pre-commit").exists() + assert SENTINEL in (hooks / "pre-commit").read_text() + + +def test_self_heal_skipped_when_no_edpa_hooks(tmp_path): + """A repo that never opted into hooks must not get them forced on update.""" + _seed_engine(tmp_path, version="1.0.0") + hooks = _git_hooks(tmp_path) # empty .git/hooks, no EDPA sentinel anywhere + result = _run(tmp_path) + assert result.returncode == 0, result.stderr + assert "updating engine" in result.stderr + assert "re-registering git hooks" not in result.stderr + for name in ("pre-commit", "pre-push", "commit-msg", "post-commit"): + assert not (hooks / name).exists(), f"{name} forced onto opt-out repo" + + +def test_self_heal_lefthook_prints_check_reminder(tmp_path): + """Under lefthook the hook does not edit .git/hooks/ — it points the user + at the doctor instead (EDPA never edits the lefthook config).""" + _seed_engine(tmp_path, version="1.0.0") + hooks = _git_hooks(tmp_path) + (tmp_path / "lefthook.yml").write_text("# user config\n") + result = _run(tmp_path) + assert result.returncode == 0, result.stderr + assert "lefthook detected" in result.stderr + assert "--check-hooks" in result.stderr + for name in ("pre-commit", "pre-push", "commit-msg", "post-commit"): + assert not (hooks / name).exists(), f"{name} written under lefthook" diff --git a/web/package-lock.json b/web/package-lock.json index 718f8df..0be9038 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "edpa-web", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "edpa-web", - "version": "2.2.1", + "version": "2.3.0", "dependencies": { "@vercel/analytics": "^2.0.1", "astro": "^5.7.0", diff --git a/web/package.json b/web/package.json index 4f465ee..fe8080a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "edpa-web", "type": "module", - "version": "2.2.1", + "version": "2.3.0", "private": true, "scripts": { "dev": "astro dev", diff --git a/web/src/pages/en/guide.astro b/web/src/pages/en/guide.astro index 8a9cbb2..fb2ab8a 100644 --- a/web/src/pages/en/guide.astro +++ b/web/src/pages/en/guide.astro @@ -101,6 +101,9 @@ python3 .edpa/engine/scripts/setup.py --with-ci`, ✓ Workflow: .github/workflows/edpa-contribution-sync.yml → PR-thread signals → evidence[] on items ✓ Git hooks installed (commit-msg: feat(): …) +# Under lefthook (lefthook.yml present): EDPA does not write .git/hooks/, +# it prints a snippet → paste into lefthook.yml and run: lefthook install +# Verify anytime: project_setup.py --check-hooks # Local-first: no org, no PAT, no GitHub Projects.`, }, diff --git a/web/src/pages/en/methodology.astro b/web/src/pages/en/methodology.astro index 2abd3d8..123b9dd 100644 --- a/web/src/pages/en/methodology.astro +++ b/web/src/pages/en/methodology.astro @@ -372,7 +372,7 @@ Accuracy = Actual / Planned × 100 %

9.2 The engine runs locally; the GitHub layer is optional

-

V2 has no GitHub Actions pipeline. The engine, WSJF computation, iteration/PI close, and velocity all run locally via /edpa:close-iteration and MCP tools. Attribution is handled by a post-commit hook (local_evidence.py) — it emits commit_author for every commit that references an EDPA item, and works offline and on GitLab/Forgejo too.

+

V2 has no GitHub Actions pipeline. The engine, WSJF computation, iteration/PI close, and velocity all run locally via /edpa:close-iteration and MCP tools. Attribution is handled by a post-commit hook (local_evidence.py) — it emits commit_author for every commit that references an EDPA item, and works offline and on GitLab/Forgejo too. Hooks register into .git/hooks/, or — if the project uses lefthook — via a printed snippet for lefthook.yml.

diff --git a/web/src/pages/en/playbook.astro b/web/src/pages/en/playbook.astro index fb4b617..6279d4e 100644 --- a/web/src/pages/en/playbook.astro +++ b/web/src/pages/en/playbook.astro @@ -371,6 +371,27 @@ git checkout -b chore/T-050-ci-pipeline
+

Hook registration is idempotent and self-refreshing (the SessionStart auto-update re-registers them after a plugin update). Foreign (non-EDPA) hooks are never clobbered — they are skipped with a warning. Check hook state (active / missing / foreign / lefthook) anytime with the read-only doctor:

+
python3 .edpa/engine/scripts/project_setup.py --check-hooks
+python3 .edpa/engine/scripts/project_setup.py --refresh-hooks   # register only
+
lefthook: if the project uses lefthook.yml (lefthook owns .git/hooks/), EDPA does not write .git/hooks/ — instead it prints a paste-ready snippet to add to lefthook.yml, then run lefthook install. Note: pre-push must have use_stdin: true or lefthook hangs.
+
pre-commit:
+  commands:
+    edpa-id-safety:
+      run: sh .edpa/engine/scripts/hooks/pre-commit-id-safety
+commit-msg:
+  commands:
+    edpa-ticket-attached:
+      run: sh .edpa/engine/scripts/hooks/commit-msg-ticket-attached {1}
+post-commit:
+  commands:
+    edpa-evidence:
+      run: sh .edpa/engine/scripts/hooks/post-commit-evidence
+pre-push:
+  commands:
+    edpa-id-safety:
+      run: sh .edpa/engine/scripts/hooks/pre-push-id-safety {1} {2}
+      use_stdin: true

Commit convention:

git commit -m "feat(S-200): implement OMOP CDM parser"
diff --git a/web/src/pages/en/setup.astro b/web/src/pages/en/setup.astro
index 6fb9caf..271010e 100644
--- a/web/src/pages/en/setup.astro
+++ b/web/src/pages/en/setup.astro
@@ -182,6 +182,27 @@ import Layout from '../../layouts/Layout.astro';
         

Claude Code skills/commands come from the installed plugin. /edpa:setup then vendors the engine (scripts, templates) into .edpa/engine/ and creates the .edpa/ structure for project data.

+ +
+

--with-hooks registers 4 git hooks (pre-commit, commit-msg, post-commit, pre-push) into .git/hooks/. Registration is idempotent and foreign hooks are never clobbered. If the project uses lefthook (lefthook.yml owns .git/hooks/), EDPA instead prints a paste-ready snippet — add it to lefthook.yml and run lefthook install. Check hook state anytime with project_setup.py --check-hooks.

+
pre-commit:
+  commands:
+    edpa-id-safety:
+      run: sh .edpa/engine/scripts/hooks/pre-commit-id-safety
+commit-msg:
+  commands:
+    edpa-ticket-attached:
+      run: sh .edpa/engine/scripts/hooks/commit-msg-ticket-attached {1}
+post-commit:
+  commands:
+    edpa-evidence:
+      run: sh .edpa/engine/scripts/hooks/post-commit-evidence
+pre-push:
+  commands:
+    edpa-id-safety:
+      run: sh .edpa/engine/scripts/hooks/pre-push-id-safety {1} {2}
+      use_stdin: true
+
diff --git a/web/src/pages/guide.astro b/web/src/pages/guide.astro index 976a6e0..87ff0b9 100644 --- a/web/src/pages/guide.astro +++ b/web/src/pages/guide.astro @@ -101,6 +101,9 @@ python3 .edpa/engine/scripts/setup.py --with-ci`, ✓ Workflow: .github/workflows/edpa-contribution-sync.yml → PR-thread signály → evidence[] na items ✓ Git hooks installed (commit-msg: feat(): …) +# Pod lefthookem (lefthook.yml přítomen): EDPA nepíše do .git/hooks/, +# ale vytiskne snippet → vlož do lefthook.yml a spusť: lefthook install +# Ověření kdykoli: project_setup.py --check-hooks # Local-first: žádný org, žádný PAT, žádné GitHub Projects.`, }, diff --git a/web/src/pages/methodology.astro b/web/src/pages/methodology.astro index 3838497..3554a5f 100644 --- a/web/src/pages/methodology.astro +++ b/web/src/pages/methodology.astro @@ -371,7 +371,7 @@ Accuracy = Actual / Planned × 100 %

9.2 Engine běží lokálně, GitHub vrstva je volitelná

-

V2 nemá pipeline GitHub Actions. Engine, výpočet WSJF, iteration/PI close i velocity běží lokálně přes /edpa:close-iteration a MCP nástroje. Atribuci řeší post-commit hook (local_evidence.py) — pro každý commit s referencí na EDPA item emituje commit_author, funguje i offline a na GitLab/Forgejo.

+

V2 nemá pipeline GitHub Actions. Engine, výpočet WSJF, iteration/PI close i velocity běží lokálně přes /edpa:close-iteration a MCP nástroje. Atribuci řeší post-commit hook (local_evidence.py) — pro každý commit s referencí na EDPA item emituje commit_author, funguje i offline a na GitLab/Forgejo. Hooky se registrují do .git/hooks/, nebo — pokud projekt používá lefthook — přes vytištěný snippet do lefthook.yml.

diff --git a/web/src/pages/playbook.astro b/web/src/pages/playbook.astro index 54a27a3..bde5d67 100644 --- a/web/src/pages/playbook.astro +++ b/web/src/pages/playbook.astro @@ -373,6 +373,27 @@ git checkout -b chore/T-050-ci-pipeline
+

Registrace hooků je idempotentní a self-refreshing (SessionStart auto-update je po update pluginu znovu zaregistruje). Cizí (ne-EDPA) hooky se nikdy nepřepisují — přeskočí se s varováním. Stav hooků (aktivní / chybějící / cizí / lefthook) zkontroluješ kdykoli read-only doctorem:

+
python3 .edpa/engine/scripts/project_setup.py --check-hooks
+python3 .edpa/engine/scripts/project_setup.py --refresh-hooks   # jen znovu zaregistruje
+
lefthook: pokud projekt používá lefthook.yml (lefthook vlastní .git/hooks/), EDPA do .git/hooks/ nezapisuje — místo toho vytiskne hotový snippet, který vložíš do lefthook.yml, a pak spustíš lefthook install. Pozor: pre-push musí mít use_stdin: true, jinak lefthook visí.
+
pre-commit:
+  commands:
+    edpa-id-safety:
+      run: sh .edpa/engine/scripts/hooks/pre-commit-id-safety
+commit-msg:
+  commands:
+    edpa-ticket-attached:
+      run: sh .edpa/engine/scripts/hooks/commit-msg-ticket-attached {1}
+post-commit:
+  commands:
+    edpa-evidence:
+      run: sh .edpa/engine/scripts/hooks/post-commit-evidence
+pre-push:
+  commands:
+    edpa-id-safety:
+      run: sh .edpa/engine/scripts/hooks/pre-push-id-safety {1} {2}
+      use_stdin: true

Commit konvence:

git commit -m "feat(S-200): implement OMOP CDM parser"
diff --git a/web/src/pages/setup.astro b/web/src/pages/setup.astro
index 1b118a2..51933b4 100644
--- a/web/src/pages/setup.astro
+++ b/web/src/pages/setup.astro
@@ -182,6 +182,27 @@ import Layout from '../layouts/Layout.astro';
         

Claude Code skills/commands prijdou z nainstalovaneho pluginu. /edpa:setup pak vendoruje engine (skripty, sablony) do .edpa/engine/ a vytvori .edpa/ strukturu pro projektova data.

+ +
+

--with-hooks registruje 4 git hooky (pre-commit, commit-msg, post-commit, pre-push) do .git/hooks/. Registrace je idempotentni a cizi hooky se nikdy neprepisuji. Pokud projekt pouziva lefthook (lefthook.yml vlastni .git/hooks/), EDPA misto toho vytiskne hotovy snippet — vloz ho do lefthook.yml a spust lefthook install. Stav hooku kdykoli overis pres project_setup.py --check-hooks.

+
pre-commit:
+  commands:
+    edpa-id-safety:
+      run: sh .edpa/engine/scripts/hooks/pre-commit-id-safety
+commit-msg:
+  commands:
+    edpa-ticket-attached:
+      run: sh .edpa/engine/scripts/hooks/commit-msg-ticket-attached {1}
+post-commit:
+  commands:
+    edpa-evidence:
+      run: sh .edpa/engine/scripts/hooks/post-commit-evidence
+pre-push:
+  commands:
+    edpa-id-safety:
+      run: sh .edpa/engine/scripts/hooks/pre-push-id-safety {1} {2}
+      use_stdin: true
+