diff --git a/.STATUS b/.STATUS index c0516e22..a8460565 100644 --- a/.STATUS +++ b/.STATUS @@ -4,7 +4,7 @@ release_date: 2026-02-06 milestone: v2.16.0 - Teaching Ecosystem + Branch Protection last_session: 2026-02-07 -progress: Teaching ecosystem merged, branch protection pending +progress: Teaching ecosystem merged, branch protection complete, ready for PR ๐Ÿ“Š Branch Status: @@ -12,7 +12,7 @@ progress: Teaching ecosystem merged, branch protection pending |--------|---------|------|---------------------------------| | main | bbd6ac7 | โ€” | Production (v2.15.0 released) | | dev | 0f94d9d | main | Teaching ecosystem merged (PR #57) | -| feature/branch-protection | 2087a98 | dev | ORCHESTRATE ready | +| feature/branch-protection | โ€” | dev | Implementation complete, PR ready | โœ… v2.15.0 โ€” Brainstorm v2.5.0: Spec Simplification + Smart Questions (2026-02-06): - Brainstorm spec simplification: 1,919 โ†’ 312 lines (84% reduction) @@ -38,16 +38,16 @@ progress: Teaching ecosystem merged, branch protection pending **feature/teaching-ecosystem**: โœ… MERGED (PR #57, 2026-02-07) - Config normalizer, break validation fix, 8 new tests, Teaching docs tab -**feature/branch-protection** (9 steps): -1. Hook script (~/.claude/hooks/branch-guard.sh) -2. Hook config (settings.json PreToolUse registration) -3. Dry-run test -4. Enable enforcement -5. Per-project config (.claude/branch-guard.json) -6. Craft command enhancements (check, do, worktree, status) -7. New commands (unprotect, protect) -8. Tests (17 hook + 4 integration) -9. Documentation +**feature/branch-protection** (9/9 complete โœ…): +1. โœ… Hook script (scripts/branch-guard.sh โ€” moved into repo) +2. โœ… Hook config (settings.json PreToolUse registration) +3. โœ… Dry-run test +4. โœ… Enable enforcement +5. โœ… Per-project config (.claude/branch-guard.json) +6. โœ… Craft command enhancements (check, do, worktree, status) +7. โœ… New commands (unprotect, protect) +8. โœ… Tests (49 unit + 31 e2e + 6 integration = 86 total) +9. โœ… Documentation + standalone installer + Homebrew formula ## Future (v2.17.0+) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6a6ea36b..37515ccd 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "craft", "version": "2.15.0", - "description": "Full-stack developer toolkit with integrated workflow automation - 106 commands (94 craft + 12 workflow), 8 agents, 21 skills with standardized dry-run preview mode (29 with --dry-run support). Code, git, site, docs, testing, architecture, CI, distribution, teaching workflows, planning, and ADHD-friendly features including brainstorming, task management, and spec capture", + "description": "Full-stack developer toolkit with integrated workflow automation - 108 commands (96 craft + 12 workflow), 8 agents, 21 skills with standardized dry-run preview mode (29 with --dry-run support). Code, git, site, docs, testing, architecture, CI, distribution, teaching workflows, planning, and ADHD-friendly features including brainstorming, task management, and spec capture", "author": { "name": "Data-Wise", "email": "dt@stat-wise.com" diff --git a/.gitignore b/.gitignore index fbf16f2e..a390e6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,9 @@ env/ .DS_Store Thumbs.db -# Test reports +# Test reports and logs tests/craft_test_report.md +tests/cli/logs/ .coverage # Brainstorm documents (working drafts) diff --git a/CLAUDE.md b/CLAUDE.md index 24a52fcf..ccafcd6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,10 @@ > **TL;DR**: Use `/craft:do ` for smart routing, `/craft:check` before commits, `/craft:git:worktree` for feature branches. **Always start work from `dev` branch** - never commit to `main` directly. -**106 commands** ยท **21 skills** ยท **8 agents** ยท **23 specs** ยท [Documentation](https://data-wise.github.io/craft/) ยท [GitHub](https://github.com/Data-Wise/craft) +**108 commands** ยท **21 skills** ยท **8 agents** ยท **23 specs** ยท [Documentation](https://data-wise.github.io/craft/) ยท [GitHub](https://github.com/Data-Wise/craft) **Current Version:** v2.15.0 | **Latest Release:** v2.15.0 (2026-02-06) -**Documentation Status:** 99% complete | **Tests:** 1294 passing (176 claude-md + 998 core + 74 formatting + 38 brainstorm-context + 8 teaching-normalization) +**Documentation Status:** 99% complete | **Tests:** 1380 passing (176 claude-md + 998 core + 74 formatting + 38 brainstorm-context + 8 teaching-normalization + 86 branch-guard) ## Git Workflow @@ -34,6 +34,17 @@ feature/* (worktrees) โ† All implementation work - **Never** write feature code on `dev` - **Always** verify branch: `git branch --show-current` +### Branch Protection (Enforced by Hook) + +| Branch | Code Files | .md Files | Git Operations | +|--------|-----------|-----------|----------------| +| `main` | BLOCKED | BLOCKED | Commit/push BLOCKED | +| `dev` | New: BLOCKED, Existing: allowed | ALLOWED | Commit/push allowed | +| `feature/*` | ALLOWED | ALLOWED | All allowed | + +Override: `/craft:git:unprotect` (session-scoped, auto-expires) +Config: `.claude/branch-guard.json` (per-project, optional) + ## Quick Commands | Task | Shell | Craft | @@ -91,7 +102,7 @@ Auto-selection: debug (errors), optimize (performance), release (deploy), else d ```text craft/ โ”œโ”€โ”€ .claude-plugin/ # Plugin manifest, hooks, validators -โ”œโ”€โ”€ commands/ # 106 commands (arch, ci, code, docs, git, site, test, workflow) +โ”œโ”€โ”€ commands/ # 108 commands (arch, ci, code, docs, git, site, test, workflow) โ”œโ”€โ”€ skills/ # 21 specialized skills โ”œโ”€โ”€ agents/ # 8 agents โ”œโ”€โ”€ scripts/ # 30+ utility scripts (dependency management, converters, installers) @@ -107,43 +118,25 @@ craft/ ## Recent Major Features -### v2.15.0 - Brainstorm v2.5.0: Spec Simplification + Smart Questions (2026-02-06) โœ… +### v2.16.0 - Branch Protection Hooks (2026-02-06) โœ… -**Part 1: Spec Simplification** โ€” brainstorm.md reduced from 1,919 โ†’ 312 lines (84% reduction). Extracted to: +**New Hook:** `~/.claude/hooks/branch-guard.sh` (~290 lines) โ€” PreToolUse hook enforcing branch protection (main=block-all, dev=block-new-code, feature=unrestricted). Per-project config via `.claude/branch-guard.json`. -- `docs/specs/SPEC-brainstorm-question-bank.md` โ€” Full question bank + project-type extensions -- `docs/tutorials/TUTORIAL-brainstorm-power-user.md` โ€” Detailed examples + advanced patterns -- `docs/reference/REFCARD-BRAINSTORM.md` โ€” Flowcharts + quick reference card +**New Commands:** `/craft:git:unprotect` + `/craft:git:protect` (session-scoped bypass) +**Enhanced:** `/craft:check` (branch context), `/craft:do` (branch-aware routing), `/craft:git:worktree` (main block), `/craft:git:status` (guard indicator) +**Tests:** 42 unit + 6 integration, all passing. **Files Changed:** 12 (+2,200 lines) -**Part 2: Context-Aware Smart Questions** โ€” New Step 1.7 context scan before presenting questions: - -- `utils/brainstorm_context.py` (~280 lines) โ€” Scans .STATUS, specs, git log, CLAUDE.md -- Project-type question extensions: 12 new questions (2 per type: R, Python, Node, Quarto, Plugin, Teaching) -- Dynamic questions: matching specs, prior brainstorms, failing tests -- Pre-fills answers from project state (version, current task) +--- -**Tests:** 38 new tests (test_brainstorm_context.py), all passing +### v2.15.0 - Brainstorm v2.5.0: Spec Simplification + Smart Questions (2026-02-06) โœ… -**Files Changed:** 8 (+1,500/-1,600) +Brainstorm.md reduced 1,919 โ†’ 312 lines (84% reduction). New `utils/brainstorm_context.py` (~280 lines) scans .STATUS, specs, git log for context-aware smart questions. 12 project-type extensions, dynamic questions from project state. **Tests:** 38 new. **Files Changed:** 8 (+1,500/-1,600) --- ### v2.14.0 - Unified Formatting Library (2026-02-05) โœ… -**Branch:** `feature/styled-output` - -**New Library:** `scripts/formatting.sh` (~180 lines) โ€” shared bash formatting library providing box-drawing (double/single line), `FMT_` prefixed color constants, ANSI-aware padding, table formatting, and source guard. All boxes standardized to 63 visible characters. - -**API:** `box_header`, `box_single`, `box_row`, `box_separator`, `box_footer`, `box_empty_row`, `box_table`, `fmt_set_width`, `fmt_divider`, `_fmt_strip_ansi`. - -**Migrations:** - -- 8 box-drawing scripts migrated (install.sh, migrate-from-workflow.sh, convert-cast.sh, health-check.sh, consent-prompt.sh, dependency-installer.sh, dependency-manager.sh) -- 15 color-only scripts migrated (validate-counts, pre-release-check, batch-convert, repair-tools, 3 installers, tool-detector, version-check, sync-version, verify-phase1/2, install-hooks, test-fix-flag, pre-commit-markdownlint) - -**Tests:** 74 new tests (28 unit + 30 integration + 16 edge cases) - -**Files Changed:** 24 (+1,100/-300) +`scripts/formatting.sh` (~180 lines) โ€” shared box-drawing, `FMT_` color constants, ANSI-aware padding. 23 scripts migrated (8 box-drawing + 15 color-only). All boxes standardized to 63 visible characters. **Tests:** 74 new. **Files Changed:** 24 (+1,100/-300) --- @@ -527,6 +520,12 @@ See `docs/specs/` for detailed specifications (23 total). See `docs/VERSION-HIST | `scripts/pre-release-check.sh` | Pre-release validation (version, counts, clean tree) | | `scripts/docs-lint-emoji.sh` | Standalone CRAFT-001 check for pre-commit hook | | `.prettierignore` | Prevents prettier from breaking emoji-attribute spacing | +| `.claude/branch-guard.json` | Per-project branch protection config (optional) | +| `commands/git/unprotect.md` | Session-scoped bypass for branch protection | +| `commands/git/protect.md` | Re-enable branch protection | +| `tests/test_branch_guard.sh` | Branch guard hook unit tests (49 tests) | +| `tests/test_branch_guard_e2e.sh` | Branch guard e2e tests (31 tests) | +| `tests/test_integration_branch_guard.py` | Branch guard integration tests (6 tests) | ## Test Suite @@ -543,6 +542,7 @@ See `docs/specs/` for detailed specifications (23 total). See `docs/VERSION-HIST | `tests/test_claude_md_v3.py` | 51 | 100% | v3 sync/optimizer (v2.12.0) | | `tests/test_claude_md_audit.py` | 11 | 100% | Audit module (v2.10.0) | | `tests/test_brainstorm_context.py` | 38 | 100% | Context scanner (v2.15.0) | +| `tests/test_branch_guard.sh` | 49 | 100% | Branch guard hook (v2.16.0) | | **Integration & E2E Tests** | | | | | `tests/test_command_enhancements_e2e.py` | 93 | 100% | Command enhancements (v2.9.0)| | `tests/test_integration_brainstorm_phase1.py` | 24 | 100% | Question control integration | @@ -550,10 +550,12 @@ See `docs/specs/` for detailed specifications (23 total). See `docs/VERSION-HIST | `tests/test_integration_orchestrator_workflows.py` | 13 | 100% | Task routing & scoring | | `tests/test_integration_claude_md_v3.py` | 9 | 100% | v3 sync/optimizer integ. | | `tests/test_integration_teaching_workflow.py` | 16 | 100% | Teaching mode + normalization (2 skipped) | +| `tests/test_integration_branch_guard.py` | 6 | 100% | Branch guard integration | +| `tests/test_branch_guard_e2e.sh` | 31 | 100% | Branch guard e2e (v2.16.0) | | **System Tests** | | | | | `tests/test_dependency_management.sh` | 79 | 100% | Dependency system | | `tests/test_formatting.sh` | 74 | 100% | Formatting library (v2.14.0) | -| **Total** | **1294** | **~90%** | **All systems** | +| **Total** | **1380** | **~90%** | **All systems** | ## Troubleshooting @@ -575,7 +577,7 @@ See `docs/specs/` for detailed specifications (23 total). See `docs/VERSION-HIST ## Links - [Documentation Site](https://data-wise.github.io/craft/) โ€” Full guides and references -- [Commands Reference](https://data-wise.github.io/craft/commands/) โ€” All 106 commands +- [Commands Reference](https://data-wise.github.io/craft/commands/) โ€” All 108 commands - [Architecture Guide](https://data-wise.github.io/craft/architecture/) โ€” How Craft works - [Specifications](docs/specs/) โ€” Implementation specs (23 total) - [Version History](docs/VERSION-HISTORY.md) โ€” Complete release timeline (NEW) diff --git a/README.md b/README.md index 0266c92c..7d5cb207 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Documentation](https://img.shields.io/badge/docs-99%25%20complete-brightgreen.svg)](https://data-wise.github.io/craft/) > **v2.15.0 - Brainstorm v2.5.0: Spec Simplification + Smart Questions** ๐Ÿš€ -> **106 commands** | **21 skills** | **8 agents** | **1294 tests passing** +> **108 commands** | **21 skills** | **8 agents** | **1380 tests passing** > Brainstorm spec reduced 84% (1,919 โ†’ 312 lines) with context-aware smart questions. A comprehensive production-ready toolkit for Claude Code featuring smart orchestration, ADHD-friendly workflows, multi-agent coordination, and complete documentation coverage. @@ -135,7 +135,7 @@ Craft is a pure plugin that uses built-in Claude Code capabilities. No external - [Quick Start](https://data-wise.github.io/craft/QUICK-START/) (30 seconds) - [ADHD Guide](https://data-wise.github.io/craft/ADHD-QUICK-START/) (neurodivergent-friendly) - [Visual Workflows](https://data-wise.github.io/craft/workflows/) (10 GIF demonstrations) -- [Command Reference](https://data-wise.github.io/craft/REFCARD/) (all 106 commands) +- [Command Reference](https://data-wise.github.io/craft/REFCARD/) (all 108 commands) - [Skills & Agents](https://data-wise.github.io/craft/guide/skills-agents/) (21 skills, 8 agents) - [Claude Code 2.1 Integration](https://data-wise.github.io/craft/guide/claude-code-2.1-integration/) (comprehensive guide with 9 diagrams) - [Complexity Scoring Algorithm](https://data-wise.github.io/craft/guide/complexity-scoring-algorithm/) (complete technical documentation with 8 diagrams) diff --git a/commands/check.md b/commands/check.md index f5b37d88..f590397f 100644 --- a/commands/check.md +++ b/commands/check.md @@ -53,6 +53,7 @@ Preview which checks will be performed without actually executing them: โ”‚ - Build tool: uv โ”‚ โ”‚ - Config: pyproject.toml โ”‚ โ”‚ - Worktree: No (main repo) โ”‚ +โ”‚ - Guard: Active (block-new-code) โ”‚ โ”‚ - Git status: Clean working tree โ”‚ โ”‚ โ”‚ โ”‚ โœ“ Validation Plan (5 checks): โ”‚ @@ -149,6 +150,7 @@ Pre-flight Check Plan: Project: () Mode: Branch: + Guard: Context: Checks to run: @@ -286,6 +288,7 @@ fi โ”‚ ๐ŸŒณ Worktree: ~/.git-worktrees/scribe/feat-hud โ”‚ โ”‚ Main: ~/projects/dev-tools/scribe โ”‚ โ”‚ Branch: feat/mission-control-hud โ”‚ +โ”‚ Guard: None (feature branches unrestricted) โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โœ“ Lint 0 issues โ”‚ โ”‚ ... โ”‚ diff --git a/commands/do.md b/commands/do.md index e8b91c25..0566e27a 100644 --- a/commands/do.md +++ b/commands/do.md @@ -107,12 +107,58 @@ Preview which commands will be executed without actually running them: **Note**: Dry-run shows routing decision based on complexity score. Agent delegation triggers for medium (4-7) and complex (8-10) tasks. +## Branch-Aware Routing (NEW in v2.16.0) + +Before routing, check branch protection status. When on `dev` or `main` and the task involves code changes: + +### On `dev` (block-new-code) + +If task involves creating new files or writing code: + +```json +{ + "questions": [{ + "question": "You're on dev (protected). How should I handle this code task?", + "header": "Branch", + "multiSelect": false, + "options": [ + { + "label": "Create worktree (Recommended)", + "description": "Auto-create feature branch and worktree from task description." + }, + { + "label": "Write spec only", + "description": "Create a spec file on dev (allowed) without code changes." + }, + { + "label": "Analyze only", + "description": "Read and analyze code without making edits." + } + ] + }] +} +``` + +### On `main` (block-all) + +``` +Cannot route code tasks on main. All changes go through PRs. + +Switch to dev: git checkout dev +Then retry: /craft:do +``` + +### On `feature/*` + +Route directly without branch intervention โ€” no restrictions. + ## How It Works -1. **Check Spec** - Look for existing spec matching task (NEW in v1.1.0) -2. **Analyze** - Parse task description for intent and category -3. **Score Complexity** - Calculate 0-10 score based on 5 factors (NEW in v1.23.0) -4. **Route Decision** - Choose execution strategy: +1. **Check Branch** - Verify branch protection status (NEW in v2.16.0) +2. **Check Spec** - Look for existing spec matching task (NEW in v1.1.0) +3. **Analyze** - Parse task description for intent and category +4. **Score Complexity** - Calculate 0-10 score based on 5 factors (NEW in v1.23.0) +5. **Route Decision** - Choose execution strategy: - **Score 0-3**: Route to craft commands (traditional) - **Score 4-7**: Delegate to specialized agent (NEW) - **Score 8-10**: Delegate to orchestrator-v2 (NEW) diff --git a/commands/docs/claude-md/init.md b/commands/docs/claude-md/init.md index 1d65bba8..f9c4e370 100644 --- a/commands/docs/claude-md/init.md +++ b/commands/docs/claude-md/init.md @@ -69,7 +69,7 @@ Analyzing project structure... Detected indicators: โœ“ .claude-plugin/plugin.json exists - โœ“ commands/ directory (106 commands) + โœ“ commands/ directory (108 commands) โœ“ skills/ directory (21 skills) โœ“ agents/ directory (8 agents) @@ -114,8 +114,8 @@ Generated CLAUDE.md Preview > **TL;DR**: Development workflow orchestration plugin -**106 commands** ยท **21 skills** ยท **8 agents** -**Version:** v2.15.0 | **Tests:** 1294 passing +**108 commands** ยท **21 skills** ยท **8 agents** +**Version:** v2.15.0 | **Tests:** 1380 passing ## Git Workflow [...] diff --git a/commands/git/protect.md b/commands/git/protect.md new file mode 100644 index 00000000..2dc0d9bf --- /dev/null +++ b/commands/git/protect.md @@ -0,0 +1,106 @@ +--- +description: Re-enable branch protection after bypass +category: git +tags: [git, branch-protection] +version: 1.0.0 +--- + +# /craft:git:protect - Re-Enable Branch Protection + +Remove the bypass marker and restore branch protection enforcement. + +## Usage + +```bash +/craft:git:protect +``` + +## Execution Behavior (MANDATORY) + +### Step 1: Check Hook and Current Status + +```bash +# Verify hook is installed +if [[ ! -f "$HOME/.claude/hooks/branch-guard.sh" ]]; then + echo "Branch guard hook is not installed." + echo "Install with: bash scripts/install-branch-guard.sh" + exit 0 +fi + +# Check if bypass is active +if [[ ! -f ".claude/allow-dev-edit" ]]; then + # Protection is already active โ€” detect level +fi +``` + +If no bypass is active, detect the actual protection level: + +```bash +BRANCH=$(git branch --show-current) +CONFIG=".claude/branch-guard.json" + +if [[ -f "$CONFIG" ]]; then + LEVEL=$(jq -r ".\"${BRANCH}\" // empty" "$CONFIG" 2>/dev/null) +else + # Auto-detect: main/master = block-all, dev = block-new-code (if dev exists) + case "$BRANCH" in + main|master) LEVEL="block-all" ;; + dev|develop) LEVEL="block-new-code" ;; + *) LEVEL="" ;; + esac +fi +``` + +Output (level detected dynamically): + +``` +Branch protection is already active. Nothing to do. + +Current branch: dev +Protection level: block-new-code (new code files blocked, fixups allowed) +``` + +### Step 2: Remove Bypass Marker + +```bash +rm -f .claude/allow-dev-edit +``` + +### Step 3: Confirm + +Detect current protection level (same logic as Step 1), then show: + +``` +Branch protection RE-ENABLED. + +Branch: +Protection: +``` + +For `block-new-code`: + +``` + - New code files: BLOCKED + - Existing file edits: allowed + - Markdown files: allowed + - Test files: allowed +``` + +For `block-all`: + +``` + - All edits: BLOCKED + - All writes: BLOCKED + - Git commit/push: BLOCKED +``` + +## Key Behaviors + +1. **Idempotent** - safe to run multiple times +2. **Informative** - shows current protection level after re-enabling +3. **Immediate** - takes effect on the next Edit/Write/Bash tool call + +## See Also + +- `/craft:git:unprotect` - Bypass branch protection +- `/craft:git:status` - Shows protection indicator diff --git a/commands/git/status.md b/commands/git/status.md index 6fa40355..631fbd21 100644 --- a/commands/git/status.md +++ b/commands/git/status.md @@ -65,6 +65,7 @@ When not in teaching mode, shows clean git status: โ”‚ ๐ŸŒฟ GIT STATUS โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Branch: feature/new-feature โ”‚ +โ”‚ Guard: None (feature branches unrestricted) โ”‚ โ”‚ Status: 2 commits ahead of origin โ”‚ โ”‚ โ”‚ โ”‚ Changes: โ”‚ @@ -348,6 +349,20 @@ else echo "${BOX_MID}" echo "โ”‚ Branch: ${CURRENT_BRANCH} โ”‚" + # Show branch protection status (NEW in v2.16.0) + PROJECT_ROOT=$(git rev-parse --show-toplevel) + if [[ -f "$PROJECT_ROOT/.claude/allow-dev-edit" ]]; then + REASON=$(jq -r '.reason // "unknown"' "$PROJECT_ROOT/.claude/allow-dev-edit" 2>/dev/null || echo "unknown") + printf "โ”‚ Guard: BYPASSED (reason: %-22s โ”‚\n" "${REASON})" + elif [[ -f "$PROJECT_ROOT/.claude/branch-guard.json" ]]; then + LEVEL=$(jq -r ".\"${CURRENT_BRANCH}\" // empty" "$PROJECT_ROOT/.claude/branch-guard.json" 2>/dev/null) + if [[ "$LEVEL" == "block-all" ]]; then + echo "โ”‚ Guard: Active (all edits blocked) โ”‚" + elif [[ "$LEVEL" == "block-new-code" ]]; then + echo "โ”‚ Guard: Active (new code blocked, fixups OK) โ”‚" + fi + fi + # Show changes CHANGES=$(git status --short) if [ -n "${CHANGES}" ]; then diff --git a/commands/git/unprotect.md b/commands/git/unprotect.md new file mode 100644 index 00000000..96ed79f2 --- /dev/null +++ b/commands/git/unprotect.md @@ -0,0 +1,118 @@ +--- +description: Session-scoped bypass for branch protection with reason logging +category: git +arguments: + - name: reason + description: Reason for bypassing protection (merge-conflict|ci-fix|maintenance) + required: false +tags: [git, branch-protection, bypass] +version: 1.0.0 +--- + +# /craft:git:unprotect - Bypass Branch Protection + +Temporarily disable branch protection. The bypass persists until re-enabled with `/craft:git:protect`. + +## Usage + +```bash +# Interactive (asks for reason) +/craft:git:unprotect + +# With reason +/craft:git:unprotect merge-conflict +/craft:git:unprotect ci-fix +/craft:git:unprotect maintenance +``` + +## Execution Behavior (MANDATORY) + +### Step 1: Check Current Protection Status + +```bash +# Get current branch +git branch --show-current + +# Check if already bypassed +if [[ -f ".claude/allow-dev-edit" ]]; then + # Show current bypass info and exit +fi +``` + +If already bypassed, show status and exit: + +``` +Branch protection is already bypassed. + +Reason: merge conflict resolution +Since: 2026-02-06T18:30:00Z + +To re-enable: /craft:git:protect +``` + +### Step 2: Ask for Reason (if not provided) + +If no reason argument was given: + +```json +{ + "questions": [{ + "question": "Why do you need to bypass branch protection?", + "header": "Reason", + "multiSelect": false, + "options": [ + { + "label": "Merge conflict resolution", + "description": "Need to edit code files to resolve merge conflicts on dev." + }, + { + "label": "CI fix", + "description": "Need to fix CI/CD configuration or test files directly." + }, + { + "label": "Maintenance", + "description": "General maintenance task that requires direct edits." + } + ] + }] +} +``` + +### Step 3: Create Bypass Marker + +```bash +mkdir -p .claude + +cat > .claude/allow-dev-edit << 'MARKER' +{ + "reason": "", + "timestamp": "", + "branch": "" +} +MARKER +``` + +### Step 4: Confirm + +``` +Branch protection BYPASSED. + +Branch: dev +Reason: merge conflict resolution +Scope: Until re-enabled via /craft:git:protect + +To re-enable manually: /craft:git:protect +``` + +## Key Behaviors + +1. **Marker-based** - bypass marker (`.claude/allow-dev-edit`) is checked by branch-guard.sh hook +2. **Reason-logged** - always records why protection was bypassed +3. **Persists until re-enabled** - run `/craft:git:protect` to remove the marker +4. **Idempotent** - running twice shows current status, doesn't create duplicate + +## See Also + +- `/craft:git:protect` - Re-enable branch protection +- `/craft:git:status` - Shows protection indicator +- `/craft:check` - Shows branch context section diff --git a/commands/git/worktree.md b/commands/git/worktree.md index ebb10899..772ce858 100644 --- a/commands/git/worktree.md +++ b/commands/git/worktree.md @@ -155,7 +155,22 @@ echo "Next: /craft:git:worktree create " ### create - Create New Worktree -Creates a worktree for an existing or new branch: +Creates a worktree for an existing or new branch. + +**Branch Guard (NEW in v2.16.0):** Worktrees must be created from `dev`, never from `main`. If on `main`: + +``` +Cannot create worktree from main. +Worktrees must branch from dev. + +Switch to dev first: + git checkout dev + +Then retry: + /craft:git:worktree create feature/your-feature +``` + +No options to override. Hard block. ```bash /craft:git:worktree create feature/new-ui @@ -165,6 +180,15 @@ Creates a worktree for an existing or new branch: **What it does:** ```bash +# Step 0: Branch guard check (belt-and-suspenders with PreToolUse hook) +# The branch-guard.sh hook blocks edits on main, but this command-level +# check gives a clearer error message specific to worktree creation. +current_branch=$(git branch --show-current) +if [[ "$current_branch" == "main" ]]; then + echo "Cannot create worktree from main. Switch to dev first." + exit 1 +fi + project=$(basename $(git rev-parse --show-toplevel)) branch=$1 folder_name=$(echo $branch | tr '/' '-') # feature/new-ui โ†’ feature-new-ui diff --git a/docs/ADHD-QUICK-START.md b/docs/ADHD-QUICK-START.md index 11f153c4..535432cd 100644 --- a/docs/ADHD-QUICK-START.md +++ b/docs/ADHD-QUICK-START.md @@ -19,7 +19,7 @@ ```bash claude plugin install craft@local-plugins # Install the plugin -/craft:hub # Verify 106 commands are available +/craft:hub # Verify 108 commands are available ``` **Expected:** You'll see a categorized list of all craft commands. @@ -66,7 +66,7 @@ claude plugin install craft@local-plugins # Install the plugin | Question | Answer | |----------|--------| -| Where are my commands? | Run `/craft:hub` to see all 106 commands | +| Where are my commands? | Run `/craft:hub` to see all 108 commands | | How do I automate docs? | Use `/craft:docs:update` for smart full cycle | | Can I create a website? | Yes! `/craft:site:create` with 8 ADHD-friendly presets | | What's the universal command? | `/craft:do "task"` - AI routes automatically | diff --git a/docs/PLAYGROUND.md b/docs/PLAYGROUND.md index 0541e151..adc96066 100644 --- a/docs/PLAYGROUND.md +++ b/docs/PLAYGROUND.md @@ -33,7 +33,7 @@ Try these commands yourself and see the magic happen! โ”‚ /craft:hub - Command Discovery โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ -โ”‚ 106 commands available across 9 categories โ”‚ +โ”‚ 108 commands available across 9 categories โ”‚ โ”‚ โ”‚ โ”‚ ๐ŸŽฏ SMART (4 commands) โ”‚ โ”‚ /craft:do - Universal task router โ”‚ @@ -407,7 +407,7 @@ You've seen individual commands in action. Now learn how they work together: - :books:{ .lg .middle } **[Commands Overview](commands/overview.md)** - Explore all 106 commands organized by category + Explore all 108 commands organized by category - :rocket:{ .lg .middle } **[Quick Reference](REFCARD.md)** diff --git a/docs/QUICK-START.md b/docs/QUICK-START.md index 847381f0..f5836c6f 100644 --- a/docs/QUICK-START.md +++ b/docs/QUICK-START.md @@ -10,7 +10,7 @@ > - **How:** Clone to `~/.claude/plugins/craft` then run `/craft:hub` > - **Next:** Try `/craft:do "your first task"` to see AI routing in action -**106 commands** ยท **21 skills** ยท **8 agents** ยท [Documentation](https://data-wise.github.io/craft/) +**108 commands** ยท **21 skills** ยท **8 agents** ยท [Documentation](https://data-wise.github.io/craft/) Get craft running in 30 seconds. @@ -49,7 +49,7 @@ Expected output: โ”‚ /craft:hub - Command Discovery โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ -โ”‚ 106 commands available across 10 categories โ”‚ +โ”‚ 108 commands available across 10 categories โ”‚ โ”‚ โ”‚ โ”‚ [Command listing...] โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ @@ -108,7 +108,7 @@ The AI routes your task to the best workflow automatically. !!! abstract "Progress: Step 4 of 4 - You're Done! ๐ŸŽ‰" Choose your learning path -- **[Commands Overview](commands/overview.md)** - Explore all 106 commands +- **[Commands Overview](commands/overview.md)** - Explore all 108 commands - **[Skills & Agents](guide/skills-agents.md)** - Understand the AI system - **[Orchestrator](guide/orchestrator.md)** - Advanced mode-aware execution - **[Quick Reference](REFCARD.md)** - Command cheat sheet diff --git a/docs/VERSION-HISTORY.md b/docs/VERSION-HISTORY.md index 18d58252..6830e92e 100644 --- a/docs/VERSION-HISTORY.md +++ b/docs/VERSION-HISTORY.md @@ -4,7 +4,7 @@ **Latest Release:** v2.15.0 (2026-02-06) **Total Releases:** 37 versions | **Development Time:** 2+ years -**Community:** 106 commands documented, 1294 tests passing, 90%+ coverage +**Community:** 108 commands documented, 1380 tests passing, 90%+ coverage --- diff --git a/docs/commands.md b/docs/commands.md index 4189be78..89dd7ee6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ Complete reference for all 97 Craft commands organized by category. Craft provid ## Quick Reference **Smart Commands:** `/craft:do`, `/craft:check`, `/craft:help`, `/craft:hub` -**Dry-Run Support:** 27 of 106 commands support `--dry-run` / `-n` preview mode +**Dry-Run Support:** 27 of 108 commands support `--dry-run` / `-n` preview mode **16 Categories:** arch, check, ci, code, dist, do, docs, git, hub, orchestrate, plan, site, smart-help, test, utils, workflow Use `/craft:hub` to discover all available commands interactively. @@ -246,7 +246,7 @@ All applicable commands support 4 execution modes: ## Dry-Run Commands -27 of 106 commands support `--dry-run` / `-n` preview mode. **Target exceeded:** 57% of target commands vs 52% goal. +27 of 108 commands support `--dry-run` / `-n` preview mode. **Target exceeded:** 57% of target commands vs 52% goal. ### Git Commands (6/6) โ€” 100% โœ… diff --git a/docs/commands/arch.md b/docs/commands/arch.md index a3b9fb02..8b9bfa5b 100644 --- a/docs/commands/arch.md +++ b/docs/commands/arch.md @@ -63,7 +63,7 @@ The command examines: ``` โ•ญโ”€ Architecture Overview โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ Project: craft | Type: Claude Code Plugin โ”‚ -โ”‚ Structure: commands/ agents/ skills/ (106 commands) โ”‚ +โ”‚ Structure: commands/ agents/ skills/ (108 commands) โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Commands: 9 categories (docs, site, code, etc.) โ”‚ โ”‚ Agents: 8 (orchestrator-v2, docs-architect, etc.) โ”‚ diff --git a/docs/commands/hub.md b/docs/commands/hub.md index f437c646..324a84bc 100644 --- a/docs/commands/hub.md +++ b/docs/commands/hub.md @@ -62,7 +62,7 @@ Hub v2.0 introduces **intelligent auto-detection** and **3-layer progressive dis - **Dynamic discovery** - Scans `commands/` directory automatically - **Always accurate** - No hardcoded counts that drift out of sync - **Fast caching** - JSON cache with auto-invalidation (<2ms cached, 12ms uncached) -- **106 commands** detected across 17 categories (v2.4.0) +- **108 commands** detected across 17 categories (v2.4.0) ### 3-Layer Navigation @@ -74,7 +74,7 @@ Hub v2.0 introduces **intelligent auto-detection** and **3-layer progressive dis - **Progressive disclosure** - Start broad, drill down as needed - **Visual hierarchy** - Clear sections, icons, and formatting -- **No overwhelm** - Never show all 106 commands at once +- **No overwhelm** - Never show all 108 commands at once - **Smart breadcrumbs** - Always know where you are --- diff --git a/docs/commands/overview.md b/docs/commands/overview.md index 6db764a1..2fffe18d 100644 --- a/docs/commands/overview.md +++ b/docs/commands/overview.md @@ -2,12 +2,12 @@ > **TL;DR** (30 seconds) > -> - **What:** 106 commands organized into 10 categories (Smart, Docs, Site, Code, Testing, Git, CI, Architecture, Distribution, Planning, Workflow) +> - **What:** 108 commands organized into 10 categories (Smart, Docs, Site, Code, Testing, Git, CI, Architecture, Distribution, Planning, Workflow) > - **Why:** One plugin handles your entire development workflow from docs to deployment > - **How:** Use `/craft:hub` to discover all commands by category > - **Next:** Start with `/craft:do` for AI-powered task routing or `/craft:check` for pre-flight validation -Craft provides **106 commands** across 10 categories for full-stack development workflows. +Craft provides **108 commands** across 10 categories for full-stack development workflows. ## Command Categories diff --git a/docs/cookbook/common/find-the-right-command.md b/docs/cookbook/common/find-the-right-command.md index a0fabbe1..c4151e0b 100644 --- a/docs/cookbook/common/find-the-right-command.md +++ b/docs/cookbook/common/find-the-right-command.md @@ -17,7 +17,7 @@ related: ## Problem -I want to find the right Craft command for a task without memorizing all 106 commands. +I want to find the right Craft command for a task without memorizing all 108 commands. ## Solution @@ -45,7 +45,7 @@ I want to find the right Craft command for a task without memorizing all 106 com workflow Workflow tools (5 commands) ``` - Why: Progressive disclosure keeps the list manageable instead of showing all 106 commands at once + Why: Progressive disclosure keeps the list manageable instead of showing all 108 commands at once 3. **Drill into a category** diff --git a/docs/cookbook/troubleshooting/claude-md-out-of-sync.md b/docs/cookbook/troubleshooting/claude-md-out-of-sync.md index 677575b5..2299edca 100644 --- a/docs/cookbook/troubleshooting/claude-md-out-of-sync.md +++ b/docs/cookbook/troubleshooting/claude-md-out-of-sync.md @@ -23,7 +23,7 @@ Your `CLAUDE.md` contains outdated information -- wrong command counts, missing **Current Version:** v2.8.0 ``` -When the project is actually at v2.12.0 with 106 commands, 21 skills, and 8 agents. +When the project is actually at v2.12.0 with 108 commands, 21 skills, and 8 agents. ## Common Causes & Solutions diff --git a/docs/guide/check-command-mastery.md b/docs/guide/check-command-mastery.md index a45d97bb..681105f1 100644 --- a/docs/guide/check-command-mastery.md +++ b/docs/guide/check-command-mastery.md @@ -234,7 +234,7 @@ Pre-flight Check Plan: โœ“ No known CVEs in dependencies [7/12] Documentation validation... - โœ“ All command docs present (106 commands) + โœ“ All command docs present (108 commands) โœ“ All help files valid YAML โœ“ mkdocs builds without warnings diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 8c46906c..a2877cbb 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -4,7 +4,7 @@ > **TL;DR** (30 seconds) > -> - **What:** Complete guide to installing and using craft's 106 commands, 21 skills, and 8 agents +> - **What:** Complete guide to installing and using craft's 108 commands, 21 skills, and 8 agents > - **Why:** Master the full-stack toolkit to automate your entire development workflow > - **How:** Install plugin โ†’ verify with `/craft:hub` โ†’ start with `/craft:do "task"` > - **Next:** Read about [Skills & Agents](skills-agents.md) to understand AI automation @@ -46,7 +46,7 @@ ln -s ~/projects/dev-tools/craft ~/.claude/plugins/craft /craft:hub ``` -You should see all 106 commands listed. +You should see all 108 commands listed. ## Initialize a New Project (Optional) diff --git a/docs/guide/homebrew-installation.md b/docs/guide/homebrew-installation.md index eb22cc6d..f50dbf5d 100644 --- a/docs/guide/homebrew-installation.md +++ b/docs/guide/homebrew-installation.md @@ -43,7 +43,7 @@ brew info craft /craft:hub ``` -You should see all 106 commands listed. +You should see all 108 commands listed. --- @@ -288,7 +288,7 @@ The same pattern applies to other Data-Wise plugins. All use Claude detection to | Plugin | Install Command | Commands | |--------|-----------------|----------| -| **craft** | `brew install data-wise/tap/craft` | 106 commands | +| **craft** | `brew install data-wise/tap/craft` | 108 commands | | **rforge** | `brew install data-wise/tap/rforge` | 15 commands | | **scholar** | `brew install data-wise/tap/scholar` | 21 commands | diff --git a/docs/index.md b/docs/index.md index c76e14f6..afabed23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,12 +6,12 @@ > **TL;DR** (30 seconds) > -> - **What:** Full-stack developer toolkit with 106 commands, 8 AI agents, and 21 auto-triggered skills +> - **What:** Full-stack developer toolkit with 108 commands, 8 AI agents, and 21 auto-triggered skills > - **Why:** Automate documentation, testing, git workflows, CLAUDE.md management, and site creation with one command > - **How:** Install via `claude plugin install craft@local-plugins` > - **Next:** Run `/craft:do "your task"` and let AI route to the best workflow -> Full-stack developer toolkit for Claude Code โ€” 106 commands, 8 agents, 21 skills with smart orchestration and ADHD-friendly workflows +> Full-stack developer toolkit for Claude Code โ€” 108 commands, 8 agents, 21 skills with smart orchestration and ADHD-friendly workflows ## Features @@ -62,7 +62,7 @@ ln -s ~/projects/dev-tools/claude-plugins/craft ~/.claude/plugins/craft The universal `/craft:do` command routes your task to the best workflow automatically. !!! success "Quick Win: Try It Now" - Run `/craft:hub` to see all 106 commands organized by category - takes 5 seconds and shows everything craft can do. + Run `/craft:hub` to see all 108 commands organized by category - takes 5 seconds and shows everything craft can do. ## Feature Highlights @@ -102,7 +102,7 @@ Complete OpenAPI-style documentation for all 106 Craft commands: - :scroll:{ .lg .middle } **[API Reference - Commands](API-REFERENCE-COMMANDS.md)** - Complete documentation for all 106 commands organized by category with parameters, usage examples, and output formats + Complete documentation for all 108 commands organized by category with parameters, usage examples, and output formats - :gear:{ .lg .middle } **[Command Parameters](reference/COMMAND-PARAMETERS.md)** @@ -173,7 +173,7 @@ Complete OpenAPI-style documentation for all 106 Craft commands: - :books:{ .lg .middle } **[Commands Overview](commands/overview.md)** - All 106 commands organized + All 108 commands organized - :sparkles:{ .lg .middle } **[Skills & Agents](guide/skills-agents.md)** @@ -185,7 +185,7 @@ Complete OpenAPI-style documentation for all 106 Craft commands: - :scroll:{ .lg .middle } **[API Reference](API-REFERENCE-COMMANDS.md)** - Complete documentation for all 106 commands + Complete documentation for all 108 commands diff --git a/docs/tutorials/TUTORIAL-first-10-minutes.md b/docs/tutorials/TUTORIAL-first-10-minutes.md index f653f3dc..2ae2ed9d 100644 --- a/docs/tutorials/TUTORIAL-first-10-minutes.md +++ b/docs/tutorials/TUTORIAL-first-10-minutes.md @@ -54,7 +54,7 @@ Open any project directory in Claude Code. Craft auto-detects your project type โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` -**Why it matters:** The hub is your home base. If you see the listing with 106 commands, Craft is working. The project type and branch are auto-detected. +**Why it matters:** The hub is your home base. If you see the listing with 108 commands, Craft is working. The project type and branch are auto-detected. ## Step 2: Run Your First Health Check @@ -124,7 +124,7 @@ CODE & TESTING (22 commands) **Layer 3** -- Pick a specific command for full details, usage examples, and related guides. -**Why it matters:** With 106 commands, you never need to memorize anything. Browse by category and the hub shows you how to use each command. +**Why it matters:** With 108 commands, you never need to memorize anything. Browse by category and the hub shows you how to use each command. ## Step 5: Try a Real Command diff --git a/install.sh b/install.sh index a0ce6109..3a49ba12 100755 --- a/install.sh +++ b/install.sh @@ -80,7 +80,7 @@ if [ -f "${PLUGIN_DIR}/.claude-plugin/plugin.json" ]; then box_row " โ€ข Help: /craft:help" box_row " โ€ข Hub: /craft:hub" box_empty_row - box_row " 106 commands | 8 agents | 21 skills" + box_row " 108 commands | 8 agents | 21 skills" box_empty_row box_footer echo "" diff --git a/mkdocs.yml b/mkdocs.yml index 2ff49305..a3585ac9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Craft Plugin -site_description: Full-stack developer toolkit - 106 commands, 8 agents, 21 skills for code, git, site, docs, testing, architecture, CI, distribution, teaching workflows, and planning with ADHD-friendly interfaces. Teaching ecosystem coordination with flow-cli config normalization and dedicated teaching documentation tab. +site_description: Full-stack developer toolkit - 108 commands, 8 agents, 21 skills for code, git, site, docs, testing, architecture, CI, distribution, teaching workflows, and planning with ADHD-friendly interfaces. v2.15.0 adds brainstorm spec simplification (84% reduction) and context-aware smart questions. site_url: https://data-wise.github.io/craft/ repo_name: Data-Wise/craft diff --git a/package.json b/package.json index 8d36d959..36f56551 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@data-wise/claude-craft-plugin", "version": "2.9.0", - "description": "Full-stack developer toolkit - 106 commands, 8 agents, 21 skills for code, git, site, docs, testing, architecture, CI, distribution, teaching workflows, and planning with ADHD-friendly interfaces", + "description": "Full-stack developer toolkit - 108 commands, 8 agents, 21 skills for code, git, site, docs, testing, architecture, CI, distribution, teaching workflows, and planning with ADHD-friendly interfaces", "main": "index.js", "scripts": { "test": "python3 tests/test_craft_plugin.py", diff --git a/scripts/branch-guard.sh b/scripts/branch-guard.sh new file mode 100755 index 00000000..c852dcf3 --- /dev/null +++ b/scripts/branch-guard.sh @@ -0,0 +1,374 @@ +#!/bin/bash +set -euo pipefail + +# branch-guard.sh โ€” Claude Code PreToolUse hook +# Enforces branch protection rules for main and dev branches. +# Reads JSON from stdin: { tool_name, tool_input: { file_path, command, ... }, cwd } +# Exits 0 = allow, 2 = block (message on stderr) +# Requires: jq (preferred), python3 (fallback), or grep/sed (last resort) + +# --------------------------------------------------------------------------- +# 1. Read stdin (JSON blob) +# --------------------------------------------------------------------------- +INPUT="$(cat)" + +# --------------------------------------------------------------------------- +# 2. Extract fields from JSON using jq (Python fallback) +# --------------------------------------------------------------------------- +_json_get() { + # Extract a string value from JSON. Uses jq, falls back to Python. + # Usage: _json_get '.tool_name' "$json" + local query="$1" json="$2" + if command -v jq &>/dev/null; then + printf '%s' "$json" | jq -r "$query // empty" 2>/dev/null || true + elif command -v python3 &>/dev/null; then + printf '%s' "$json" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + keys = '${query}'.lstrip('.').split('.') + v = d + for k in keys: + v = v.get(k) if isinstance(v, dict) else None + if v is None: break + if v is not None: print(v, end='') +except: pass +" 2>/dev/null || true + else + # Last resort: grep/sed (extracts by the final key name in the path) + local key="${query##*.}" + printf '%s' "$json" | grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" 2>/dev/null | head -1 | sed "s/\"${key}\"[[:space:]]*:[[:space:]]*\"//;s/\"$//" || true + fi +} + +TOOL_NAME="$(_json_get '.tool_name' "$INPUT")" +CWD="$(_json_get '.cwd' "$INPUT")" + +# Extract file_path from tool_input (for Edit / Write tools) +FILE_PATH="$(_json_get '.tool_input.file_path' "$INPUT")" +# Also try filePath variant +if [[ -z "$FILE_PATH" ]]; then + FILE_PATH="$(_json_get '.tool_input.filePath' "$INPUT")" +fi + +# Extract command from tool_input (for Bash tool) +COMMAND="$(_json_get '.tool_input.command' "$INPUT")" + +# --------------------------------------------------------------------------- +# 3. Determine git context +# --------------------------------------------------------------------------- +# If no cwd provided, fall back to PWD +CWD="${CWD:-$PWD}" + +# Check if we're in a git repo +PROJECT_ROOT="$(cd "$CWD" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)" || { + # Not a git repo โ€” allow everything + exit 0 +} + +BRANCH="$(cd "$CWD" 2>/dev/null && git branch --show-current 2>/dev/null)" || { + # Detached HEAD or other edge case โ€” allow + exit 0 +} + +# If branch is empty (detached HEAD), allow +[[ -z "$BRANCH" ]] && exit 0 + +# Project short name (basename of repo root) +PROJECT_NAME="$(basename "$PROJECT_ROOT")" + +# --------------------------------------------------------------------------- +# 4. Check bypass marker +# --------------------------------------------------------------------------- +if [[ -f "${PROJECT_ROOT}/.claude/allow-dev-edit" ]]; then + exit 0 +fi + +# --------------------------------------------------------------------------- +# 5. Check dry-run marker +# --------------------------------------------------------------------------- +DRY_RUN=false +if [[ -f "${PROJECT_ROOT}/.claude/branch-guard-dryrun" ]]; then + DRY_RUN=true +fi + +# --------------------------------------------------------------------------- +# 6. Load config or auto-detect protection rules +# --------------------------------------------------------------------------- +# Protection levels: "block-all", "block-new-code", "" (none) +PROTECTION="" +MAIN_PROTECTION="" +DEV_PROTECTION="" + +CONFIG_FILE="${PROJECT_ROOT}/.claude/branch-guard.json" + +USE_CONFIG=false +if [[ -f "$CONFIG_FILE" ]]; then + # Validate and parse config file + CONFIG_CONTENT="$(cat "$CONFIG_FILE")" + if command -v jq &>/dev/null && ! printf '%s' "$CONFIG_CONTENT" | jq -e . &>/dev/null; then + echo "[branch-guard] WARNING: Invalid JSON in $CONFIG_FILE โ€” using auto-detect" >&2 + else + USE_CONFIG=true + # Look up current branch directly in the config (supports any branch name) + PROTECTION="$(_json_get ".\"${BRANCH}\"" "$CONFIG_CONTENT")" + fi + # If current branch not found in config, no protection + # (Custom config is explicit โ€” only listed branches are protected) +fi + +if [[ "$USE_CONFIG" == false ]]; then + # Auto-detect: does 'dev' branch exist? + MAIN_PROTECTION="block-all" + DEV_PROTECTION="" + if cd "$PROJECT_ROOT" && git rev-parse --verify dev &>/dev/null; then + DEV_PROTECTION="block-new-code" + fi + + # Determine which protection level applies to current branch + PROTECTION="" + case "$BRANCH" in + main|master) + PROTECTION="$MAIN_PROTECTION" + ;; + dev|develop) + PROTECTION="$DEV_PROTECTION" + ;; + feature/*|feat/*|fix/*|hotfix/*|bugfix/*|refactor/*|chore/*|docs/*|test/*) + # Feature branches and common prefixes โ€” no protection + PROTECTION="" + ;; + *) + # Any other branch โ€” no protection + PROTECTION="" + ;; + esac +fi + +# No protection for this branch โ€” allow everything +[[ -z "$PROTECTION" ]] && exit 0 + +# --------------------------------------------------------------------------- +# 8. Helper: block or dry-run +# --------------------------------------------------------------------------- +block() { + local message="$1" + if [[ "$DRY_RUN" == true ]]; then + echo "[DRY-RUN] branch-guard would block: $message" >&2 + exit 0 + else + printf '%b\n' "$message" >&2 + exit 2 + fi +} + +# --------------------------------------------------------------------------- +# 8b. Inline box-drawing helpers (standalone โ€” no external dependency) +# --------------------------------------------------------------------------- +_W=63 # visible width (matches formatting.sh convention) +_TL='โ•”' _TR='โ•—' _BL='โ•š' _BR='โ•' +_H='โ•' _V='โ•‘' _ML='โ• ' _MR='โ•ฃ' +_R='\033[1;31m' _G='\033[0;32m' _Y='\033[1;33m' +_C='\033[1;36m' _B='\033[1m' _D='\033[2m' _N='\033[0m' + +_hr() { + local l="$1" r="$2" pad="" + pad="$(printf '%0.sโ•' $(seq 1 $((_W - 2))))" + printf '%s%s%s\n' "$l" "$pad" "$r" +} +_row() { + local txt="$1" + # Strip ANSI for width measurement + local plain + plain="$(printf '%b' "$txt" | sed $'s/\033\[[0-9;]*m//g')" + local len=${#plain} + local gap=$((_W - 3 - len)) + if (( gap < 0 )); then gap=0; fi + printf '%s %b%*s%s\n' "$_V" "$txt" "$gap" "" "$_V" +} +_empty() { printf '%s%*s%s\n' "$_V" "$((_W - 2))" "" "$_V"; } + +# Build a formatted block message +# Usage: _box "line1" "line2" ... +# Special lines: "---" inserts a mid-rule (โ• โ•โ•โ•โ•ฃ) +_box() { + local msg="" + msg+="$(_hr "$_TL" "$_TR")"$'\n' + for line in "$@"; do + if [[ "$line" == "---" ]]; then + msg+="$(_hr "$_ML" "$_MR")"$'\n' + elif [[ "$line" == "" ]]; then + msg+="$(_empty)"$'\n' + else + msg+="$(_row "$line")"$'\n' + fi + done + msg+="$(_hr "$_BL" "$_BR")" + printf '%s' "$msg" +} + +# --------------------------------------------------------------------------- +# 9. Apply protection: block-all (main branch) +# --------------------------------------------------------------------------- +if [[ "$PROTECTION" == "block-all" ]]; then + case "$TOOL_NAME" in + Edit|edit) + block "$(_box \ + "${_R}${_B}BRANCH PROTECTION${_N}" \ + "---" \ + "Cannot edit files on ${_B}${BRANCH}${_N}." \ + "" \ + "${_D}File:${_N} ${_C}${FILE_PATH}${_N}" \ + "${_D}Branch:${_N} ${BRANCH} (block-all)" \ + "---" \ + "${_Y}โ†’ git checkout dev${_N}" \ + )" + ;; + + Write|write) + block "$(_box \ + "${_R}${_B}BRANCH PROTECTION${_N}" \ + "---" \ + "Cannot write files on ${_B}${BRANCH}${_N}." \ + "" \ + "${_D}File:${_N} ${_C}${FILE_PATH}${_N}" \ + "${_D}Branch:${_N} ${BRANCH} (block-all)" \ + "---" \ + "${_Y}โ†’ git checkout dev${_N}" \ + )" + ;; + + Bash|bash) + # Check for destructive git commands + if echo "$COMMAND" | grep -qE 'git[[:space:]]+commit|git[[:space:]]+push'; then + block "$(_box \ + "${_R}${_B}BRANCH PROTECTION${_N}" \ + "---" \ + "Cannot commit/push on ${_B}${BRANCH}${_N}." \ + "" \ + "Use the PR workflow:" \ + " ${_Y}1.${_N} git checkout dev" \ + " ${_Y}2.${_N} Create worktree for changes" \ + " ${_Y}3.${_N} PR: feature โ†’ dev โ†’ main" \ + )" + fi + if echo "$COMMAND" | grep -qE 'git[[:space:]]+reset[[:space:]]+--hard'; then + block "$(_box \ + "${_R}${_B}BRANCH PROTECTION${_N}" \ + "---" \ + "Cannot reset --hard on ${_B}${BRANCH}${_N}." \ + "${_D}Branch:${_N} ${BRANCH} (block-all)" \ + )" + fi + # All other bash commands are allowed + exit 0 + ;; + + *) + # Any other tool โ€” allow + exit 0 + ;; + esac +fi + +# --------------------------------------------------------------------------- +# 10. Apply protection: block-new-code (dev branch) +# --------------------------------------------------------------------------- +if [[ "$PROTECTION" == "block-new-code" ]]; then + case "$TOOL_NAME" in + Edit|edit) + # Editing existing files is always allowed on dev + exit 0 + ;; + + Write|write) + # Markdown files โ€” always allowed + if [[ "$FILE_PATH" == *.md ]]; then + exit 0 + fi + + # Extension-less files (no dot in basename) โ€” allowed + # Examples: .STATUS, Makefile, Dockerfile, LICENSE + BASENAME="$(basename "$FILE_PATH")" + if [[ "$BASENAME" != *.* ]] || [[ "$BASENAME" == .* && "${BASENAME#.}" != *.* ]]; then + exit 0 + fi + + # Files in tests/ directory โ€” allowed + if echo "$FILE_PATH" | grep -qE '(^|/)tests/'; then + exit 0 + fi + + # Determine the actual file path (could be relative or absolute) + ACTUAL_PATH="$FILE_PATH" + if [[ "$FILE_PATH" != /* ]]; then + ACTUAL_PATH="${CWD}/${FILE_PATH}" + fi + + # Existing file (overwrite/fixup) โ€” allowed + if [[ -f "$ACTUAL_PATH" ]]; then + exit 0 + fi + + # Also check relative to project root + if [[ -f "${PROJECT_ROOT}/${FILE_PATH}" ]]; then + exit 0 + fi + + # New code file โ€” determine extension + EXT="${FILE_PATH##*.}" + CODE_EXTENSIONS="py sh js ts jsx tsx json yml yaml toml cfg ini r R zsh" + + IS_CODE=false + for ext in $CODE_EXTENSIONS; do + if [[ "$EXT" == "$ext" ]]; then + IS_CODE=true + break + fi + done + + if [[ "$IS_CODE" == true ]]; then + block "$(_box \ + "${_R}${_B}BRANCH PROTECTION${_N}" \ + "---" \ + "Cannot create new ${_B}.${EXT}${_N} file on ${_B}${BRANCH}${_N}." \ + "" \ + "${_D}File:${_N} ${_C}${FILE_PATH}${_N}" \ + "${_D}Branch:${_N} ${BRANCH} (block-new-code)" \ + "---" \ + "Options:" \ + " ${_Y}1.${_N} Create worktree for feature work" \ + " ${_Y}2.${_N} Edit an EXISTING file (fixups allowed)" \ + " ${_Y}3.${_N} Bypass: ${_G}/craft:git:unprotect${_N}" \ + )" + fi + + # Non-code extension or unrecognized โ€” allow + exit 0 + ;; + + Bash|bash) + # Block force push (--force, -f, --force-with-lease) + if echo "$COMMAND" | grep -qE 'git[[:space:]]+push[[:space:]].*(--force|--force-with-lease|-f)([[:space:]]|$)'; then + block "$(_box \ + "${_R}${_B}BRANCH PROTECTION${_N}" \ + "---" \ + "Cannot force push on ${_B}${BRANCH}${_N}." \ + "" \ + "${_D}Command:${_N} ${_C}${COMMAND}${_N}" \ + "${_D}Branch:${_N} ${BRANCH} (block-new-code)" \ + )" + fi + # All other bash commands โ€” allow + exit 0 + ;; + + *) + # Any other tool โ€” allow + exit 0 + ;; + esac +fi + +# If we get here, no rule matched โ€” allow +exit 0 diff --git a/scripts/install-branch-guard.sh b/scripts/install-branch-guard.sh new file mode 100755 index 00000000..8da8cec6 --- /dev/null +++ b/scripts/install-branch-guard.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# install-branch-guard.sh โ€” Install the branch-guard hook for Claude Code +# +# Copies scripts/branch-guard.sh to ~/.claude/hooks/ and registers it +# in ~/.claude/settings.json as a PreToolUse hook. +# +# Usage: +# ./scripts/install-branch-guard.sh # from repo root +# bash scripts/install-branch-guard.sh # explicit +# +# Idempotent: safe to run multiple times. +# Requires: jq (for settings.json registration; warns if missing) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +HOOK_SRC="${REPO_ROOT}/scripts/branch-guard.sh" +HOOK_DIR="${HOME}/.claude/hooks" +HOOK_DEST="${HOOK_DIR}/branch-guard.sh" +SETTINGS="${HOME}/.claude/settings.json" + +# --------------------------------------------------------------------------- +# Colors (minimal โ€” no dependency on formatting.sh) +# --------------------------------------------------------------------------- +G='\033[0;32m' Y='\033[1;33m' R='\033[1;31m' B='\033[1m' N='\033[0m' + +ok() { printf "${G}โœ“${N} %s\n" "$1"; } +warn() { printf "${Y}!${N} %s\n" "$1"; } +err() { printf "${R}โœ—${N} %s\n" "$1" >&2; } + +# --------------------------------------------------------------------------- +# 1. Verify source exists +# --------------------------------------------------------------------------- +if [[ ! -f "$HOOK_SRC" ]]; then + err "Source not found: $HOOK_SRC" + err "Run this script from the craft repo root." + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2. Copy hook to ~/.claude/hooks/ +# --------------------------------------------------------------------------- +mkdir -p "$HOOK_DIR" + +if [[ -L "$HOOK_DEST" ]]; then + # Already a symlink (e.g. dev setup) โ€” leave it + ok "branch-guard.sh already symlinked: $(readlink "$HOOK_DEST")" +elif [[ -f "$HOOK_DEST" ]]; then + # Compare contents โ€” skip if identical + if diff -q "$HOOK_SRC" "$HOOK_DEST" &>/dev/null; then + ok "branch-guard.sh already up to date" + else + cp "$HOOK_SRC" "$HOOK_DEST" + chmod +x "$HOOK_DEST" + ok "branch-guard.sh updated" + fi +else + cp "$HOOK_SRC" "$HOOK_DEST" + chmod +x "$HOOK_DEST" + ok "branch-guard.sh installed to ${HOOK_DIR}/" +fi + +# --------------------------------------------------------------------------- +# 3. Register in settings.json (requires jq) +# --------------------------------------------------------------------------- +if ! command -v jq &>/dev/null; then + warn "jq not found โ€” skipping settings.json registration" + echo " Install jq (brew install jq) and re-run, or add manually:" + echo "" + echo " In ~/.claude/settings.json, add to \"hooks.PreToolUse\":" + echo ' { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "/bin/bash ~/.claude/hooks/branch-guard.sh", "timeout": 5000 }] }' + echo ' { "matcher": "Bash", "hooks": [{ "type": "command", "command": "/bin/bash ~/.claude/hooks/branch-guard.sh", "timeout": 5000 }] }' + exit 0 +fi + +# Ensure settings.json exists with minimal structure +if [[ ! -f "$SETTINGS" ]]; then + echo '{}' > "$SETTINGS" +fi + +HOOK_CMD="/bin/bash ${HOME}/.claude/hooks/branch-guard.sh" + +# Check if branch-guard is already registered (look for the hook command) +if jq -e '.hooks.PreToolUse // [] | map(.hooks[]?.command) | any(test("branch-guard"))' "$SETTINGS" &>/dev/null; then + ok "settings.json already has branch-guard entries" + exit 0 +fi + +# Build the two PreToolUse entries +EDIT_ENTRY="$(jq -n --arg cmd "$HOOK_CMD" '{ + "matcher": "Edit|Write", + "hooks": [{ "type": "command", "command": $cmd, "timeout": 5000 }] +}')" + +BASH_ENTRY="$(jq -n --arg cmd "$HOOK_CMD" '{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": $cmd, "timeout": 5000 }] +}')" + +# Append to PreToolUse array (create if missing) +jq --argjson edit "$EDIT_ENTRY" --argjson bash "$BASH_ENTRY" ' + .hooks.PreToolUse = (.hooks.PreToolUse // []) + [$edit, $bash] +' "$SETTINGS" > "${SETTINGS}.tmp" && mv "${SETTINGS}.tmp" "$SETTINGS" + +ok "Registered branch-guard in ${SETTINGS}" + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- +echo "" +echo -e "${B}Branch guard installed.${N} Protection is active for:" +echo " โ€ข main/master โ€” all edits and commits blocked" +echo " โ€ข dev/develop โ€” new code files blocked (edits allowed)" +echo " โ€ข feature/* โ€” unrestricted" +echo "" +echo "Commands:" +echo " /craft:git:unprotect โ€” session-scoped bypass" +echo " /craft:git:protect โ€” re-enable protection" diff --git a/tests/cli/README.md b/tests/cli/README.md index 8a79318e..1f658efa 100644 --- a/tests/cli/README.md +++ b/tests/cli/README.md @@ -46,11 +46,58 @@ bash tests/cli/interactive-tests.sh - `n` = fail - `q` = quit +## Branch Protection Tests + +### E2E Dogfooding Tests (`../test_branch_guard_e2e.sh`) + +Automated end-to-end tests for the branch-guard hook that exercise full multi-step workflows: + +```bash +bash tests/test_branch_guard_e2e.sh +``` + +**What it tests (8 groups, 31 tests):** + +- Full workflow: dev -> feature worktree -> back to dev +- Bypass lifecycle: create marker -> allowed -> remove -> blocked +- Config cascade: auto-detect, custom config, malformed fallthrough +- Error messages: header, file path, branch name, options +- Cross-tool consistency: Edit vs Write on main and dev +- Dry-run -> enforcement transitions +- Performance: 50 invocations < 5s, no temp file leaks +- Real-world scenarios: CLAUDE.md, tests/, .STATUS, force push + +### Interactive Branch Guard Tests (`../test_branch_guard_interactive.sh`) + +Human-guided QA tests for user-facing behavior: + +```bash +bash tests/test_branch_guard_interactive.sh +``` + +**What it tests (10 scenarios):** + +- Hook registration in settings.json +- Error message readability and formatting +- Bypass marker format validation +- Command integration (status, check, worktree) +- Dry-run logging visibility +- Perceived performance responsiveness +- Config file format + +**Keys:** `y` = pass, `n` = fail, `s` = skip, `q` = quit + +### Related Unit Tests + +- `tests/test_branch_guard.sh` โ€” 49 unit tests (automated) +- `tests/test_integration_branch_guard.py` โ€” 6 integration tests (automated) + ## Logs Test logs are saved to `tests/cli/logs/`: - `interactive-test-YYYYMMDD-HHMMSS.log` +- `branch-guard-interactive-YYYYMMDD-HHMMSS.log` ## Running from Project Root diff --git a/tests/test_branch_guard.sh b/tests/test_branch_guard.sh new file mode 100755 index 00000000..848c740d --- /dev/null +++ b/tests/test_branch_guard.sh @@ -0,0 +1,811 @@ +#!/usr/bin/env bash +# +# Test Suite for ~/.claude/hooks/branch-guard.sh +# Tests: Branch protection hook for Claude Code PreToolUse +# +# Usage: +# ./tests/test_branch_guard.sh # Run all tests +# bash tests/test_branch_guard.sh # Run all tests +# +# Approach: +# - Creates a temporary git repo for each test group +# - Pipes JSON to the hook script via stdin +# - Checks exit code (0=allow, 2=block) and stderr output +# +# Requirements: +# - ~/.claude/hooks/branch-guard.sh must exist +# - git must be available + +# Note: Not using 'set -e' because we want tests to continue after failures +set -uo pipefail + +# ============================================================================ +# Configuration +# ============================================================================ + +HOOK_SCRIPT="$HOME/.claude/hooks/branch-guard.sh" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Color output +T_RED='\033[0;31m' +T_GREEN='\033[0;32m' +T_YELLOW='\033[1;33m' +T_BLUE='\033[0;34m' +T_BOLD='\033[1m' +T_NC='\033[0m' + +# Test counters +TOTAL=0 +PASS=0 +FAIL=0 +SKIP=0 + +declare -a FAILED_NAMES=() + +# Temp directories to clean up +declare -a CLEANUP_DIRS=() + +# ============================================================================ +# Preflight Check +# ============================================================================ + +if [[ ! -f "$HOOK_SCRIPT" ]]; then + echo -e "${T_RED}ERROR${T_NC}: Hook script not found at $HOOK_SCRIPT" + echo " Create the hook first, then run these tests." + exit 1 +fi + +if ! command -v git &>/dev/null; then + echo -e "${T_RED}ERROR${T_NC}: git is not available" + exit 1 +fi + +# ============================================================================ +# Helpers +# ============================================================================ + +# Create a temp directory and register it for cleanup +make_tmpdir() { + local dir + dir=$(mktemp -d "${TMPDIR:-/tmp}/branch-guard-test.XXXXXX") + CLEANUP_DIRS+=("$dir") + echo "$dir" +} + +# Cleanup all temp directories +cleanup() { + for dir in "${CLEANUP_DIRS[@]}"; do + if [[ -d "$dir" ]]; then + rm -rf "$dir" + fi + done +} +trap cleanup EXIT + +# Initialize a git repo with main + dev branches and initial commit +# Usage: init_repo [--no-dev] +# Returns the repo path via stdout +init_repo() { + local no_dev=false + if [[ "${1:-}" == "--no-dev" ]]; then + no_dev=true + fi + + local repo + repo=$(make_tmpdir) + + ( + cd "$repo" + git init -b main --quiet + git config user.email "test@test.com" + git config user.name "Test" + + # Initial commit so branches work + mkdir -p src tests .claude + echo "# README" > README.md + git add -A + git commit -m "Initial commit" --quiet + + if [[ "$no_dev" == false ]]; then + git branch dev + fi + ) + + echo "$repo" +} + +# Switch branch in a repo +switch_branch() { + local repo="$1" + local branch="$2" + (cd "$repo" && git checkout "$branch" --quiet 2>/dev/null) +} + +# Create a branch and switch to it +create_and_switch() { + local repo="$1" + local branch="$2" + (cd "$repo" && git checkout -b "$branch" --quiet 2>/dev/null) +} + +# Build JSON for Edit tool +json_edit() { + local file_path="$1" + local cwd="$2" + printf '{"tool_name":"Edit","tool_input":{"file_path":"%s","old_string":"x","new_string":"y"},"cwd":"%s"}' \ + "$file_path" "$cwd" +} + +# Build JSON for Write tool +json_write() { + local file_path="$1" + local cwd="$2" + printf '{"tool_name":"Write","tool_input":{"file_path":"%s","content":"# new content"},"cwd":"%s"}' \ + "$file_path" "$cwd" +} + +# Build JSON for Bash tool +json_bash() { + local command="$1" + local cwd="$2" + printf '{"tool_name":"Bash","tool_input":{"command":"%s"},"cwd":"%s"}' \ + "$command" "$cwd" +} + +# Run the hook and check exit code +# Usage: run_test "test name" expected_exit json_string [cwd] +run_test() { + local name="$1" + local expected_exit="$2" + local json="$3" + local cwd="${4:-}" + + TOTAL=$((TOTAL + 1)) + + # If cwd provided, run from there; otherwise use current dir + local actual_exit=0 + local stderr_output="" + + if [[ -n "$cwd" ]]; then + stderr_output=$(echo "$json" | (cd "$cwd" && bash "$HOOK_SCRIPT") 2>&1 >/dev/null) || actual_exit=$? + else + stderr_output=$(echo "$json" | bash "$HOOK_SCRIPT" 2>&1 >/dev/null) || actual_exit=$? + fi + + if [[ "$actual_exit" -eq "$expected_exit" ]]; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} $name ${T_BOLD}(exit=$actual_exit)${T_NC}" + else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + echo -e " ${T_RED}FAIL${T_NC} $name ${T_BOLD}(expected=$expected_exit, got=$actual_exit)${T_NC}" + if [[ -n "$stderr_output" ]]; then + echo -e " stderr: $(echo "$stderr_output" | head -3)" + fi + fi +} + +# Run the hook and also capture stderr for content checking +# Usage: run_test_with_stderr "test name" expected_exit json_string cwd expected_stderr_pattern +run_test_with_stderr() { + local name="$1" + local expected_exit="$2" + local json="$3" + local cwd="$4" + local expected_pattern="$5" + + TOTAL=$((TOTAL + 1)) + + local actual_exit=0 + local stderr_output="" + stderr_output=$(echo "$json" | (cd "$cwd" && bash "$HOOK_SCRIPT") 2>&1 >/dev/null) || actual_exit=$? + + local exit_ok=false + local pattern_ok=false + + [[ "$actual_exit" -eq "$expected_exit" ]] && exit_ok=true + if echo "$stderr_output" | grep -qi "$expected_pattern" 2>/dev/null; then + pattern_ok=true + fi + + if [[ "$exit_ok" == true && "$pattern_ok" == true ]]; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} $name ${T_BOLD}(exit=$actual_exit, pattern matched)${T_NC}" + elif [[ "$exit_ok" == true && "$pattern_ok" == false ]]; then + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + echo -e " ${T_RED}FAIL${T_NC} $name ${T_BOLD}(exit OK, but stderr missing pattern: '$expected_pattern')${T_NC}" + echo -e " stderr: $(echo "$stderr_output" | head -3)" + else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + echo -e " ${T_RED}FAIL${T_NC} $name ${T_BOLD}(expected exit=$expected_exit, got=$actual_exit)${T_NC}" + if [[ -n "$stderr_output" ]]; then + echo -e " stderr: $(echo "$stderr_output" | head -3)" + fi + fi +} + +skip() { + local name="$1" + local reason="${2:-Skipped}" + TOTAL=$((TOTAL + 1)) + SKIP=$((SKIP + 1)) + echo -e " ${T_YELLOW}SKIP${T_NC} $name ($reason)" +} + +# ============================================================================ +# Test Groups +# ============================================================================ + +echo "" +echo -e "${T_BOLD}Branch Guard Hook Tests${T_NC}" +echo -e "${T_BOLD}=======================${T_NC}" +echo -e "Hook: $HOOK_SCRIPT" +echo "" + +# -------------------------------------------------------------------------- +# Group 1: Main branch protection (block-all) +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Main Branch Protection (block-all) ---${T_NC}" + +REPO=$(init_repo) +switch_branch "$REPO" "main" + +# Test 1: Edit .py on main -> BLOCK +run_test \ + "test_edit_py_on_main" \ + 2 \ + "$(json_edit "$REPO/src/app.py" "$REPO")" \ + "$REPO" + +# Test 6: Edit .md on main -> BLOCK (block-all means ALL files) +run_test \ + "test_edit_md_on_main" \ + 2 \ + "$(json_edit "$REPO/docs/README.md" "$REPO")" \ + "$REPO" + +# Test 13: Bash git commit on main -> BLOCK +run_test \ + "test_bash_git_commit_on_main" \ + 2 \ + "$(json_bash "git commit -m 'test'" "$REPO")" \ + "$REPO" + +# Test 14: Bash git push on main -> BLOCK +run_test \ + "test_bash_git_push_on_main" \ + 2 \ + "$(json_bash "git push origin main" "$REPO")" \ + "$REPO" + +# Test 17: Bash non-git command on main -> ALLOW (only git-mutating blocked) +run_test \ + "test_bash_non_git_on_main" \ + 0 \ + "$(json_bash "ls -la" "$REPO")" \ + "$REPO" + +echo "" + +# -------------------------------------------------------------------------- +# Group 2: Dev branch protection (block-new-code) +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Dev Branch Protection (block-new-code) ---${T_NC}" + +REPO_DEV=$(init_repo) +switch_branch "$REPO_DEV" "dev" + +# Test 2: Edit existing .py on dev -> ALLOW (fixup) +# Create the file first so it exists +echo "print('hello')" > "$REPO_DEV/src/app.py" +(cd "$REPO_DEV" && git add src/app.py && git commit -m "Add app.py" --quiet) + +run_test \ + "test_edit_py_on_dev_existing" \ + 0 \ + "$(json_edit "$REPO_DEV/src/app.py" "$REPO_DEV")" \ + "$REPO_DEV" + +# Test 3: Write new .py on dev (file doesn't exist) -> BLOCK +run_test \ + "test_write_new_py_on_dev" \ + 2 \ + "$(json_write "$REPO_DEV/src/new_module.py" "$REPO_DEV")" \ + "$REPO_DEV" + +# Test 4: Write new .md on dev -> ALLOW +run_test \ + "test_write_new_md_on_dev" \ + 0 \ + "$(json_write "$REPO_DEV/docs/design.md" "$REPO_DEV")" \ + "$REPO_DEV" + +# Test 7: Write .py in tests/ on dev -> ALLOW +run_test \ + "test_write_py_in_tests_on_dev" \ + 0 \ + "$(json_write "$REPO_DEV/tests/test_something.py" "$REPO_DEV")" \ + "$REPO_DEV" + +# Test 8: Write extension-less file on dev -> ALLOW +run_test \ + "test_write_extensionless_status_on_dev" \ + 0 \ + "$(json_write "$REPO_DEV/.STATUS" "$REPO_DEV")" \ + "$REPO_DEV" + +# Bonus: Also test Makefile (extension-less) +run_test \ + "test_write_extensionless_makefile_on_dev" \ + 0 \ + "$(json_write "$REPO_DEV/Makefile" "$REPO_DEV")" \ + "$REPO_DEV" + +# Test 19: Write to existing .py on dev (overwrite) -> ALLOW +echo "print('existing')" > "$REPO_DEV/src/existing.py" +(cd "$REPO_DEV" && git add src/existing.py && git commit -m "Add existing.py" --quiet) + +run_test \ + "test_write_existing_py_on_dev" \ + 0 \ + "$(json_write "$REPO_DEV/src/existing.py" "$REPO_DEV")" \ + "$REPO_DEV" + +# Test 15: Bash git push --force on dev -> BLOCK +run_test \ + "test_bash_git_push_force_on_dev" \ + 2 \ + "$(json_bash "git push --force" "$REPO_DEV")" \ + "$REPO_DEV" + +# Test 16: Bash git merge on dev -> ALLOW +run_test \ + "test_bash_git_merge_on_dev" \ + 0 \ + "$(json_bash "git merge feature/x" "$REPO_DEV")" \ + "$REPO_DEV" + +# Bonus: Bash git commit on dev -> ALLOW (doc commits, merge commits) +run_test \ + "test_bash_git_commit_on_dev" \ + 0 \ + "$(json_bash "git commit -m 'docs: update'" "$REPO_DEV")" \ + "$REPO_DEV" + +# Bonus: Bash git push (no --force) on dev -> ALLOW +run_test \ + "test_bash_git_push_on_dev" \ + 0 \ + "$(json_bash "git push origin dev" "$REPO_DEV")" \ + "$REPO_DEV" + +echo "" + +# -------------------------------------------------------------------------- +# Group 3: Feature branch (no restrictions) +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Feature Branch (no restrictions) ---${T_NC}" + +REPO_FEAT=$(init_repo) +create_and_switch "$REPO_FEAT" "feature/test" + +# Test 5: Write new .py on feature/* -> ALLOW +run_test \ + "test_write_new_py_on_feature" \ + 0 \ + "$(json_write "$REPO_FEAT/src/new_module.py" "$REPO_FEAT")" \ + "$REPO_FEAT" + +# Bonus: Edit on feature -> ALLOW +run_test \ + "test_edit_py_on_feature" \ + 0 \ + "$(json_edit "$REPO_FEAT/src/app.py" "$REPO_FEAT")" \ + "$REPO_FEAT" + +# Bonus: Bash git commit on feature -> ALLOW +run_test \ + "test_bash_git_commit_on_feature" \ + 0 \ + "$(json_bash "git commit -m 'feat: add feature'" "$REPO_FEAT")" \ + "$REPO_FEAT" + +# Bonus: Bash git push --force on feature -> ALLOW (force push OK on feature branches) +run_test \ + "test_bash_git_push_force_on_feature" \ + 0 \ + "$(json_bash "git push --force origin feature/test" "$REPO_FEAT")" \ + "$REPO_FEAT" + +echo "" + +# -------------------------------------------------------------------------- +# Group 4: Bypass mechanisms +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Bypass Mechanisms ---${T_NC}" + +# Test 9: Bypass marker active on dev -> ALLOW +REPO_BYPASS=$(init_repo) +switch_branch "$REPO_BYPASS" "dev" +mkdir -p "$REPO_BYPASS/.claude" +echo '{"reason":"test","timestamp":"2026-02-06T00:00:00Z"}' > "$REPO_BYPASS/.claude/allow-dev-edit" + +run_test \ + "test_bypass_marker_active" \ + 0 \ + "$(json_write "$REPO_BYPASS/src/new_code.py" "$REPO_BYPASS")" \ + "$REPO_BYPASS" + +# Clean up bypass marker and verify block resumes +rm -f "$REPO_BYPASS/.claude/allow-dev-edit" + +run_test \ + "test_bypass_marker_removed_blocks_again" \ + 2 \ + "$(json_write "$REPO_BYPASS/src/new_code.py" "$REPO_BYPASS")" \ + "$REPO_BYPASS" + +echo "" + +# -------------------------------------------------------------------------- +# Group 5: Dry-run mode +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Dry-Run Mode ---${T_NC}" + +# Test 18: Dry-run mode logs but doesn't block +REPO_DRY=$(init_repo) +switch_branch "$REPO_DRY" "main" +mkdir -p "$REPO_DRY/.claude" +touch "$REPO_DRY/.claude/branch-guard-dryrun" + +# In dry-run, action that would be blocked should exit 0 but log +run_test_with_stderr \ + "test_dryrun_mode_allows_with_log" \ + 0 \ + "$(json_edit "$REPO_DRY/src/app.py" "$REPO_DRY")" \ + "$REPO_DRY" \ + "dry.run\|DRYRUN\|dryrun\|DRY.RUN\|would block\|would have blocked" + +# Remove dry-run marker, verify it blocks again +rm -f "$REPO_DRY/.claude/branch-guard-dryrun" + +run_test \ + "test_dryrun_removed_blocks_again" \ + 2 \ + "$(json_edit "$REPO_DRY/src/app.py" "$REPO_DRY")" \ + "$REPO_DRY" + +echo "" + +# -------------------------------------------------------------------------- +# Group 6: Edge cases +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Edge Cases ---${T_NC}" + +# Test 10: No git repo (non-git directory) -> ALLOW (graceful fallthrough) +NON_GIT_DIR=$(make_tmpdir) + +run_test \ + "test_no_git_repo" \ + 0 \ + "$(json_edit "$NON_GIT_DIR/src/app.py" "$NON_GIT_DIR")" \ + "$NON_GIT_DIR" + +# Test 11: Repo with only main, no dev branch -> only protect main +REPO_NODEV=$(init_repo --no-dev) +switch_branch "$REPO_NODEV" "main" + +# Edit on main (no dev) -> still BLOCK (main is always protected) +run_test \ + "test_no_dev_branch_main_still_blocked" \ + 2 \ + "$(json_edit "$REPO_NODEV/src/app.py" "$REPO_NODEV")" \ + "$REPO_NODEV" + +# Create a non-main, non-dev branch in the no-dev repo -> ALLOW +create_and_switch "$REPO_NODEV" "working" + +run_test \ + "test_no_dev_branch_other_branch_allowed" \ + 0 \ + "$(json_write "$REPO_NODEV/src/new.py" "$REPO_NODEV")" \ + "$REPO_NODEV" + +echo "" + +# -------------------------------------------------------------------------- +# Group 7: Custom branch-guard.json +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Custom branch-guard.json ---${T_NC}" + +# Test 12: Custom config with production=block-all +REPO_CUSTOM=$(init_repo) +create_and_switch "$REPO_CUSTOM" "production" + +mkdir -p "$REPO_CUSTOM/.claude" +cat > "$REPO_CUSTOM/.claude/branch-guard.json" <<'JSONEOF' +{ + "production": "block-all" +} +JSONEOF + +run_test \ + "test_custom_branch_guard_json_production_blocked" \ + 2 \ + "$(json_edit "$REPO_CUSTOM/src/app.py" "$REPO_CUSTOM")" \ + "$REPO_CUSTOM" + +# Verify non-listed branch in custom config -> ALLOW +create_and_switch "$REPO_CUSTOM" "staging" + +run_test \ + "test_custom_branch_guard_json_unlisted_allowed" \ + 0 \ + "$(json_edit "$REPO_CUSTOM/src/app.py" "$REPO_CUSTOM")" \ + "$REPO_CUSTOM" + +# Custom config with draft=block-new-code (teaching project style) +REPO_TEACH=$(init_repo) +create_and_switch "$REPO_TEACH" "draft" + +mkdir -p "$REPO_TEACH/.claude" +cat > "$REPO_TEACH/.claude/branch-guard.json" <<'JSONEOF' +{ + "production": "block-all", + "draft": "block-new-code" +} +JSONEOF + +# New .py on draft -> BLOCK +run_test \ + "test_custom_draft_new_code_blocked" \ + 2 \ + "$(json_write "$REPO_TEACH/src/new_module.py" "$REPO_TEACH")" \ + "$REPO_TEACH" + +# .md on draft -> ALLOW +run_test \ + "test_custom_draft_md_allowed" \ + 0 \ + "$(json_write "$REPO_TEACH/docs/notes.md" "$REPO_TEACH")" \ + "$REPO_TEACH" + +# Existing .py on draft -> ALLOW (fixup) +echo "print('hello')" > "$REPO_TEACH/src/existing.py" +(cd "$REPO_TEACH" && git add src/existing.py && git commit -m "Add file" --quiet) + +run_test \ + "test_custom_draft_existing_py_allowed" \ + 0 \ + "$(json_edit "$REPO_TEACH/src/existing.py" "$REPO_TEACH")" \ + "$REPO_TEACH" + +echo "" + +# -------------------------------------------------------------------------- +# Group 8: Bash command edge cases +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Bash Command Edge Cases ---${T_NC}" + +REPO_BASH=$(init_repo) + +# git push --force-with-lease on dev -> should also be blocked +switch_branch "$REPO_BASH" "dev" + +run_test \ + "test_bash_git_push_force_with_lease_on_dev" \ + 2 \ + "$(json_bash "git push --force-with-lease origin dev" "$REPO_BASH")" \ + "$REPO_BASH" + +# git reset --hard on main -> BLOCK +switch_branch "$REPO_BASH" "main" + +run_test \ + "test_bash_git_reset_hard_on_main" \ + 2 \ + "$(json_bash "git reset --hard HEAD~1" "$REPO_BASH")" \ + "$REPO_BASH" + +# Piped command with git commit on main -> BLOCK +run_test \ + "test_bash_piped_git_commit_on_main" \ + 2 \ + "$(json_bash "echo test && git commit -m 'sneak'" "$REPO_BASH")" \ + "$REPO_BASH" + +# Non-git bash on dev -> ALLOW +switch_branch "$REPO_BASH" "dev" + +run_test \ + "test_bash_python_command_on_dev" \ + 0 \ + "$(json_bash "python3 -c 'print(1)'" "$REPO_BASH")" \ + "$REPO_BASH" + +echo "" + +# -------------------------------------------------------------------------- +# Group 9: File extension coverage +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- File Extension Coverage ---${T_NC}" + +REPO_EXT=$(init_repo) +switch_branch "$REPO_EXT" "dev" + +# New .sh on dev -> BLOCK +run_test \ + "test_write_new_sh_on_dev" \ + 2 \ + "$(json_write "$REPO_EXT/scripts/deploy.sh" "$REPO_EXT")" \ + "$REPO_EXT" + +# New .js on dev -> BLOCK +run_test \ + "test_write_new_js_on_dev" \ + 2 \ + "$(json_write "$REPO_EXT/src/index.js" "$REPO_EXT")" \ + "$REPO_EXT" + +# New .yml on dev -> BLOCK (config-as-code) +run_test \ + "test_write_new_yml_on_dev" \ + 2 \ + "$(json_write "$REPO_EXT/config/app.yml" "$REPO_EXT")" \ + "$REPO_EXT" + +# New .json on dev -> BLOCK +run_test \ + "test_write_new_json_on_dev" \ + 2 \ + "$(json_write "$REPO_EXT/package.json" "$REPO_EXT")" \ + "$REPO_EXT" + +# New Dockerfile (extension-less) on dev -> ALLOW +run_test \ + "test_write_dockerfile_on_dev" \ + 0 \ + "$(json_write "$REPO_EXT/Dockerfile" "$REPO_EXT")" \ + "$REPO_EXT" + +# New .txt on dev -> ALLOW (not in code extensions list) +run_test \ + "test_write_txt_on_dev" \ + 0 \ + "$(json_write "$REPO_EXT/notes.txt" "$REPO_EXT")" \ + "$REPO_EXT" + +echo "" + +# -------------------------------------------------------------------------- +# Group 10: Edge cases โ€” path traversal, symlinks, special branch names, +# malformed config warning, git -C invocations +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Advanced Edge Cases ---${T_NC}" + +REPO_ADV=$(init_repo) + +# Path traversal with ".." โ€” writing ../../../etc/foo.py from dev +# The resolved path doesn't exist -> new code file -> BLOCK +switch_branch "$REPO_ADV" "dev" + +run_test \ + "test_path_traversal_new_file_blocked" \ + 2 \ + "$(json_write "$REPO_ADV/../nonexistent/evil.py" "$REPO_ADV")" \ + "$REPO_ADV" + +# Symlink: Create a symlink to an existing file (absolute target), then "write" to it +# The symlink target exists -> -f follows symlinks -> treated as existing file -> ALLOW +(cd "$REPO_ADV" && ln -sf "$REPO_ADV/README.md" src/link-to-readme.py 2>/dev/null) + +run_test \ + "test_symlink_to_existing_file_allowed" \ + 0 \ + "$(json_write "$REPO_ADV/src/link-to-readme.py" "$REPO_ADV")" \ + "$REPO_ADV" + +# Branch name with special regex chars (e.g., "release/v2.0") +# Custom config with that branch name -> should match via jq +mkdir -p "$REPO_ADV/.claude" +cat > "$REPO_ADV/.claude/branch-guard.json" <<'JSONEOF' +{ + "release/v2.0": "block-all", + "dev": "block-new-code" +} +JSONEOF + +create_and_switch "$REPO_ADV" "release/v2.0" + +run_test \ + "test_special_branch_name_slash_dot" \ + 2 \ + "$(json_edit "$REPO_ADV/README.md" "$REPO_ADV")" \ + "$REPO_ADV" + +# Clean up custom config for remaining tests +rm -f "$REPO_ADV/.claude/branch-guard.json" +switch_branch "$REPO_ADV" "dev" + +# git -C commit on main โ€” current pattern doesn't catch this +# because grep expects "gitcommit" but sees "git -C ... commit" +# This is a known limitation โ€” documenting the behavior +REPO_GIT_C=$(init_repo) +switch_branch "$REPO_GIT_C" "main" + +run_test \ + "test_bash_git_dash_c_commit_on_main_not_caught" \ + 0 \ + "$(json_bash "git -C /some/path commit -m test" "$REPO_GIT_C")" \ + "$REPO_GIT_C" + +# Malformed config: should log a WARNING to stderr and fall through to auto-detect +REPO_WARN=$(init_repo) +mkdir -p "$REPO_WARN/.claude" +echo "not valid json {{" > "$REPO_WARN/.claude/branch-guard.json" +switch_branch "$REPO_WARN" "main" + +run_test_with_stderr \ + "test_malformed_config_warns_on_stderr" \ + 2 \ + "$(json_edit "$REPO_WARN/README.md" "$REPO_WARN")" \ + "$REPO_WARN" \ + "WARNING.*Invalid JSON" + +# Write to .STATUS (dot-prefixed extension-less) on dev -> ALLOW +REPO_DOT=$(init_repo) +switch_branch "$REPO_DOT" "dev" + +run_test \ + "test_write_dot_status_file_on_dev" \ + 0 \ + "$(json_write "$REPO_DOT/.STATUS" "$REPO_DOT")" \ + "$REPO_DOT" + +# Write new .R file on dev -> BLOCK (R is in code extensions) +run_test \ + "test_write_new_r_file_on_dev" \ + 2 \ + "$(json_write "$REPO_DOT/analysis.R" "$REPO_DOT")" \ + "$REPO_DOT" + +echo "" + +# ============================================================================ +# Summary +# ============================================================================ + +echo -e "${T_BOLD}===============================${T_NC}" +echo -e "${T_BOLD} Branch Guard Test Summary${T_NC}" +echo -e "${T_BOLD}===============================${T_NC}" +echo "" +echo -e " Total: ${T_BOLD}$TOTAL${T_NC}" +echo -e " Passed: ${T_GREEN}$PASS${T_NC}" +echo -e " Failed: ${T_RED}$FAIL${T_NC}" +echo -e " Skipped: ${T_YELLOW}$SKIP${T_NC}" +echo "" + +if [[ $FAIL -gt 0 ]]; then + echo -e "${T_RED}Failed tests:${T_NC}" + for name in "${FAILED_NAMES[@]}"; do + echo -e " ${T_RED}-${T_NC} $name" + done + echo "" + echo -e "${T_RED}RESULT: FAIL${T_NC}" + exit 1 +else + echo -e "${T_GREEN}RESULT: ALL TESTS PASSED${T_NC}" + exit 0 +fi diff --git a/tests/test_branch_guard_e2e.sh b/tests/test_branch_guard_e2e.sh new file mode 100755 index 00000000..bf3cb3e0 --- /dev/null +++ b/tests/test_branch_guard_e2e.sh @@ -0,0 +1,663 @@ +#!/usr/bin/env bash +# +# E2E Dogfooding Tests for ~/.claude/hooks/branch-guard.sh +# Tests: Multi-step realistic workflows, config cascades, bypass lifecycle, +# cross-tool consistency, performance, and real-world scenarios. +# +# Usage: +# bash tests/test_branch_guard_e2e.sh +# +# Differs from test_branch_guard.sh (unit tests) by exercising full workflows +# with multiple steps and cross-component interactions. +# +# Requirements: +# - ~/.claude/hooks/branch-guard.sh must exist +# - git must be available + +# Note: Not using 'set -e' because we want tests to continue after failures +set -uo pipefail + +# ============================================================================ +# Configuration +# ============================================================================ + +HOOK_SCRIPT="$HOME/.claude/hooks/branch-guard.sh" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Color output +T_RED='\033[0;31m' +T_GREEN='\033[0;32m' +T_YELLOW='\033[1;33m' +T_BLUE='\033[0;34m' +T_BOLD='\033[1m' +T_NC='\033[0m' + +# Test counters +TOTAL=0 +PASS=0 +FAIL=0 +SKIP=0 + +declare -a FAILED_NAMES=() + +# Temp directories to clean up +declare -a CLEANUP_DIRS=() + +# ============================================================================ +# Preflight Check +# ============================================================================ + +if [[ ! -f "$HOOK_SCRIPT" ]]; then + echo -e "${T_RED}ERROR${T_NC}: Hook script not found at $HOOK_SCRIPT" + echo " Create the hook first, then run these tests." + exit 1 +fi + +if ! command -v git &>/dev/null; then + echo -e "${T_RED}ERROR${T_NC}: git is not available" + exit 1 +fi + +# ============================================================================ +# Helpers (same pattern as test_branch_guard.sh) +# ============================================================================ + +make_tmpdir() { + local dir + dir=$(mktemp -d "${TMPDIR:-/tmp}/branch-guard-e2e.XXXXXX") + CLEANUP_DIRS+=("$dir") + echo "$dir" +} + +cleanup() { + for dir in "${CLEANUP_DIRS[@]}"; do + if [[ -d "$dir" ]]; then + rm -rf "$dir" + fi + done +} +trap cleanup EXIT + +init_repo() { + local no_dev=false + if [[ "${1:-}" == "--no-dev" ]]; then + no_dev=true + fi + + local repo + repo=$(make_tmpdir) + + ( + cd "$repo" + git init -b main --quiet + git config user.email "test@test.com" + git config user.name "Test" + + mkdir -p src tests .claude docs utils + echo "# README" > README.md + echo "print('hello')" > src/app.py + echo "# Status" > .STATUS + git add -A + git commit -m "Initial commit" --quiet + + if [[ "$no_dev" == false ]]; then + git branch dev + fi + ) + + echo "$repo" +} + +switch_branch() { + local repo="$1" branch="$2" + (cd "$repo" && git checkout "$branch" --quiet 2>/dev/null) +} + +create_and_switch() { + local repo="$1" branch="$2" + (cd "$repo" && git checkout -b "$branch" --quiet 2>/dev/null) +} + +json_edit() { + local file_path="$1" cwd="$2" + printf '{"tool_name":"Edit","tool_input":{"file_path":"%s","old_string":"x","new_string":"y"},"cwd":"%s"}' \ + "$file_path" "$cwd" +} + +json_write() { + local file_path="$1" cwd="$2" + printf '{"tool_name":"Write","tool_input":{"file_path":"%s","content":"# new content"},"cwd":"%s"}' \ + "$file_path" "$cwd" +} + +json_bash() { + local command="$1" cwd="$2" + printf '{"tool_name":"Bash","tool_input":{"command":"%s"},"cwd":"%s"}' \ + "$command" "$cwd" +} + +run_test() { + local name="$1" expected_exit="$2" json="$3" cwd="${4:-}" + + TOTAL=$((TOTAL + 1)) + + local actual_exit=0 + local stderr_output="" + + if [[ -n "$cwd" ]]; then + stderr_output=$(echo "$json" | (cd "$cwd" && bash "$HOOK_SCRIPT") 2>&1 >/dev/null) || actual_exit=$? + else + stderr_output=$(echo "$json" | bash "$HOOK_SCRIPT" 2>&1 >/dev/null) || actual_exit=$? + fi + + if [[ "$actual_exit" -eq "$expected_exit" ]]; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} $name ${T_BOLD}(exit=$actual_exit)${T_NC}" + else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + echo -e " ${T_RED}FAIL${T_NC} $name ${T_BOLD}(expected=$expected_exit, got=$actual_exit)${T_NC}" + if [[ -n "$stderr_output" ]]; then + echo -e " stderr: $(echo "$stderr_output" | head -3)" + fi + fi +} + +run_test_with_stderr() { + local name="$1" expected_exit="$2" json="$3" cwd="$4" expected_pattern="$5" + + TOTAL=$((TOTAL + 1)) + + local actual_exit=0 + local stderr_output="" + stderr_output=$(echo "$json" | (cd "$cwd" && bash "$HOOK_SCRIPT") 2>&1 >/dev/null) || actual_exit=$? + + local exit_ok=false pattern_ok=false + + [[ "$actual_exit" -eq "$expected_exit" ]] && exit_ok=true + if echo "$stderr_output" | grep -qiE "$expected_pattern" 2>/dev/null; then + pattern_ok=true + fi + + if [[ "$exit_ok" == true && "$pattern_ok" == true ]]; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} $name ${T_BOLD}(exit=$actual_exit, pattern matched)${T_NC}" + elif [[ "$exit_ok" == true && "$pattern_ok" == false ]]; then + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + echo -e " ${T_RED}FAIL${T_NC} $name ${T_BOLD}(exit OK, but stderr missing pattern: '$expected_pattern')${T_NC}" + echo -e " stderr: $(echo "$stderr_output" | head -3)" + else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + echo -e " ${T_RED}FAIL${T_NC} $name ${T_BOLD}(expected exit=$expected_exit, got=$actual_exit)${T_NC}" + if [[ -n "$stderr_output" ]]; then + echo -e " stderr: $(echo "$stderr_output" | head -3)" + fi + fi +} + +# Check that stderr does NOT contain a pattern +run_test_without_stderr() { + local name="$1" expected_exit="$2" json="$3" cwd="$4" forbidden_pattern="$5" + + TOTAL=$((TOTAL + 1)) + + local actual_exit=0 + local stderr_output="" + stderr_output=$(echo "$json" | (cd "$cwd" && bash "$HOOK_SCRIPT") 2>&1 >/dev/null) || actual_exit=$? + + local exit_ok=false pattern_absent=true + + [[ "$actual_exit" -eq "$expected_exit" ]] && exit_ok=true + if echo "$stderr_output" | grep -qiE "$forbidden_pattern" 2>/dev/null; then + pattern_absent=false + fi + + if [[ "$exit_ok" == true && "$pattern_absent" == true ]]; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} $name ${T_BOLD}(exit=$actual_exit, no forbidden output)${T_NC}" + elif [[ "$exit_ok" == true && "$pattern_absent" == false ]]; then + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + echo -e " ${T_RED}FAIL${T_NC} $name ${T_BOLD}(exit OK, but stderr contains forbidden: '$forbidden_pattern')${T_NC}" + echo -e " stderr: $(echo "$stderr_output" | head -3)" + else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + echo -e " ${T_RED}FAIL${T_NC} $name ${T_BOLD}(expected exit=$expected_exit, got=$actual_exit)${T_NC}" + fi +} + +skip() { + local name="$1" reason="${2:-Skipped}" + TOTAL=$((TOTAL + 1)) + SKIP=$((SKIP + 1)) + echo -e " ${T_YELLOW}SKIP${T_NC} $name ($reason)" +} + +# ============================================================================ +# Test Groups +# ============================================================================ + +echo "" +echo -e "${T_BOLD}Branch Guard E2E Dogfooding Tests${T_NC}" +echo -e "${T_BOLD}=================================${T_NC}" +echo -e "Hook: $HOOK_SCRIPT" +echo "" + +# -------------------------------------------------------------------------- +# Group 1: Full workflow โ€” dev -> feature worktree -> back to dev +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Group 1: Full Workflow (dev -> worktree -> dev) ---${T_NC}" + +REPO_WF=$(init_repo) + +# Step 1: On dev, new code is blocked +switch_branch "$REPO_WF" "dev" + +run_test \ + "e2e_workflow_step1_dev_blocks_new_code" \ + 2 \ + "$(json_write "$REPO_WF/src/feature.py" "$REPO_WF")" \ + "$REPO_WF" + +# Step 2: Create feature branch, new code is allowed +create_and_switch "$REPO_WF" "feature/new-thing" + +run_test \ + "e2e_workflow_step2_feature_allows_new_code" \ + 0 \ + "$(json_write "$REPO_WF/src/feature.py" "$REPO_WF")" \ + "$REPO_WF" + +# Step 3: Switch back to dev, blocked again +switch_branch "$REPO_WF" "dev" + +run_test \ + "e2e_workflow_step3_back_to_dev_blocked_again" \ + 2 \ + "$(json_write "$REPO_WF/src/feature.py" "$REPO_WF")" \ + "$REPO_WF" + +echo "" + +# -------------------------------------------------------------------------- +# Group 2: Bypass lifecycle +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Group 2: Bypass Lifecycle ---${T_NC}" + +REPO_BP=$(init_repo) +switch_branch "$REPO_BP" "dev" +MARKER_FILE="$REPO_BP/.claude/allow-dev-edit" + +# Step 1: Blocked without marker +run_test \ + "e2e_bypass_step1_blocked_without_marker" \ + 2 \ + "$(json_write "$REPO_BP/src/new.py" "$REPO_BP")" \ + "$REPO_BP" + +# Step 2: Create marker with JSON content -> allowed +mkdir -p "$REPO_BP/.claude" +cat > "$MARKER_FILE" <<'EOF' +{"reason":"testing bypass","timestamp":"2026-02-06T12:00:00Z","branch":"dev"} +EOF + +run_test \ + "e2e_bypass_step2_allowed_with_marker" \ + 0 \ + "$(json_write "$REPO_BP/src/new.py" "$REPO_BP")" \ + "$REPO_BP" + +# Step 3: Verify marker has valid JSON content +TOTAL=$((TOTAL + 1)) +if jq -e . "$MARKER_FILE" &>/dev/null; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} e2e_bypass_step3_marker_valid_json ${T_BOLD}(valid JSON)${T_NC}" +elif python3 -c "import json; json.load(open('$MARKER_FILE'))" &>/dev/null; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} e2e_bypass_step3_marker_valid_json ${T_BOLD}(valid JSON via python3)${T_NC}" +else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("e2e_bypass_step3_marker_valid_json") + echo -e " ${T_RED}FAIL${T_NC} e2e_bypass_step3_marker_valid_json ${T_BOLD}(marker not valid JSON)${T_NC}" +fi + +# Step 4: Remove marker -> blocked again +rm -f "$MARKER_FILE" + +run_test \ + "e2e_bypass_step4_blocked_after_removal" \ + 2 \ + "$(json_write "$REPO_BP/src/new.py" "$REPO_BP")" \ + "$REPO_BP" + +echo "" + +# -------------------------------------------------------------------------- +# Group 3: Config cascade +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Group 3: Config Cascade ---${T_NC}" + +# Test 3a: No config (auto-detect) โ€” main=block-all, dev=block-new-code +REPO_NC=$(init_repo) +# Ensure no config exists +rm -f "$REPO_NC/.claude/branch-guard.json" + +switch_branch "$REPO_NC" "main" +run_test \ + "e2e_config_no_config_main_blocked" \ + 2 \ + "$(json_edit "$REPO_NC/README.md" "$REPO_NC")" \ + "$REPO_NC" + +switch_branch "$REPO_NC" "dev" +run_test \ + "e2e_config_no_config_dev_blocks_new" \ + 2 \ + "$(json_write "$REPO_NC/src/new.py" "$REPO_NC")" \ + "$REPO_NC" + +# Test 3b: Custom config overrides auto-detect +# Config says staging=block-all but NOT main +REPO_CC=$(init_repo) +create_and_switch "$REPO_CC" "staging" + +mkdir -p "$REPO_CC/.claude" +cat > "$REPO_CC/.claude/branch-guard.json" <<'EOF' +{"staging": "block-all"} +EOF + +# staging is blocked via config +run_test \ + "e2e_config_custom_staging_blocked" \ + 2 \ + "$(json_edit "$REPO_CC/README.md" "$REPO_CC")" \ + "$REPO_CC" + +# main with custom config โ€” config is explicit, main not listed means allowed +switch_branch "$REPO_CC" "main" +run_test \ + "e2e_config_custom_main_not_listed_allowed" \ + 0 \ + "$(json_edit "$REPO_CC/README.md" "$REPO_CC")" \ + "$REPO_CC" + +# Test 3c: Malformed config falls through to auto-detect +REPO_BAD=$(init_repo) +mkdir -p "$REPO_BAD/.claude" +echo "this is not json at all {{{" > "$REPO_BAD/.claude/branch-guard.json" + +switch_branch "$REPO_BAD" "main" +# With malformed config, jq validation fails -> falls through to auto-detect +# Auto-detect on main = block-all -> exit 2 +run_test \ + "e2e_config_malformed_fallthrough" \ + 2 \ + "$(json_edit "$REPO_BAD/README.md" "$REPO_BAD")" \ + "$REPO_BAD" + +echo "" + +# -------------------------------------------------------------------------- +# Group 4: Error messages +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Group 4: Error Messages ---${T_NC}" + +REPO_MSG=$(init_repo) +switch_branch "$REPO_MSG" "main" + +# Block message contains "BRANCH PROTECTION" +run_test_with_stderr \ + "e2e_errmsg_contains_branch_protection" \ + 2 \ + "$(json_edit "$REPO_MSG/src/app.py" "$REPO_MSG")" \ + "$REPO_MSG" \ + "BRANCH PROTECTION" + +# Block message includes file path +run_test_with_stderr \ + "e2e_errmsg_includes_file_path" \ + 2 \ + "$(json_edit "$REPO_MSG/src/app.py" "$REPO_MSG")" \ + "$REPO_MSG" \ + "src/app.py" + +# Block message includes branch name +run_test_with_stderr \ + "e2e_errmsg_includes_branch_name" \ + 2 \ + "$(json_edit "$REPO_MSG/src/app.py" "$REPO_MSG")" \ + "$REPO_MSG" \ + "main" + +# Dev block message includes options/suggestions +switch_branch "$REPO_MSG" "dev" +run_test_with_stderr \ + "e2e_errmsg_dev_includes_options" \ + 2 \ + "$(json_write "$REPO_MSG/src/new.py" "$REPO_MSG")" \ + "$REPO_MSG" \ + "worktree|unprotect|Options" + +echo "" + +# -------------------------------------------------------------------------- +# Group 5: Cross-tool consistency +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Group 5: Cross-Tool Consistency ---${T_NC}" + +REPO_CT=$(init_repo) + +# Both Edit and Write blocked on main for same file +switch_branch "$REPO_CT" "main" + +run_test \ + "e2e_cross_tool_edit_blocked_on_main" \ + 2 \ + "$(json_edit "$REPO_CT/src/app.py" "$REPO_CT")" \ + "$REPO_CT" + +run_test \ + "e2e_cross_tool_write_blocked_on_main" \ + 2 \ + "$(json_write "$REPO_CT/src/app.py" "$REPO_CT")" \ + "$REPO_CT" + +# On dev: Edit allowed (existing file) but Write new file blocked +switch_branch "$REPO_CT" "dev" + +run_test \ + "e2e_cross_tool_edit_allowed_on_dev" \ + 0 \ + "$(json_edit "$REPO_CT/src/app.py" "$REPO_CT")" \ + "$REPO_CT" + +run_test \ + "e2e_cross_tool_write_new_blocked_on_dev" \ + 2 \ + "$(json_write "$REPO_CT/src/brand_new.py" "$REPO_CT")" \ + "$REPO_CT" + +echo "" + +# -------------------------------------------------------------------------- +# Group 6: Dry-run -> enforcement +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Group 6: Dry-Run -> Enforcement ---${T_NC}" + +REPO_DR=$(init_repo) +switch_branch "$REPO_DR" "main" + +# Step 1: Enable dry-run โ€” action allowed but logged +mkdir -p "$REPO_DR/.claude" +touch "$REPO_DR/.claude/branch-guard-dryrun" + +run_test_with_stderr \ + "e2e_dryrun_allows_with_log" \ + 0 \ + "$(json_edit "$REPO_DR/src/app.py" "$REPO_DR")" \ + "$REPO_DR" \ + "DRY.RUN|would block" + +# Step 2: Verify stderr contains [DRY-RUN] prefix +TOTAL=$((TOTAL + 1)) +DR_STDERR="" +DR_EXIT=0 +DR_STDERR=$(echo "$(json_edit "$REPO_DR/src/app.py" "$REPO_DR")" | (cd "$REPO_DR" && bash "$HOOK_SCRIPT") 2>&1 >/dev/null) || DR_EXIT=$? + +if echo "$DR_STDERR" | grep -q '\[DRY-RUN\]'; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} e2e_dryrun_stderr_has_prefix ${T_BOLD}(found [DRY-RUN])${T_NC}" +else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("e2e_dryrun_stderr_has_prefix") + echo -e " ${T_RED}FAIL${T_NC} e2e_dryrun_stderr_has_prefix ${T_BOLD}(missing [DRY-RUN] prefix)${T_NC}" + echo -e " stderr: $DR_STDERR" +fi + +# Step 3: Remove dry-run -> blocks +rm -f "$REPO_DR/.claude/branch-guard-dryrun" + +run_test \ + "e2e_dryrun_removed_blocks" \ + 2 \ + "$(json_edit "$REPO_DR/src/app.py" "$REPO_DR")" \ + "$REPO_DR" + +echo "" + +# -------------------------------------------------------------------------- +# Group 7: Performance dogfooding +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Group 7: Performance Dogfooding ---${T_NC}" + +REPO_PERF=$(init_repo) +switch_branch "$REPO_PERF" "dev" + +# Test: 50 invocations complete in < 5s (avg < 100ms each) +TOTAL=$((TOTAL + 1)) +PERF_START=$(date +%s%N 2>/dev/null || python3 -c "import time; print(int(time.time()*1e9))") +PERF_JSON="$(json_write "$REPO_PERF/docs/note.md" "$REPO_PERF")" + +for _ in $(seq 1 50); do + echo "$PERF_JSON" | (cd "$REPO_PERF" && bash "$HOOK_SCRIPT") >/dev/null 2>&1 || true +done + +PERF_END=$(date +%s%N 2>/dev/null || python3 -c "import time; print(int(time.time()*1e9))") +PERF_MS=$(( (PERF_END - PERF_START) / 1000000 )) + +if [[ "$PERF_MS" -lt 5000 ]]; then + PASS=$((PASS + 1)) + local_avg=$((PERF_MS / 50)) + echo -e " ${T_GREEN}PASS${T_NC} e2e_perf_50_invocations ${T_BOLD}(${PERF_MS}ms total, ~${local_avg}ms avg)${T_NC}" +else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("e2e_perf_50_invocations") + echo -e " ${T_RED}FAIL${T_NC} e2e_perf_50_invocations ${T_BOLD}(${PERF_MS}ms > 5000ms budget)${T_NC}" +fi + +# Test: No temp files leaked after invocations +TOTAL=$((TOTAL + 1)) +LEAKED_FILES=$(find "${TMPDIR:-/tmp}" -maxdepth 1 -name "branch-guard-*" -newer "$REPO_PERF/README.md" 2>/dev/null | grep -v "branch-guard-e2e" | wc -l | tr -d ' ') + +if [[ "$LEAKED_FILES" -eq 0 ]]; then + PASS=$((PASS + 1)) + echo -e " ${T_GREEN}PASS${T_NC} e2e_perf_no_temp_file_leak ${T_BOLD}(0 leaked files)${T_NC}" +else + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("e2e_perf_no_temp_file_leak") + echo -e " ${T_RED}FAIL${T_NC} e2e_perf_no_temp_file_leak ${T_BOLD}($LEAKED_FILES leaked files)${T_NC}" +fi + +# Test: No stderr on allow (clean pass-through) +ALLOW_JSON="$(json_write "$REPO_PERF/docs/note.md" "$REPO_PERF")" +run_test_without_stderr \ + "e2e_perf_no_stderr_on_allow" \ + 0 \ + "$ALLOW_JSON" \ + "$REPO_PERF" \ + "BRANCH PROTECTION|ERROR|WARNING" + +echo "" + +# -------------------------------------------------------------------------- +# Group 8: Real-world scenarios +# -------------------------------------------------------------------------- + +echo -e "${T_BLUE}--- Group 8: Real-World Scenarios ---${T_NC}" + +REPO_RW=$(init_repo) +switch_branch "$REPO_RW" "dev" + +# Scenario 1: CLAUDE.md edit on dev (allowed โ€” markdown) +run_test \ + "e2e_realworld_claudemd_edit_on_dev" \ + 0 \ + "$(json_write "$REPO_RW/CLAUDE.md" "$REPO_RW")" \ + "$REPO_RW" + +# Scenario 2: New utils/foo.py on dev (blocked โ€” new code file) +run_test \ + "e2e_realworld_new_utils_py_on_dev" \ + 2 \ + "$(json_write "$REPO_RW/utils/foo.py" "$REPO_RW")" \ + "$REPO_RW" + +# Scenario 3: tests/test_new.py on dev (allowed โ€” tests directory) +run_test \ + "e2e_realworld_test_file_on_dev" \ + 0 \ + "$(json_write "$REPO_RW/tests/test_new.py" "$REPO_RW")" \ + "$REPO_RW" + +# Scenario 4: .STATUS on dev (allowed โ€” extension-less) +run_test \ + "e2e_realworld_status_file_on_dev" \ + 0 \ + "$(json_write "$REPO_RW/.STATUS" "$REPO_RW")" \ + "$REPO_RW" + +# Scenario 5: git push --force-with-lease on dev (blocked) +run_test \ + "e2e_realworld_force_push_with_lease_on_dev" \ + 2 \ + "$(json_bash "git push --force-with-lease origin dev" "$REPO_RW")" \ + "$REPO_RW" + +echo "" + +# ============================================================================ +# Summary +# ============================================================================ + +echo -e "${T_BOLD}===============================${T_NC}" +echo -e "${T_BOLD} Branch Guard E2E Summary${T_NC}" +echo -e "${T_BOLD}===============================${T_NC}" +echo "" +echo -e " Total: ${T_BOLD}$TOTAL${T_NC}" +echo -e " Passed: ${T_GREEN}$PASS${T_NC}" +echo -e " Failed: ${T_RED}$FAIL${T_NC}" +echo -e " Skipped: ${T_YELLOW}$SKIP${T_NC}" +echo "" + +if [[ $FAIL -gt 0 ]]; then + echo -e "${T_RED}Failed tests:${T_NC}" + for name in "${FAILED_NAMES[@]}"; do + echo -e " ${T_RED}-${T_NC} $name" + done + echo "" + echo -e "${T_RED}RESULT: FAIL${T_NC}" + exit 1 +else + echo -e "${T_GREEN}RESULT: ALL TESTS PASSED${T_NC}" + exit 0 +fi diff --git a/tests/test_branch_guard_interactive.sh b/tests/test_branch_guard_interactive.sh new file mode 100755 index 00000000..7ed901e8 --- /dev/null +++ b/tests/test_branch_guard_interactive.sh @@ -0,0 +1,248 @@ +#!/bin/bash +# Interactive Dogfooding Tests for: branch-guard.sh +# Generated: 2026-02-06 +# Run: bash tests/test_branch_guard_interactive.sh +# +# Human-guided QA tests that validate user-facing behavior of the +# branch protection hook. Each test runs a command, shows expected +# vs actual, and prompts the human for pass/fail. +# +# These tests cover aspects that can't be fully automated: +# - Error message readability and formatting +# - Integration with craft commands +# - Perceived responsiveness + +set -euo pipefail + +# ============================================ +# Configuration +# ============================================ + +HOOK_SCRIPT="$HOME/.claude/hooks/branch-guard.sh" +PASS=0 +FAIL=0 +TOTAL=0 +TOTAL_TESTS=10 + +# Logging +LOG_DIR="${LOG_DIR:-tests/cli/logs}" +mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR="/tmp" +LOG_FILE="$LOG_DIR/branch-guard-interactive-$(date +%Y%m%d-%H%M%S).log" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" +} + +log "=== Branch Guard Interactive Test Session Started ===" +log "Working directory: $(pwd)" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# ============================================ +# Helpers +# ============================================ + +print_header() { + echo -e "${BOLD}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + echo -e "${BOLD} BRANCH GUARD INTERACTIVE TESTS ($TOTAL_TESTS tests)${NC}" + echo -e "${BOLD}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + echo -e " ${BLUE}Keys:${NC} y=pass, n=fail, s=skip, q=quit" + echo -e " ${BLUE}Log:${NC} $LOG_FILE" + echo -e " ${BLUE}Hook:${NC} $HOOK_SCRIPT" + echo "" +} + +run_test() { + local test_num=$1 + local test_name=$2 + local command=$3 + local expected=$4 + + TOTAL=$((TOTAL + 1)) + + echo "" + echo -e "${CYAN}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" + echo -e "${BOLD}TEST $test_num/$TOTAL_TESTS: $test_name${NC}" + echo -e "${CYAN}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" + echo -e " ${BLUE}Command:${NC} $command" + echo "" + + log "TEST $test_num: $test_name" + log " Command: $command" + + local output + output=$(bash -c "$command" 2>&1) || true + log " Output: $output" + + echo -e "${BLUE}EXPECTED:${NC} $expected" + echo "" + echo -e "${GREEN}ACTUAL:${NC}" + echo "$output" + echo "" + + read -p "[y=pass, n=fail, s=skip, q=quit] " -n 1 -r + echo "" + + case "$REPLY" in + [Yy]) + PASS=$((PASS + 1)) + log " Result: PASS" + echo -e "${GREEN}PASS${NC}" + ;; + [Ss]) + log " Result: SKIP" + echo -e "${YELLOW}SKIP${NC}" + ;; + [Qq]) + log "User quit at test $test_num" + echo -e "${YELLOW}Exiting...${NC}" + print_summary + exit 0 + ;; + *) + FAIL=$((FAIL + 1)) + log " Result: FAIL" + echo -e "${RED}FAIL${NC}" + read -p " Notes (optional): " notes + [[ -n "$notes" ]] && log " Notes: $notes" + ;; + esac +} + +print_summary() { + echo "" + echo -e "${BOLD}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + echo -e "${BOLD} RESULTS: $PASS passed, $FAIL failed (of $TOTAL run)${NC}" + echo -e "${BOLD}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + + if [[ $FAIL -eq 0 ]]; then + echo -e "${GREEN}${BOLD}ALL TESTS PASSED!${NC}" + log "Final: ALL TESTS PASSED ($PASS/$TOTAL)" + else + echo -e "${RED}${BOLD}$FAIL TEST(S) FAILED${NC}" + log "Final: $FAIL TESTS FAILED" + fi + + log "Summary: $PASS passed, $FAIL failed" + echo -e "Log: ${BLUE}$LOG_FILE${NC}" + echo "" +} + +# ============================================ +# Preflight +# ============================================ + +if [[ ! -f "$HOOK_SCRIPT" ]]; then + echo -e "${RED}ERROR${NC}: Hook not found at $HOOK_SCRIPT" + exit 1 +fi + +# ============================================ +# Main +# ============================================ + +print_header + +# ============================================ +# TEST 1: Hook registration in settings.json +# ============================================ + +run_test 1 "Hook registered in settings.json" \ + "grep -l 'branch-guard' ~/.claude/settings.json ~/.claude/settings.local.json 2>/dev/null || echo 'Not found in settings files'" \ + "Should show file path where branch-guard hook is registered" + +# ============================================ +# TEST 2: Error message readability (main block) +# ============================================ + +run_test 2 "Main branch block message is readable" \ + "echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"src/app.py\"},\"cwd\":\"'$(pwd)'\"}' | bash '$HOOK_SCRIPT' 2>&1 || true" \ + "Clear error with: BRANCH PROTECTION header, file path, branch name, actionable suggestion (checkout dev)" + +# ============================================ +# TEST 3: Dev branch block message is readable +# ============================================ + +TMPDIR_T3=$(mktemp -d) +(cd "$TMPDIR_T3" && git init -b main --quiet && git config user.email "t@t" && git config user.name "T" && echo x > f && git add -A && git commit -m init --quiet && git branch dev && git checkout dev --quiet 2>/dev/null) + +run_test 3 "Dev branch block message is readable" \ + "echo '{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"src/new.py\",\"content\":\"x\"},\"cwd\":\"$TMPDIR_T3\"}' | (cd '$TMPDIR_T3' && bash '$HOOK_SCRIPT') 2>&1 || true" \ + "Clear error with: BRANCH PROTECTION, file path, options (worktree, edit existing, /craft:git:unprotect)" + +rm -rf "$TMPDIR_T3" + +# ============================================ +# TEST 4: Bypass marker from /craft:git:unprotect +# ============================================ + +run_test 4 "Bypass marker format check" \ + "if [[ -f .claude/allow-dev-edit ]]; then cat .claude/allow-dev-edit; else echo 'No bypass marker active (expected if not on dev with unprotect)'; fi" \ + "Either valid JSON with reason/timestamp/branch, or message that no marker exists" + +# ============================================ +# TEST 5: /craft:git:status shows Guard line +# ============================================ + +run_test 5 "Git status command mentions guard" \ + "test -f commands/git/status.md && grep -i 'guard\|protect' commands/git/status.md | head -5 || echo 'status.md not found'" \ + "status.md should reference branch protection / guard indicator" + +# ============================================ +# TEST 6: /craft:check shows branch context +# ============================================ + +run_test 6 "Check command references branch protection" \ + "test -f commands/check.md && grep -i 'branch\|guard\|protect' commands/check.md | head -5 || echo 'check.md not found'" \ + "check.md should reference branch protection context" + +# ============================================ +# TEST 7: /craft:git:worktree has main protection +# ============================================ + +run_test 7 "Worktree command has main branch check" \ + "test -f commands/git/worktree.md && grep -i 'main\|protect\|refuse\|block' commands/git/worktree.md | head -5 || echo 'worktree.md not found'" \ + "worktree.md should mention refusing to create from main" + +# ============================================ +# TEST 8: Dry-run logging visible on stderr +# ============================================ + +TMPDIR_T8=$(mktemp -d) +(cd "$TMPDIR_T8" && git init -b main --quiet && git config user.email "t@t" && git config user.name "T" && echo x > f && git add -A && git commit -m init --quiet && mkdir -p .claude && touch .claude/branch-guard-dryrun) + +run_test 8 "Dry-run mode stderr output" \ + "echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"src/app.py\"},\"cwd\":\"$TMPDIR_T8\"}' | (cd '$TMPDIR_T8' && bash '$HOOK_SCRIPT') 2>&1; echo 'exit: '\$?" \ + "Should show [DRY-RUN] prefix in output and exit 0 (not 2)" + +rm -rf "$TMPDIR_T8" + +# ============================================ +# TEST 9: Performance feels instant +# ============================================ + +run_test 9 "Hook performance feels instant" \ + "time (for i in \$(seq 1 10); do echo '{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"x.md\",\"content\":\"x\"},\"cwd\":\"'$(pwd)'\"}' | bash '$HOOK_SCRIPT' >/dev/null 2>&1 || true; done) 2>&1" \ + "10 invocations should complete in under 1 second (< 100ms each)" + +# ============================================ +# TEST 10: Config file format +# ============================================ + +run_test 10 "branch-guard.json config format" \ + "if [[ -f .claude/branch-guard.json ]]; then cat .claude/branch-guard.json; else echo 'No branch-guard.json found'; fi" \ + "JSON with branch names as keys, protection levels as values (block-all, block-new-code)" + +# ============================================ +# Summary +# ============================================ + +log "=== Session Completed ===" +print_summary diff --git a/tests/test_integration_branch_guard.py b/tests/test_integration_branch_guard.py new file mode 100644 index 00000000..fc1640e4 --- /dev/null +++ b/tests/test_integration_branch_guard.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +Integration Tests: Branch Guard Hook +====================================== +Tests the full branch-guard.sh PreToolUse hook end-to-end +including protection, bypass, config loading, and auto-detection. + +Hook under test: ~/.claude/hooks/branch-guard.sh +Protocol: reads JSON from stdin, exits 0 (allow) or 2 (block) + +Run with: python3 tests/test_integration_branch_guard.py +""" + +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +# Hook path +HOOK_PATH = os.path.expanduser("~/.claude/hooks/branch-guard.sh") + + +def _run_hook(json_payload: dict, timeout: int = 10) -> subprocess.CompletedProcess: + """Pipe JSON to the branch-guard hook and return the result.""" + return subprocess.run( + ["bash", HOOK_PATH], + input=json.dumps(json_payload), + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _init_repo(path: str, branches: list[str] | None = None) -> None: + """Initialize a git repo at path with main branch and optional extras. + + Creates an initial commit so branches can be created. + """ + subprocess.run( + ["git", "init", "-b", "main", path], + capture_output=True, check=True, + ) + # Initial commit so branches work + readme = os.path.join(path, "README.md") + with open(readme, "w") as f: + f.write("# Test repo\n") + subprocess.run( + ["git", "-C", path, "add", "."], + capture_output=True, check=True, + ) + subprocess.run( + ["git", "-C", path, "commit", "-m", "Initial commit"], + capture_output=True, check=True, + env={**os.environ, "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t"}, + ) + for branch in (branches or []): + subprocess.run( + ["git", "-C", path, "branch", branch], + capture_output=True, check=True, + ) + + +def _checkout(path: str, branch: str) -> None: + """Switch to branch in the repo at path.""" + subprocess.run( + ["git", "-C", path, "checkout", branch], + capture_output=True, check=True, + ) + + +def _make_write_json(file_path: str, cwd: str) -> dict: + """Build a Write tool JSON payload for a new .py file.""" + return { + "tool_name": "Write", + "tool_input": { + "file_path": file_path, + "content": "# new code\n", + }, + "cwd": cwd, + } + + +def _make_edit_json(file_path: str, cwd: str) -> dict: + """Build an Edit tool JSON payload.""" + return { + "tool_name": "Edit", + "tool_input": { + "file_path": file_path, + "old_string": "x", + "new_string": "y", + }, + "cwd": cwd, + } + + +@unittest.skipUnless(os.path.isfile(HOOK_PATH), f"Hook not found: {HOOK_PATH}") +class TestBranchGuardFullWorkflow(unittest.TestCase): + """Test the full protection workflow: dev blocks new code, feature allows it.""" + + def setUp(self): + self.repo = tempfile.mkdtemp(prefix="bg-workflow-") + _init_repo(self.repo, branches=["dev"]) + + def tearDown(self): + shutil.rmtree(self.repo, ignore_errors=True) + + def test_dev_new_code_blocked_then_worktree_allowed(self): + """New .py on dev is blocked; same file on feature branch is allowed.""" + payload = _make_write_json("src/feature.py", self.repo) + + # On dev: should be blocked + _checkout(self.repo, "dev") + result = _run_hook(payload) + self.assertEqual(result.returncode, 2, "New .py should be blocked on dev") + self.assertIn("BRANCH PROTECTION", result.stderr) + + # Create a feature branch and switch to it + subprocess.run( + ["git", "-C", self.repo, "checkout", "-b", "feature/new-thing"], + capture_output=True, check=True, + ) + + # On feature branch: should be allowed + result = _run_hook(payload) + self.assertEqual(result.returncode, 0, "New .py should be allowed on feature branch") + + +@unittest.skipUnless(os.path.isfile(HOOK_PATH), f"Hook not found: {HOOK_PATH}") +class TestBranchGuardBypassFlow(unittest.TestCase): + """Test the .claude/allow-dev-edit bypass marker.""" + + def setUp(self): + self.repo = tempfile.mkdtemp(prefix="bg-bypass-") + _init_repo(self.repo, branches=["dev"]) + _checkout(self.repo, "dev") + + def tearDown(self): + shutil.rmtree(self.repo, ignore_errors=True) + + def test_bypass_enables_and_disables(self): + """Bypass marker toggles protection on and off.""" + payload = _make_write_json("lib/new_module.py", self.repo) + marker = os.path.join(self.repo, ".claude", "allow-dev-edit") + + # Step 1: blocked by default + result = _run_hook(payload) + self.assertEqual(result.returncode, 2, "Should be blocked without bypass marker") + + # Step 2: create bypass marker -> allowed + os.makedirs(os.path.dirname(marker), exist_ok=True) + Path(marker).touch() + result = _run_hook(payload) + self.assertEqual(result.returncode, 0, "Should be allowed with bypass marker") + + # Step 3: remove marker -> blocked again + os.remove(marker) + result = _run_hook(payload) + self.assertEqual(result.returncode, 2, "Should be blocked after marker removal") + + +@unittest.skipUnless(os.path.isfile(HOOK_PATH), f"Hook not found: {HOOK_PATH}") +class TestBranchGuardConfigLoading(unittest.TestCase): + """Test custom .claude/branch-guard.json config loading.""" + + def setUp(self): + self.repo = tempfile.mkdtemp(prefix="bg-config-") + _init_repo(self.repo, branches=["production", "staging"]) + # Write custom config + config_dir = os.path.join(self.repo, ".claude") + os.makedirs(config_dir, exist_ok=True) + # The hook reads branch names as top-level keys using jq: + # _json_get '."production"' reads the protection level directly. + # Write branch name as top-level key to match the hook's config format. + config_file = os.path.join(config_dir, "branch-guard.json") + with open(config_file, "w") as f: + json.dump({"production": "block-all"}, f) + + def tearDown(self): + shutil.rmtree(self.repo, ignore_errors=True) + + def test_custom_config_production_blocked(self): + """Production branch with block-all config rejects edits.""" + _checkout(self.repo, "production") + payload = _make_edit_json("README.md", self.repo) + result = _run_hook(payload) + self.assertEqual(result.returncode, 2, "Edit should be blocked on production (block-all)") + self.assertIn("BRANCH PROTECTION", result.stderr) + + def test_custom_config_unlisted_branch_allowed(self): + """Branch not listed in config is unprotected.""" + _checkout(self.repo, "staging") + payload = _make_edit_json("README.md", self.repo) + result = _run_hook(payload) + self.assertEqual(result.returncode, 0, "Edit should be allowed on staging (not in config)") + + +@unittest.skipUnless(os.path.isfile(HOOK_PATH), f"Hook not found: {HOOK_PATH}") +class TestBranchGuardAutoDetect(unittest.TestCase): + """Test auto-detection of protection rules based on repo structure.""" + + def setUp(self): + self.repos = [] + + def tearDown(self): + for repo in self.repos: + shutil.rmtree(repo, ignore_errors=True) + + def _make_repo(self, branches=None): + repo = tempfile.mkdtemp(prefix="bg-autodetect-") + _init_repo(repo, branches=branches) + self.repos.append(repo) + return repo + + def test_repo_with_dev_protects_both(self): + """Repo with dev branch: main=block-all, dev=block-new-code.""" + repo = self._make_repo(branches=["dev"]) + + # On main: Edit is blocked (block-all) + _checkout(repo, "main") + edit_payload = _make_edit_json("README.md", repo) + result = _run_hook(edit_payload) + self.assertEqual(result.returncode, 2, "Edit on main should be blocked (block-all)") + + # On dev: Write new .py is blocked (block-new-code) + _checkout(repo, "dev") + write_payload = _make_write_json("app/server.py", repo) + result = _run_hook(write_payload) + self.assertEqual(result.returncode, 2, "New .py on dev should be blocked (block-new-code)") + + def test_repo_without_dev_only_protects_main(self): + """Repo without dev branch: main=block-all, other branches unprotected.""" + repo = self._make_repo(branches=[]) + + # On main: Edit is blocked + _checkout(repo, "main") + edit_payload = _make_edit_json("README.md", repo) + result = _run_hook(edit_payload) + self.assertEqual(result.returncode, 2, "Edit on main should be blocked") + + # Create and switch to a working branch + subprocess.run( + ["git", "-C", repo, "checkout", "-b", "working"], + capture_output=True, check=True, + ) + + # On working: Write new .py is allowed + write_payload = _make_write_json("lib/utils.py", repo) + result = _run_hook(write_payload) + self.assertEqual(result.returncode, 0, "New .py on 'working' branch should be allowed") + + +if __name__ == "__main__": + unittest.main()