diff --git a/.claude/hooks/block-dangerous-git.sh b/.claude/hooks/block-dangerous-git.sh index c40b59c..f0fe5aa 100755 --- a/.claude/hooks/block-dangerous-git.sh +++ b/.claude/hooks/block-dangerous-git.sh @@ -1,25 +1,47 @@ #!/bin/bash +# Block genuinely destructive git operations. +# +# Design notes: +# - Matches the *command*, not the raw tool input. The previous version +# grepped the whole string, so prose in a heredoc that merely named a +# command was blocked (including this file's own replacement), while +# `git push` with two spaces or `git -C . push` slipped straight through. +# - Plain `git push` is not destructive and is no longer blocked. The +# repository's own pre-push hook is the policy-backed control; this hook +# only refuses operations that destroy history or uncommitted work. +# - Degrades safely when jq is unavailable. INPUT=$(cat) -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command') - -DANGEROUS_PATTERNS=( - "git push" - "git reset --hard" - "git clean -fd" - "git clean -f" - "git branch -D" - "git checkout \." - "git restore \." - "push --force" - "reset --hard" -) - -for pattern in "${DANGEROUS_PATTERNS[@]}"; do - if echo "$COMMAND" | grep -qE "$pattern"; then - echo "BLOCKED: '$COMMAND' matches dangerous pattern '$pattern'. The user has prevented you from doing this." >&2 - exit 2 - fi -done + +if command -v jq >/dev/null 2>&1; then + COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty') +else + COMMAND=$(printf '%s' "$INPUT" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p') +fi + +[ -z "$COMMAND" ] && exit 0 + +# Drop heredoc bodies, so documentation or test data that names a command is +# not mistaken for an invocation of it. A heredoc body spans lines, so this +# keeps the line that opens it and discards everything after. +SCRUBBED=$(printf '%s' "$COMMAND" | awk '/<&2 + exit 2 +} + +# --force rewrites remote history unconditionally; --force-with-lease is guarded. +if printf '%s' "$SCRUBBED" | grep -qE 'push([[:space:]]|$).*(--force([[:space:]]|$)|[[:space:]]-f([[:space:]]|$))'; then + printf '%s' "$SCRUBBED" | grep -q -- '--force-with-lease' || deny "an unguarded force-push" +fi + +printf '%s' "$SCRUBBED" | grep -qE 'reset[[:space:]]+--hard' && deny "a hard reset (discards working-tree changes)" +printf '%s' "$SCRUBBED" | grep -qE 'clean[[:space:]]+-[a-zA-Z]*f' && deny "a forced clean (deletes untracked files)" +printf '%s' "$SCRUBBED" | grep -qE 'branch[[:space:]]+-D([[:space:]]|$)' && deny "a forced branch delete" +printf '%s' "$SCRUBBED" | grep -qE '(checkout|restore)[[:space:]]+(--[[:space:]]+)?\.([[:space:]]|$)' && deny "a bulk discard of working-tree changes" exit 0 diff --git a/.github/workflows/cargo-release.yml b/.github/workflows/cargo-release.yml index 5413135..2e79572 100644 --- a/.github/workflows/cargo-release.yml +++ b/.github/workflows/cargo-release.yml @@ -20,7 +20,7 @@ jobs: draft: ${{ steps.release_type.outputs.draft }} changelog: ${{ steps.changelog.outputs.changelog }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 @@ -84,7 +84,7 @@ jobs: asset_suffix: macos-arm64 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get binary name and version shell: bash diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2adccc8..2aeee09 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: lint-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Rust uses: actions-rs/toolchain@v1.0.7 with: diff --git a/.github/workflows/native-git-verification.yml b/.github/workflows/native-git-verification.yml index 3710d50..cb36fe4 100644 --- a/.github/workflows/native-git-verification.yml +++ b/.github/workflows/native-git-verification.yml @@ -7,6 +7,6 @@ jobs: native-git-e2e: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Run native git Docker verification run: ./scripts/verify_native_git_e2e.sh diff --git a/docs/src/content/docs/project/roadmap.mdx b/docs/src/content/docs/project/roadmap.mdx deleted file mode 100644 index 1dafba3..0000000 --- a/docs/src/content/docs/project/roadmap.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Roadmap -description: Get an overview of the future of Committy ---- \ No newline at end of file diff --git a/src/cli/commands/hooks.rs b/src/cli/commands/hooks.rs index f634279..d906371 100644 --- a/src/cli/commands/hooks.rs +++ b/src/cli/commands/hooks.rs @@ -28,13 +28,21 @@ jobs: lint-commits: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: + # Full history: linting a PR needs both ends of the commit range. fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + - name: Cache the Committy build + uses: Swatinem/rust-cache@v2 + with: + key: committy-cli - name: Install Committy run: cargo install committy --locked - name: Lint pull request commits - run: committy --non-interactive lint --from-ref "${{ github.event.pull_request.base.sha }}" --to-ref "${{ github.sha }}" + env: + COMMITTY_NONINTERACTIVE: "1" + run: committy lint --from-ref "${{ github.event.pull_request.base.sha }}" --to-ref "${{ github.sha }}" "#; #[derive(Debug, StructOpt)] diff --git a/tests/hooks_cli_tests.rs b/tests/hooks_cli_tests.rs index 0041b8f..8bf981b 100644 --- a/tests/hooks_cli_tests.rs +++ b/tests/hooks_cli_tests.rs @@ -69,9 +69,23 @@ fn hooks_install_writes_executable_hooks_and_ci_workflow() { assert!(fs::read_to_string(&pre_push) .unwrap() .contains("hooks run pre-push")); - assert!(fs::read_to_string(workflow) - .unwrap() - .contains("committy --non-interactive lint")); + // The workflow must lint the PR range, and must do so without prompting. + // Non-interactive mode comes from COMMITTY_NONINTERACTIVE rather than the + // flag, which keeps the command immune to flag-placement mistakes. + let workflow_body = fs::read_to_string(workflow).unwrap(); + assert!( + workflow_body.contains("committy lint --from-ref"), + "workflow must lint the pull request commit range" + ); + assert!( + workflow_body.contains("COMMITTY_NONINTERACTIVE") + || workflow_body.contains("--non-interactive"), + "workflow must run non-interactively" + ); + assert!( + workflow_body.contains("fetch-depth: 0"), + "linting a range needs full history" + ); assert_ne!( fs::metadata(commit_msg).unwrap().permissions().mode() & 0o111, 0