diff --git a/CLAUDE.md b/CLAUDE.md index 15aed5c..5910a16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,39 @@ All web API endpoints must be versioned from day one: `/api/v1/...`. behavior to match newer one. - Router structure reflects versioning explicitly (e.g. `api/v1/routes/`). +## Version control — gitflow (universal) + +Every git action follows gitflow — inside a skill AND for ad-hoc commits made +outside one on direct request. The model is universal across all projects. + +### Branch model +`main` (prod) · `develop` (integration, off main) · `feature/*` + `bugfix/*` +(off develop → develop) · `release/*` (off develop → main + back-merge develop) +· `hotfix/*` (off main → main + develop [+ any open release/*]). `master`→`main` +everywhere. + +### Rules for every git action +- **Never commit code directly on `main` or `develop`.** Branch first from the + correct base, named `/`. (`.claude/**` memory/config commits are + exempt — they follow the work, not the code's gitflow.) +- **Branch + merge via the lib, never by hand** — the directed-merge + hotfix + fan-out logic lives there once: + `bash ~/.claude/lib/gitflow.sh start ` · `… finish`. +- **`gitflow finish` (merge) only on an explicit human signal** ("merge it", + "feature OK") — never because tests pass, a plan step says "merge", or a verb + ("ship") implied it. +- **Assistance flows** (`/feat` `/bugfix` `/hotfix`) auto-branch on a protected + base (the aiguillage); on a working branch they commit in place, never finish. +- **New/onboarded projects** get the model + the versioned pre-commit hook via + `gitflow init` (init-project STEP 5f, onboard STEP 2.6). + +### Enforcement layers +Advisory — it can be forgotten on a long conversation (no reliable oracle). The +deterministic backstops are the per-repo **pre-commit hook** (`gitflow init` +installs it: blocks code commits on main/develop, exempts `.claude/**` + merges + +the root commit) and **Gitea branch protection** on `main`/`develop` (set up by +the migration). Don't lean on `--no-verify` to bypass them. + ## Security — non-negotiable defaults Apply at every dev step: design, scaffolding, implementation, review. diff --git a/agents/bugfixer.md b/agents/bugfixer.md index 6e1b822..59210b3 100644 --- a/agents/bugfixer.md +++ b/agents/bugfixer.md @@ -102,6 +102,10 @@ RISK: ## STEP 4 — FIX +**Gitflow aiguillage (before editing):** follow `$HOME/.claude/lib/gitflow-aiguillage.md` +— your type = `bugfix`. On `main`/`develop` it branches first; on a working +branch it's a no-op (commit in place). Never `finish`. + Apply the fix following the plan: - Fix the root cause, not the symptom. diff --git a/agents/feater.md b/agents/feater.md index 75e1050..7751099 100644 --- a/agents/feater.md +++ b/agents/feater.md @@ -90,6 +90,10 @@ If ambiguous: ask the user one focused question, then proceed. ## STEP 2 — IMPLEMENT +**Gitflow aiguillage (before editing):** follow `$HOME/.claude/lib/gitflow-aiguillage.md` +— your type = `feature`. On `main`/`develop` it branches first; on a working +branch it's a no-op (commit in place). Never `finish`. + Work through the plan: - Implement directly (no subagents). diff --git a/agents/hotfixer.md b/agents/hotfixer.md index e9f6c64..fa21e9e 100644 --- a/agents/hotfixer.md +++ b/agents/hotfixer.md @@ -49,6 +49,10 @@ Follow `$HOME/.claude/lib/design-gate.md`: ## STEP 2 — PRE-FLIGHT + FIX +**Gitflow aiguillage (before editing):** follow `$HOME/.claude/lib/gitflow-aiguillage.md` +— your type = `hotfix`. On `main`/`develop` it branches first; on a working +branch it's a no-op (commit in place). Never `finish`. + ### Pre-flight (mandatory) Before editing, snapshot current state so revert is possible: diff --git a/lib/gitflow-aiguillage.md b/lib/gitflow-aiguillage.md new file mode 100644 index 0000000..20a7628 --- /dev/null +++ b/lib/gitflow-aiguillage.md @@ -0,0 +1,26 @@ +# Gitflow aiguillage — assistance flows branch on a protected base + +Assistance flows (`/feat`, `/bugfix`, `/hotfix`) commit IN PLACE on a working +branch — the frequent case, behavior unchanged. But they must NEVER commit code +on a protected base (`main`/`develop`). Run this check **before editing any +file**. The caller passes its TYPE: feat→`feature`, bugfix→`bugfix`, +hotfix→`hotfix`. + +```bash +bash "$HOME/.claude/lib/gitflow.sh" protected-base && echo PROTECTED || echo WORKING +``` + +- **WORKING** (`feature/*`, `bugfix/*`, `hotfix/*`, or any non-protected branch) + → proceed; you commit in place on this branch. Nothing changes. +- **PROTECTED** (`main`/`develop`) → branch first, do NOT commit here: + ```bash + bash "$HOME/.claude/lib/gitflow.sh" start + ``` + `` derived from the request. Then do the work on the new branch. + +**Never run `gitflow finish`** — assistance flows commit, they do not merge. +Integration is a separate, human-gated step (the `gitflow` skill). + +Note: `hotfix` branches off **main** (prod) even when invoked from `develop` — +that is the gitflow definition of a hotfix. For a dev-scoped small fix, use +`/bugfix` (branches off develop). diff --git a/lib/gitflow-migrate.sh b/lib/gitflow-migrate.sh new file mode 100755 index 0000000..6cbc9b4 --- /dev/null +++ b/lib/gitflow-migrate.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# gitflow-migrate.sh — migrate an existing repo to the gitflow model. +# LOCAL (no token): gitflow init existing → master→main, develop, socle, hook. +# PROBE (token, READ-ONLY): identity + scope/rights, before any write. +# REMOTE (token, DESTRUCTIVE): push, default→main, protection, delete master. +# Writes ordered reversible→irreversible; DELETE master is LAST and only +# runs if every prior step succeeded. Halts on first failure. +# No `... | grep -q` under pipefail (SIGPIPE false-negative gotcha). Never echo the token. +set -uo pipefail +GITEA="${GITEA_URL:-https://git.bchanot.fr}" +OWNER="${GITEA_OWNER:-bchanot}" + +# ── LOCAL half (token-free) ────────────────────────────────────────────────── +migrate_local() { # + local repo="$1" renamed="no" + cd "$repo" || { echo " ✗ cannot cd $repo" >&2; return 1; } + [ -z "$(git status --porcelain)" ] || { echo " ✗ working tree not clean — stash/commit first" >&2; return 2; } + { [ -n "$(git config user.name)" ] && [ -n "$(git config user.email)" ]; } \ + || { echo " ✗ git identity unset (user.name/user.email) — set it before migrating $repo" >&2; return 3; } + git show-ref --verify -q refs/heads/master && renamed="yes" + bash "$HOME/.claude/lib/gitflow.sh" init || return 1 + git show-ref --verify -q refs/heads/main || { echo " ✗ no main" >&2; return 1; } + git show-ref --verify -q refs/heads/develop || { echo " ✗ no develop" >&2; return 1; } + [ "$(git config core.hooksPath)" = ".githooks" ] || { echo " ✗ hook not active" >&2; return 1; } + [ -z "$(git status --porcelain)" ] || { echo " ✗ tree dirty after init" >&2; return 1; } + echo " ✓ local: main+develop, hook active, tree clean (master→main: $renamed)" +} + +# ── Gitea API helper (token in header only; never printed) ──────────────────── +_gitea() { # [json-body] + local m="$1" p="$2" body="${3:-}" + curl -fsS -X "$m" -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" ${body:+-d "$body"} "$GITEA/api/v1$p" +} +_json() { python3 -c "import sys,json;$1" 2>/dev/null; } # tiny JSON field reader + +# ── PROBE (READ-ONLY: identity informational, rights = the real gate) ───────── +# /user needs read:user (cosmetic — the migration never calls it) → informational. +# The gates are the repo-scoped rights the writes actually require: admin+push on +# the repo, and admin scope confirmed by a readable branch_protections list. +gitea_probe() { # + local name="$1" me pj perm + [ -n "${GITEA_TOKEN:-}" ] || { echo " ✗ GITEA_TOKEN unset" >&2; return 1; } + + # [a] identity — INFORMATIONAL (needs read:user scope the migration never uses) + if me=$(_gitea GET "/user" 2>/dev/null | _json "print(json.load(sys.stdin).get('login','?'))") && [ -n "$me" ]; then + echo " ✓ token identity: $me" + else + echo " ⚠ token identity unavailable (no read:user scope) — cosmetic, migration is repo-scoped" + fi + + # [b] repo rights — GATE: admin AND push must be true (default_branch, protections, push) + pj=$(_gitea GET "/repos/$OWNER/$name") \ + || { echo " ✗ GET /repos/$OWNER/$name failed — token lacks repo read scope" >&2; return 1; } + perm=$(printf '%s' "$pj" | _json "p=json.load(sys.stdin).get('permissions',{});print('admin=%s push=%s pull=%s'%(p.get('admin'),p.get('push'),p.get('pull')))") + printf '%s' "$pj" | _json "p=json.load(sys.stdin).get('permissions',{});sys.exit(0 if (p.get('admin') and p.get('push')) else 1)" \ + || { echo " ✗ insufficient rights on $name ($perm) — need admin+push" >&2; return 1; } + echo " ✓ rights on $name: $perm (admin+push confirmed)" + + # [c] admin-scope canary — GATE: branch_protections readable (POST/PATCH/DELETE need repo-admin) + _gitea GET "/repos/$OWNER/$name/branch_protections" >/dev/null \ + || { echo " ✗ cannot read branch_protections — token lacks repo-admin scope; protection step would fail" >&2; return 1; } + echo " ✓ repo-admin scope confirmed (branch_protections readable → POST/PATCH/DELETE OK)" +} + +# ── REMOTE half (DESTRUCTIVE; reversible→irreversible; delete master LAST) ──── +_protect() { # (Option 1: owner-pushable) + _gitea POST "/repos/$OWNER/$1/branch_protections" \ + "{\"branch_name\":\"$2\",\"enable_push\":true,\"enable_push_whitelist\":true,\"push_whitelist_usernames\":[\"$OWNER\"]}" +} +migrate_remote() { # (cwd = the local repo) + local name="$1" + [ -n "${GITEA_TOKEN:-}" ] || { echo " ✗ GITEA_TOKEN unset" >&2; return 1; } + echo " [1/4] push main + develop (ADDITIVE/reversible)…" + git push -u origin main || { echo " ✗ push main failed (push scope?) — STOP, nothing irreversible done" >&2; return 1; } + git push -u origin develop || { echo " ✗ push develop failed — STOP" >&2; return 1; } + echo " [2/4] default_branch → main (REVERSIBLE — scope canary)…" + _gitea PATCH "/repos/$OWNER/$name" '{"default_branch":"main"}' >/dev/null \ + || { echo " ✗ PATCH default_branch failed (admin/write scope?) — STOP before protection & delete" >&2; return 1; } + echo " [3/4] branch protection main + develop (REVERSIBLE)…" + _protect "$name" main >/dev/null || { echo " ✗ protect main failed — STOP before delete" >&2; return 1; } + _protect "$name" develop >/dev/null || { echo " ✗ protect develop failed — STOP before delete" >&2; return 1; } + echo " [4/4] DELETE remote master (IRREVERSIBLE — last; default already repointed)…" + git push origin --delete master || { echo " ✗ delete master failed (left in place — safe)" >&2; return 1; } + echo " ✓ remote: default=main, main/develop protected (owner-pushable), remote master deleted" +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + case "${1:-}" in + local) migrate_local "$2" ;; + probe) gitea_probe "$2" ;; + remote) migrate_remote "$2" ;; + *) echo "usage: gitflow-migrate.sh {local |probe |remote }" >&2; exit 2 ;; + esac +fi diff --git a/lib/gitflow-test.sh b/lib/gitflow-test.sh new file mode 100644 index 0000000..8a181fc --- /dev/null +++ b/lib/gitflow-test.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Throwaway-repo test suite for lib/gitflow.sh. Each test builds an isolated +# repo under $WORK, asserts, and cleans up. Run: bash lib/gitflow-test.sh +# +# shellcheck disable=SC2016 +# (the chk helper EVALs its second arg; single-quoted assertion strings are +# intentional — they must not expand at definition time.) +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# Do NOT override GITFLOW_GITIGNORE_TEMPLATE: the lib self-resolves it from its +# own location (../templates), which is correct in both the repo and installed. +# shellcheck source=/dev/null +source "$HERE/gitflow.sh" + +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } +chk() { if eval "$2"; then ok "$1"; else no "$1 [ $2 ]"; fi; } +newrepo() { local d="$WORK/$1"; rm -rf "$d"; mkdir -p "$d"; cd "$d" || return 1; git init -q; \ + git config user.email t@t; git config user.name t; \ + git config core.hooksPath /dev/null; } # hooks off during setup +hookon() { git config --unset core.hooksPath 2>/dev/null || true; } # use repo default .githooks + +echo "T1 — pure predicates" +chk "type feature" '[ "$(gitflow_branch_type feature/x)" = feature ]' +chk "type hotfix" '[ "$(gitflow_branch_type hotfix/x)" = hotfix ]' +chk "type main" '[ "$(gitflow_branch_type main)" = main ]' +chk "type other" '[ "$(gitflow_branch_type wip/x)" = other ]' +chk "protected main" 'gitflow_protected_base main' +chk "protected develop" 'gitflow_protected_base develop' +chk "not protected feat" '! gitflow_protected_base feature/x' +chk "base feature=develop" '[ "$(gitflow_base_for feature)" = develop ]' +chk "base hotfix=main" '[ "$(gitflow_base_for hotfix)" = main ]' + +echo "T2 — init fresh (BLK-010 root commit)" +newrepo fresh; echo scaffold > README.md; hookon +gitflow_init "chore: scaffold" >/dev/null 2>&1 +chk "main exists" 'git rev-parse --verify -q refs/heads/main >/dev/null' +chk "develop exists" 'git rev-parse --verify -q refs/heads/develop >/dev/null' +chk "root commit on main" '[ -n "$(git rev-parse -q --verify main)" ]' +chk "gitignore created" '[ -f .gitignore ]' +chk "socle: !.claude/deploy/" 'grep -qxF "!.claude/deploy/" .gitignore' +chk "socle: re-ignore PENDING" 'grep -qxF ".claude/deploy/PENDING.json" .gitignore' +chk "hook installed" '[ -x .githooks/pre-commit ] && [ "$(git config core.hooksPath)" = .githooks ]' +chk "tree CLEAN after init" '[ -z "$(git status --porcelain)" ]' +chk "hook TRACKED in commit" 'git ls-files --error-unmatch .githooks/pre-commit >/dev/null 2>&1' +chk "socle IN root commit" 'git show HEAD:.gitignore | grep -qxF ".claude/deploy/PENDING.json"' + +echo "T2b — init existing (master→main rename + adoption commit, hook inactive during it)" +newrepo existing +git symbolic-ref HEAD refs/heads/master # force the repo onto 'master' +echo a > a.txt; printf 'node_modules/\n' > .gitignore; git add -A +git -c core.hooksPath=/dev/null commit -q -m "pre-existing on master" +hookon +gitflow_init >/dev/null 2>&1 +chk "master→main renamed" 'git rev-parse --verify -q refs/heads/main >/dev/null && ! git rev-parse --verify -q refs/heads/master >/dev/null' +chk "develop created" 'git rev-parse --verify -q refs/heads/develop >/dev/null' +chk "adoption commit" 'git log main --oneline | grep -q "adopt gitflow"' +chk "existing tree CLEAN" '[ -z "$(git status --porcelain)" ]' +chk "existing hook tracked" 'git ls-files --error-unmatch .githooks/pre-commit >/dev/null 2>&1' +chk "kept project rule" 'git show HEAD:.gitignore | grep -qxF "node_modules/"' + +echo "T3 — hook blocks/permits after init" +cd "$WORK/fresh" || exit 1 +git checkout -q main +echo x >> README.md; git add README.md +chk "block direct code on main" '! git commit -q -m onmain 2>/dev/null' +git restore --staged README.md 2>/dev/null; git checkout -q -- README.md +mkdir -p .claude/memory; echo m > .claude/memory/decisions.md; git add .claude/memory/decisions.md +chk "allow .claude/** on main" 'git commit -q -m "chore(memory)" 2>/dev/null' +gitflow_start feature demo >/dev/null 2>&1 +echo f > feat.txt; git add feat.txt +chk "allow code on feature" 'git commit -q -m "feat work" 2>/dev/null' + +echo "T4/T5 — start picks correct base" +newrepo starts; echo a>a; hookon; gitflow_init >/dev/null 2>&1 +gitflow_start feature foo >/dev/null 2>&1 +chk "feature off develop" '[ "$(git symbolic-ref --short HEAD)" = feature/foo ]' +chk "feature has develop ancestry" 'git merge-base --is-ancestor develop HEAD' +git checkout -q develop +gitflow_start hotfix bar >/dev/null 2>&1 +chk "hotfix branch named" '[ "$(git symbolic-ref --short HEAD)" = hotfix/bar ]' +chk "hotfix off main" 'git merge-base --is-ancestor main HEAD' + +echo "T6 — finish feature → develop only" +newrepo finfeat; echo a>a; hookon; gitflow_init >/dev/null 2>&1 +gitflow_start feature f1 >/dev/null 2>&1; echo w>w.txt; git add w.txt; git commit -q -m w +main_before="$(git rev-parse main)" +gitflow_finish >/dev/null 2>&1 +chk "merged into develop" 'git log develop --oneline | grep -q "Merge feature/f1 into develop"' +chk "main untouched" "[ \"\$(git rev-parse main)\" = \"$main_before\" ]" +chk "branch deleted" '! git rev-parse --verify -q refs/heads/feature/f1 >/dev/null' + +echo "T7 — finish hotfix → main + develop fan-out" +newrepo finhot; echo a>a; hookon; gitflow_init >/dev/null 2>&1 +gitflow_start hotfix h1 >/dev/null 2>&1; echo p>patch.txt; git add patch.txt; git commit -q -m patch +gitflow_finish >/dev/null 2>&1 +chk "hotfix in main" 'git log main --oneline | grep -q "Merge hotfix/h1 into main"' +chk "hotfix in develop" 'git log develop --oneline | grep -q "Merge hotfix/h1 into develop"' +chk "hotfix branch gone" '! git rev-parse --verify -q refs/heads/hotfix/h1 >/dev/null' + +echo "T8 — finish hotfix also lands in OPEN release" +newrepo finhotrel; echo a>a; hookon; gitflow_init >/dev/null 2>&1 +gitflow_start release 1.0 >/dev/null 2>&1; echo r>rel.txt; git add rel.txt; git commit -q -m relwork +gitflow_start hotfix h2 >/dev/null 2>&1; echo p>p2.txt; git add p2.txt; git commit -q -m patch2 +gitflow_finish >/dev/null 2>&1 +chk "hotfix in open release" 'git log release/1.0 --oneline | grep -q "Merge hotfix/h2 into release/1.0"' + +echo "T9 — reconcile is additive + idempotent + preserves project rules" +newrepo recon; echo a>a; git add a; git commit -q -m a +printf '%s\n' "node_modules/" "# my project rule" > .gitignore +gitflow_reconcile_gitignore 2>/dev/null +chk "kept project rule" 'grep -qxF "node_modules/" .gitignore' +chk "added socle" 'grep -qxF ".claude/*" .gitignore' +before="$(md5sum .gitignore)" +gitflow_reconcile_gitignore 2>/dev/null +chk "idempotent 2nd run" "[ \"$before\" = \"\$(md5sum .gitignore)\" ]" + +echo "T10 — COHERENCE: hook verdict == lib predicate (drift detector, #4)" +newrepo coh; echo a>a; hookon; gitflow_init >/dev/null 2>&1 +for br in main develop feature/x bugfix/y release/z hotfix/w master mainline qa; do + if gitflow_protected_base "$br"; then lib=protected; else lib=open; fi + git checkout -q -B "$br" 2>/dev/null + printf 'x\n' >> a; git add a + if .githooks/pre-commit 2>/dev/null; then hook=allow; else hook=block; fi + git restore --staged a 2>/dev/null || true + if { [ "$lib" = protected ] && [ "$hook" = block ]; } || { [ "$lib" = open ] && [ "$hook" = allow ]; }; then + ok "coherent($br): lib=$lib hook=$hook" + else + no "DRIFT($br): lib=$lib hook=$hook" + fi +done + +echo "T11 — CLI executable mode (the contract orchestrators call)" +newrepo cli; echo a>a +bash "$HERE/gitflow.sh" init >/dev/null 2>&1 +chk "cli init → develop" 'git rev-parse --verify -q refs/heads/develop >/dev/null' +cli_out="$(bash "$HERE/gitflow.sh" start feature cli-foo 2>/dev/null)" +chk "cli start echoes branch" "[ \"$cli_out\" = feature/cli-foo ]" +chk "cli start switched HEAD" '[ "$(git symbolic-ref --short HEAD)" = feature/cli-foo ]' +if bash "$HERE/gitflow.sh" protected-base main; then ok "cli protected-base main → rc0"; else no "cli protected-base main"; fi +if bash "$HERE/gitflow.sh" protected-base feature/x; then no "cli protected-base feature (rc0?)"; else ok "cli protected-base feature → rc1"; fi +chk "cli base-for hotfix=main" '[ "$(bash "$HERE/gitflow.sh" base-for hotfix)" = main ]' + +echo +echo "==== RESULT: $PASS passed, $FAIL failed ====" +[ "$FAIL" -eq 0 ] diff --git a/lib/gitflow.sh b/lib/gitflow.sh new file mode 100644 index 0000000..ca2067c --- /dev/null +++ b/lib/gitflow.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# gitflow.sh — mechanical core of the gitflow model. +# +# Two ways in: +# - SOURCED by tests / skills that want the functions. +# - EXECUTED as a CLI dispatcher: `gitflow.sh [args]` (how skills call it, +# one Bash invocation per operation). +# +# The judgment layer (WHEN to finish — the human gate) lives in skills/gitflow/ +# SKILL.md, never here. This file only does the deterministic mechanics, so it +# can be tested on throwaway repos. Mirrors the surgical-commit helper style: +# `set -uo pipefail` on execute, argv arrays, fail loud, no global state. + +_GITFLOW_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── branch model ───────────────────────────────────────────────────────────── +GITFLOW_MAIN="main" +GITFLOW_DEVELOP="develop" +# template resolved relative to the lib; overridable for tests. +GITFLOW_GITIGNORE_TEMPLATE="${GITFLOW_GITIGNORE_TEMPLATE:-$_GITFLOW_LIB_DIR/../templates/gitignore/standard.gitignore}" + +# ── predicates / pure helpers ──────────────────────────────────────────────── + +# echo the gitflow type of a branch: feature|bugfix|release|hotfix|main|develop|other +gitflow_branch_type() { + local br="${1:-$(git symbolic-ref --short -q HEAD 2>/dev/null)}" + case "$br" in + "$GITFLOW_MAIN") echo main ;; + "$GITFLOW_DEVELOP") echo develop ;; + feature/*) echo feature ;; + bugfix/*) echo bugfix ;; + release/*) echo release ;; + hotfix/*) echo hotfix ;; + *) echo other ;; + esac +} + +# THE shared predicate — rc 0 iff (given or current) branch is a protected base. +# Consumed by: start/finish (here), the assistance skills (aiguillage), and the +# pre-commit hook (mirrored, coherence-tested — see gitflow-test.sh T10). +gitflow_protected_base() { + local br="${1:-$(git symbolic-ref --short -q HEAD 2>/dev/null)}" + [ "$br" = "$GITFLOW_MAIN" ] || [ "$br" = "$GITFLOW_DEVELOP" ] +} + +# echo the base a given type must fork from. +gitflow_base_for() { + case "$1" in + feature|bugfix|release) echo "$GITFLOW_DEVELOP" ;; + hotfix) echo "$GITFLOW_MAIN" ;; + *) echo "gitflow: unknown type '$1'" >&2; return 2 ;; + esac +} + +# rc 0 iff at least one release/* branch exists (hotfix fan-out condition). +gitflow_release_open() { + [ -n "$(git for-each-ref --format='%(refname:short)' 'refs/heads/release/*')" ] +} + +# ── start ──────────────────────────────────────────────────────────────────── + +# gitflow_start → checkout -b / from the correct base. +gitflow_start() { + local type="${1:-}" name="${2:-}" base + base="$(gitflow_base_for "$type")" || return 2 + [ -n "$name" ] || { echo "gitflow_start: missing " >&2; return 2; } + git rev-parse --verify -q "$base" >/dev/null \ + || { echo "gitflow_start: base '$base' missing — run 'gitflow init' first" >&2; return 3; } + git checkout -q "$base" || return 1 + git pull --ff-only -q 2>/dev/null || true # best-effort sync; offline / no-upstream ok + git checkout -q -b "$type/$name" || return 1 + echo "$type/$name" +} + +# ── finish (directed merge + hotfix fan-out) ───────────────────────────────── + +_gitflow_merge_into() { # _gitflow_merge_into + local target="$1" source="$2" + git checkout -q "$target" || return 1 + git pull --ff-only -q 2>/dev/null || true + git merge --no-ff -q -m "Merge $source into $target" "$source" \ + || { echo "gitflow: conflict merging $source → $target — resolve, commit, re-run finish" >&2; return 4; } +} + +_gitflow_merge_into_open_releases() { # + local source="$1" rel + while IFS= read -r rel; do + [ -n "$rel" ] || continue + _gitflow_merge_into "$rel" "$source" || return 4 + done < <(git for-each-ref --format='%(refname:short)' 'refs/heads/release/*') +} + +_gitflow_delete() { # + local br="$1" + git checkout -q "$GITFLOW_DEVELOP" 2>/dev/null || git checkout -q "$GITFLOW_MAIN" + git branch -q -d "$br" || { echo "gitflow: '$br' not fully merged — branch kept" >&2; return 5; } +} + +# gitflow_finish → directed merge of the CURRENT branch per its type, then delete. +# WHEN to call this is the human gate (SKILL.md). This only performs the merge. +gitflow_finish() { + local br type + br="$(git symbolic-ref --short -q HEAD)" || { echo "gitflow_finish: detached HEAD" >&2; return 3; } + type="$(gitflow_branch_type "$br")" + case "$type" in + feature|bugfix) + _gitflow_merge_into "$GITFLOW_DEVELOP" "$br" && _gitflow_delete "$br" ;; + release) + _gitflow_merge_into "$GITFLOW_MAIN" "$br" \ + && _gitflow_merge_into "$GITFLOW_DEVELOP" "$br" \ + && _gitflow_delete "$br" ;; + hotfix) + _gitflow_merge_into "$GITFLOW_MAIN" "$br" \ + && _gitflow_merge_into "$GITFLOW_DEVELOP" "$br" \ + && { gitflow_release_open && _gitflow_merge_into_open_releases "$br" || true; } \ + && _gitflow_delete "$br" ;; + *) echo "gitflow_finish: '$br' is not a finishable gitflow branch" >&2; return 2 ;; + esac +} + +# ── init (resolves BLK-010) + reconcile + hook install ─────────────────────── + +_gitflow_init_fresh() { # unborn HEAD → deterministic root commit on main + local msg="${1:-chore: initial commit}" + git symbolic-ref HEAD "refs/heads/$GITFLOW_MAIN" # name the unborn branch 'main' + git add -A + git commit -q -m "$msg" \ + || { echo "gitflow_init: nothing staged for the root commit (scaffold first)" >&2; return 1; } + git branch "$GITFLOW_DEVELOP" +} + +_gitflow_init_existing() { # has commits → ensure main (rename master) + develop + if ! git rev-parse --verify -q "refs/heads/$GITFLOW_MAIN" >/dev/null; then + if git rev-parse --verify -q refs/heads/master >/dev/null; then + git branch -m master "$GITFLOW_MAIN" + else + echo "gitflow_init: no '$GITFLOW_MAIN' and no 'master' — refusing to guess the prod branch" >&2 + return 2 + fi + fi + git checkout -q "$GITFLOW_MAIN" || return 1 + # commit the socle + versioned hook now, while hooksPath is NOT yet active + # (activation is the last step of gitflow_init) → never self-blocked. + git add -- .gitignore .githooks 2>/dev/null || true + # socle commit failure is FATAL — abort BEFORE develop/hook-activation so a + # partial run can't activate the hook and self-block every re-run (was a bug: + # the `|| commit` form swallowed the failure, then init activated the hook). + if ! git diff --cached --quiet -- .gitignore .githooks 2>/dev/null; then + git commit -q -m "chore: adopt gitflow socle + pre-commit hook" \ + || { echo "gitflow_init: socle commit failed — aborting before hook activation (recoverable)" >&2; return 1; } + fi + git rev-parse --verify -q "refs/heads/$GITFLOW_DEVELOP" >/dev/null \ + || git branch "$GITFLOW_DEVELOP" "$GITFLOW_MAIN" +} + +# gitflow_init [msg] → idempotent. Order matters (full BLK-010 closure): +# reconcile .gitignore + write the versioned hook FIRST, so the fresh root +# commit / existing adoption commit EMBED them; activate the hook LAST so the +# bootstrap commits are never self-blocked by the hook they install. +gitflow_init() { + git rev-parse --git-dir >/dev/null 2>&1 || { echo "gitflow_init: not a git repo" >&2; return 1; } + # identity precheck — without it the root/socle commit fails mid-run (see fatal + # guard in _gitflow_init_existing). Fail loud up front instead of half-applying. + { [ -n "$(git config user.name)" ] && [ -n "$(git config user.email)" ]; } \ + || { echo "gitflow_init: git identity unset (user.name/user.email) — set it first" >&2; return 1; } + gitflow_reconcile_gitignore || return $? # socle into .gitignore BEFORE any commit + _gitflow_write_hook || return $? # write .githooks/pre-commit (inactive) + if ! git rev-parse --verify -q HEAD >/dev/null 2>&1; then + _gitflow_init_fresh "$@" || return $? # root commit embeds scaffold + socle + hook + else + _gitflow_init_existing || return $? # adoption commit (hook still inactive) + fi + gitflow_activate_hook || return $? # activate LAST +} + +# Additive reconcile: ensure every non-comment template line is present; append +# only what's missing under a managed marker. NEVER rewrites project-own rules. +gitflow_reconcile_gitignore() { + local tmpl="$GITFLOW_GITIGNORE_TEMPLATE" gi=".gitignore" line + local -a missing=() + [ -f "$tmpl" ] || { echo "gitflow: gitignore template missing: $tmpl" >&2; return 1; } + [ -e "$gi" ] || : > "$gi" + while IFS= read -r line; do + case "$line" in ''|\#*) continue ;; esac + grep -qxF -- "$line" "$gi" || missing+=("$line") + done < "$tmpl" + [ "${#missing[@]}" -gt 0 ] || return 0 # idempotent no-op + { + echo "" + echo "# ── gitflow standard socle (added by gitflow_init; additive, safe to edit) ──" + printf '%s\n' "${missing[@]}" + } >> "$gi" + echo "gitflow: appended ${#missing[@]} socle line(s) to $gi" >&2 +} + +# Emit the self-contained pre-commit hook. The protected-base test is INLINED +# (mirror of gitflow_protected_base) because the hook runs in arbitrary project +# repos with no access to this lib. Coherence guaranteed by gitflow-test.sh T10. +_gitflow_emit_pre_commit() { +cat </dev/null) + +git rev-parse --verify -q HEAD >/dev/null 2>&1 || exit 0 # root commit — allow +[ -f "\$gd/MERGE_HEAD" ] && exit 0 # merge in progress — allow + +case "\$br" in + $GITFLOW_MAIN|$GITFLOW_DEVELOP) ;; # protected — keep checking + *) exit 0 ;; # working branch — allow +esac + +# whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) — allow +if [ -z "\$(git diff --cached --name-only | grep -v '^\.claude/' | head -1)" ]; then + exit 0 +fi + +echo "gitflow pre-commit: BLOCKED — direct commit on '\$br'." >&2 +echo " Branch from the right base (feature/bugfix->develop, hotfix->main), or merge." >&2 +echo " (.claude/** memory commits are exempt; --no-verify bypasses locally.)" >&2 +exit 1 +HOOK +} + +# write the versioned hook file — does NOT activate (see gitflow_activate_hook). +_gitflow_write_hook() { + local hd=".githooks" + mkdir -p "$hd" + _gitflow_emit_pre_commit > "$hd/pre-commit" + chmod +x "$hd/pre-commit" +} + +# point git at the versioned hook dir. Run LAST in init so the bootstrap commits +# (socle / adoption / root) are never blocked by the hook they install. +gitflow_activate_hook() { + git config core.hooksPath .githooks +} + +# convenience: write + activate in one call (re-install / CLI 'install-hook'). +gitflow_install_hook() { + _gitflow_write_hook && gitflow_activate_hook +} + +# ── CLI dispatch (only when executed, not sourced) ─────────────────────────── +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + set -uo pipefail + cmd="${1:-}"; shift 2>/dev/null || true + case "$cmd" in + type) gitflow_branch_type "$@" ;; + protected-base) gitflow_protected_base "$@" ;; + base-for) gitflow_base_for "$@" ;; + release-open) gitflow_release_open ;; + start) gitflow_start "$@" ;; + finish) gitflow_finish "$@" ;; + init) gitflow_init "$@" ;; + reconcile) gitflow_reconcile_gitignore "$@" ;; + install-hook) gitflow_install_hook "$@" ;; + emit-hook) _gitflow_emit_pre_commit ;; + *) echo "usage: gitflow.sh {type|protected-base|base-for|release-open|start|finish|init|reconcile|install-hook|emit-hook}" >&2; exit 2 ;; + esac +fi diff --git a/skills/gitflow/SKILL.md b/skills/gitflow/SKILL.md new file mode 100644 index 0000000..cddc507 --- /dev/null +++ b/skills/gitflow/SKILL.md @@ -0,0 +1,81 @@ +--- +name: gitflow +description: Use when a project needs gitflow branch operations — bootstrapping main+develop, starting a typed branch (feature/bugfix/release/hotfix), or integrating finished work by directed merge — or when an orchestrator must branch or merge under the gitflow model. Use when about to merge any branch into develop or main. +--- + +# gitflow + +## Overview + +The one place gitflow branch logic lives. The mechanics — branch, merge, hotfix +fan-out, init, `.gitignore` reconcile, the protected-base predicate — are in +`~/.claude/lib/gitflow.sh` (tested, deterministic). This skill governs **when**, +and bulletproofs the single judgment call: **`finish` merges only on an explicit +human signal.** + +Replaces `finishing-a-development-branch` for gitflow flows — that skill is +single-target and cannot do the directed / fan-out merges below. + +## When to Use + +- An orchestrator (`ship-feature`) or an assistance skill (`feat`/`bugfix`/`hotfix`) must branch or merge. +- Bootstrapping a repo's branch model (`init-project`, `onboard`). +- You are about to integrate a finished branch into `develop` or `main`. + +## Branch model + +`main` (prod) · `develop` (integration, off main) · `feature/*` and `bugfix/*` +(off develop → develop) · `release/*` (off develop → main + back-merge develop) +· `hotfix/*` (off main → main + develop [+ any open release/*]). + +## Operations — all via the lib + +``` +bash ~/.claude/lib/gitflow.sh init [msg] # main+develop; root-commit (fresh) or ensure (existing); reconcile .gitignore; install hook +bash ~/.claude/lib/gitflow.sh start # branch from the correct base +bash ~/.claude/lib/gitflow.sh finish # directed merge of the CURRENT branch — HUMAN-GATED (below) +bash ~/.claude/lib/gitflow.sh protected-base [br] # rc 0 on main/develop — the shared predicate +``` + +`finish` merges by the current branch's type: + +| Current branch | Merges into | then | +|---|---|---| +| `feature/*` · `bugfix/*` | develop | delete | +| `release/*` | main + develop | delete | +| `hotfix/*` | main + develop + any open `release/*` | delete | + +## The finish gate — merge ONLY on an explicit human signal + +`finish` writes to shared branches (`develop`, `main`). Run it ONLY when the user +gives a **real-time, explicit go for THIS merge** — "merge it", "feature OK", +"finish it". Dev and testing happen out of git; finish never auto-fires. + +**Violating the letter of this gate violates its spirit.** + +| Rationalization | Reality | +|---|---| +| "Tests pass, so I'll merge." | Green = *ready to* merge, not *authorized*. Present "ready — merge?" and wait. | +| "The user said 'ship' / 'implement and ship'." | "Ship" ends at ready-to-merge **and ask**. The verb is not a merge signal. Pushing or opening a PR is still initiating integration — ask first. | +| "The plan's next step says 'merge into develop'." | A step written *before* the work cannot consent to integrating it. Stop at that step and ask. | +| "finish is the last pipeline step — I'll chain it." | The orchestrator STOPS at the finish gate and asks. Reaching it ≠ permission. | + +### Red flags — STOP, do not finish + +- About to run `gitflow finish` / `git merge` into develop or main, and the user has not, in THIS exchange, explicitly said to merge. +- The authorization you're leaning on is a plan step, a task description, or the word "ship" — not a live "merge it". +- "It's obviously done — surely they want it merged." + +All of these mean: present the merge as a question, then wait for the explicit go. + +## Aiguillage (assistance skills) + +On a protected base, assistance skills (`feat`/`bugfix`/`hotfix`) call +`start ` to branch first; on a working branch they commit in place. Same +`protected-base` predicate the out-of-skill hook uses. + +## Common Mistakes + +- Using `finishing-a-development-branch` for a gitflow merge → it can't do directed/fan-out merges. Use `gitflow finish`. +- Hand-writing `git merge` instead of `gitflow finish` → loses fan-out, branch delete, base sync. +- Calling `finish` because the work *looks* done → see the gate. diff --git a/skills/init-project/SKILL.md b/skills/init-project/SKILL.md index 3c66ddc..f626457 100644 --- a/skills/init-project/SKILL.md +++ b/skills/init-project/SKILL.md @@ -129,6 +129,18 @@ Rules: - React Native, Flutter, backend, embedded, static HTML → skipped. - If another animation lib (gsap, lottie-react, react-spring, …) is already present → skipped. +## STEP 5f — GITFLOW INIT +After every scaffold file exists (STEP 5–5e have run), establish the gitflow +layout and the deterministic root commit: +```bash +bash "$HOME/.claude/lib/gitflow.sh" init "chore: scaffold " +``` +Creates `main`+`develop`, root-commits the FULL scaffold (CLAUDE.md, README, +config, `.gitignore`, deps), reconciles the `.gitignore` socle, and installs the +versioned pre-commit hook — all embedded in the root commit, working tree clean. +This is the deterministic scaffold commit owner (closes BLK-010). The MVP is +implemented on a `feature/*` branch off `develop` (STEP 8). + ## STEP 6 — PLAN Invoke `superpowers:writing-plans` with BRIEF + skeleton. Granular tasks (2-5 min each), exact file paths, TDD: tests before code. @@ -144,7 +156,15 @@ Approve and start? (yes / request changes) Changes → back to STEP 6. Approved → continue. ## STEP 8 — IMPLEMENT -Invoke `superpowers:subagent-driven-development`. Isolated subagents, TDD, 2-stage review per task. +Start the MVP feature branch off develop, then implement on it: +```bash +bash "$HOME/.claude/lib/gitflow.sh" start feature mvp +``` +Invoke `superpowers:subagent-driven-development` for the per-task implement loop +**and** the final whole-branch review **only**. Do NOT run its terminal +`finishing-a-development-branch` step — this orchestrator owns integration via +`gitflow finish` (STEP 11). When SDD's flow reaches "Use +finishing-a-development-branch", stop and return. ## STEP 8b — GRAPHIFY FULL (after implementation) If `graphify` CLI is installed AND complexity >= 30%: @@ -214,7 +234,7 @@ nothing was capitalized, the helper no-ops — no commit. ## STEP 10c — DOC SYNC Run BEFORE STEP 11 FINISH (moved here from post-FINISH). doc-syncer PATCHES public docs but -does NOT commit them, and `finishing-a-development-branch` integrates only COMMITTED history +does NOT commit them, and `gitflow finish` integrates only COMMITTED history — so a patch left uncommitted never reaches the merge/PR. Same PR-stranding class as the STEP 10b capitalize fix (BDR-034). @@ -226,14 +246,17 @@ ONLY the files doc-syncer patched (its `PATCHED_FILES` output, one path per line arg each), never `git add -A`, never `.claude/`/`CLAUDE.md`, and no-ops if nothing was patched. Report per its rc table — rc 4 = a LOUD upstream BDR-022 anomaly, not a silent skip. -> **Partial fix (conscious).** This commits the docs doc-sync patched, so they reach the -> merge/PR. It does NOT fix the scaffold (STEP 5) + bootstrap-README (STEP 5b) commit gap -> (BLK-010: no deterministic commit owner; `git worktree add -b` on an unborn HEAD) — a -> separate chantier. After this, init-project's doc-sync is fixed but the scaffold/bootstrap -> commit gap stays open; GSD STEP 12 still creates ROADMAP.md post-FINISH (BLK-011) — also separate. +> **Scaffold commit owner = STEP 5f `gitflow init`** (root commit embeds scaffold + README + +> `.gitignore` socle + hook, tree clean — BLK-010 closed). This doc-sync commit lands the +> patched docs on the MVP feature branch so they reach the merge. GSD STEP 12 still creates +> ROADMAP.md post-FINISH (BLK-011) — separate. ## STEP 11 — FINISH -Invoke `superpowers:finishing-a-development-branch`. Tests pass, build clean, no placeholders, initial commit ready. +Tests pass, build clean, no placeholders. Integrate the MVP feature into develop +— **only on the user's explicit go** (the `gitflow` finish gate): +```bash +bash "$HOME/.claude/lib/gitflow.sh" finish # feature/mvp → develop +``` ## STEP 12 — GSD v2 INIT (optional) If `multi-session` signal was detected in STEP 0 OR the project has >3 planned milestones: diff --git a/skills/onboard/SKILL.md b/skills/onboard/SKILL.md index d4d6b25..c1e60c7 100644 --- a/skills/onboard/SKILL.md +++ b/skills/onboard/SKILL.md @@ -134,6 +134,26 @@ Cas : --- +## STEP 2.6 — GITFLOW INIT + +Adopter le modèle gitflow sur ce repo existant : +```bash +bash "$HOME/.claude/lib/gitflow.sh" init +``` +Sur un repo existant, cela : renomme `master`→`main` si besoin (LOCAL), crée +`develop` depuis main, réconcilie le socle `.gitignore` (additif — n'écrase +jamais les règles du projet), installe le hook pre-commit versionné, et fait UN +commit `chore: adopt gitflow socle + hook` sur main (pendant que le hook est +inactif → jamais auto-bloqué). Idempotent — un re-run est un no-op. + +**Annoncer le renommage master→main** s'il a lieu. Le renommage est LOCAL ; +repointer la branche par défaut du remote vers `main` + la protection de branche +sur `main`/`develop` est une étape de migration séparée (sous-chantier B) — pas +faite ici. Pré-condition : working tree raisonnablement propre (le commit +d'adoption ne stage que `.gitignore` + `.githooks`). + +--- + ## STEP 3 — DEEP INTERVIEW L'orchestrateur pilote directement l'interview (l'agent `interviewer.md` est laissé pour `/init-project` où le BRIEF format est attendu ; ici on reste en markdown libre dans la CLAUDE.md). diff --git a/skills/ship-feature/SKILL.md b/skills/ship-feature/SKILL.md index e83fa35..f6873c6 100644 --- a/skills/ship-feature/SKILL.md +++ b/skills/ship-feature/SKILL.md @@ -121,7 +121,15 @@ assert a check not performed). No RELATED MEMORY from 0d → omit the block. Changes → back to STEP 2. Approved → continue. ## STEP 4 — IMPLEMENT -Invoke `superpowers:subagent-driven-development`. Isolated subagents. 2-stage review per task: spec compliance → code quality. +Start the feature branch off develop, then implement on it: +```bash +bash "$HOME/.claude/lib/gitflow.sh" start feature +``` +Invoke `superpowers:subagent-driven-development` for the per-task implement loop +**and** the final whole-branch review **only**. Do NOT run its terminal +`finishing-a-development-branch` step — this orchestrator owns integration via +`gitflow finish` (STEP 9). When SDD's flow reaches "Use +finishing-a-development-branch", stop and return. ## STEP 4b — ERROR RECOVERY (if STEP 4 fails) If a subagent returns a build error, failing test, or type error: @@ -191,7 +199,7 @@ memory is integrated with the branch, not stranded outside the PR. ## STEP 8 — DOC SYNC Run BEFORE STEP 9 FINISH. doc-syncer PATCHES public docs but does NOT commit them, and -`finishing-a-development-branch` integrates only COMMITTED history — so a patch left +`gitflow finish` integrates only COMMITTED history — so a patch left uncommitted (or committed after) never reaches the merge/PR. Same PR-stranding class as the STEP 7 capitalize fix (BDR-034). @@ -206,7 +214,11 @@ surfaced a forbidden path), not a silent skip. It runs BEFORE FINISH so the doc on the branch FINISH integrates. ## STEP 9 — FINISH -Invoke `superpowers:finishing-a-development-branch`. Tests pass, build clean, ready to merge. +Tests pass, build clean. Integrate the feature into develop — **only on the +user's explicit go** (the `gitflow` finish gate): +```bash +bash "$HOME/.claude/lib/gitflow.sh" finish # feature/ → develop +``` --- diff --git a/templates/gitignore/standard.gitignore b/templates/gitignore/standard.gitignore new file mode 100644 index 0000000..53fc2c3 --- /dev/null +++ b/templates/gitignore/standard.gitignore @@ -0,0 +1,26 @@ +# ── env / secrets ── +.env +.env.* +!.env.example +# ── editors / IDE ── +.vscode/ +.idea/ +*.swp +# ── OS ── +.DS_Store +Thumbs.db +# ── logs / debug ── +*.log +# ── Claude config: local by default, project-memory versioned ── +.claude/* +!.claude/memory/ +!.claude/tasks/ +!.claude/audits/ +!.claude/settings.json +!.claude/deploy/ +# re-ignore transients inside the versioned subtrees (the PENDING.json trap) +.claude/settings.local.json +.claude/agent-memory/ +.claude/gstack/ +.claude/deploy/PENDING.json +.claude/deploy/NEXT.sh