diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7d14fe6..3b0f552 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -159,4 +159,4 @@ jobs: - env: FETCH_BASE_REF: "true" - run: ./ci/conventional_commits.sh + run: ./ci/validate_commit_range.sh diff --git a/app/usage-rules.md b/app/usage-rules.md index 6a814e5..6d31650 100644 --- a/app/usage-rules.md +++ b/app/usage-rules.md @@ -8,13 +8,12 @@ - Mark breaking changes with `!` before the colon (e.g. `feat!: ...`). - Bare `Merge branch ...` subjects are rejected — reword as `chore: Merge branch ...`. - Enforced locally by the `commit-msg` hook at `git-hooks/commit-msg` (each - commit's own subject, via its shared subject validator) and the `pre-push` - hook at `git-hooks/pre-push` (`mix precommit`, which validates - every commit about to be pushed and runs the dependency security audits, - formatting, static analysis, and tests) — run `mix setup` once per clone - to activate both. + commit's own subject, via `ci/validate_conventional_subject.sh`) and the + `pre-push` hook at `git-hooks/pre-push` (every non-deletion ref update, + via `ci/validate_push_refs.sh`) — run `mix setup` once per clone to + activate both. - Enforced in CI across a whole PR's commit range by - `ci/conventional_commits.sh` (skips GitHub's own auto-generated + `ci/validate_commit_range.sh` (skips GitHub's own auto-generated update-branch merge commits). ## Dogfooding: use `lc`, not a Linear MCP server or skill diff --git a/ci/conventional_commits.sh b/ci/conventional_commits.sh deleted file mode 100755 index e9394f7..0000000 --- a/ci/conventional_commits.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Compatibility wrapper — the implementation has moved to git-hooks/validate-commit-range. -script_dir=$(cd "$(dirname "$0")" && pwd) -exec "$script_dir/../git-hooks/validate-commit-range" "$@" diff --git a/git-hooks/validate-commit-range b/ci/validate_commit_range.sh similarity index 98% rename from git-hooks/validate-commit-range rename to ci/validate_commit_range.sh index 40432cf..523ee3e 100755 --- a/git-hooks/validate-commit-range +++ b/ci/validate_commit_range.sh @@ -33,7 +33,7 @@ git_or_die() { } repo_top=$(git_or_die rev-parse --show-toplevel) -validator="$repo_top/git-hooks/validate-conventional-subject" +validator="$repo_top/ci/validate_conventional_subject.sh" [ -x "$validator" ] || die "validator is not executable: $validator" diff --git a/ci/validate_conventional_commit.sh b/ci/validate_conventional_commit.sh deleted file mode 100755 index 158528b..0000000 --- a/ci/validate_conventional_commit.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Compatibility wrapper — the implementation has moved to git-hooks/validate-conventional-subject. -script_dir=$(cd "$(dirname "$0")" && pwd) -exec "$script_dir/../git-hooks/validate-conventional-subject" "$@" diff --git a/git-hooks/validate-conventional-subject b/ci/validate_conventional_subject.sh similarity index 100% rename from git-hooks/validate-conventional-subject rename to ci/validate_conventional_subject.sh diff --git a/git-hooks/validate-pull-request-title b/ci/validate_pull_request_title.sh similarity index 93% rename from git-hooks/validate-pull-request-title rename to ci/validate_pull_request_title.sh index abf6917..6d7f4fc 100755 --- a/git-hooks/validate-pull-request-title +++ b/ci/validate_pull_request_title.sh @@ -22,7 +22,7 @@ then fi repo_top=$(git rev-parse --show-toplevel) || exit 1 -validator="$repo_top/git-hooks/validate-conventional-subject" +validator="$repo_top/ci/validate_conventional_subject.sh" if [ ! -x "$validator" ] then diff --git a/ci/validate_push_refs.sh b/ci/validate_push_refs.sh new file mode 100755 index 0000000..34cd2ff --- /dev/null +++ b/ci/validate_push_refs.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Validates commit subjects for every non-deletion ref update received on +# Git's pre-push stdin. Each input line has the format: +# +# +# Deletions (local SHA is all zeros) are skipped. For new branches (remote +# SHA is all zeros) the base is the merge base with origin/main or main. +# For existing remote branches the exact remote SHA is used as the base. +# +# Activated automatically by git-hooks/pre-push. May also be invoked +# directly for testing or manual audits with push-ref lines on stdin. + +repo_top=$(git rev-parse --show-toplevel) || exit 1 +validator="$repo_top/ci/validate_conventional_subject.sh" + +if [ ! -x "$validator" ] +then + printf "ERROR: validator not executable: %s\n" "$validator" >&2 + exit 1 +fi + +zero_sha="0000000000000000000000000000000000000000" +github_merge_pattern="^Merge branch '[^']+' into .+" +validation_status=0 + +while read -r local_ref local_sha remote_ref remote_sha +do + [ "$local_sha" = "$zero_sha" ] && continue + + if [ "$remote_sha" = "$zero_sha" ] + then + base_ref="" + for candidate in origin/main main + do + if git rev-parse --verify --quiet "$candidate" >/dev/null + then + base_ref=$candidate + break + fi + done + + if [ -z "$base_ref" ] + then + printf "ERROR: unable to resolve base ref for new branch %s\n" "$local_ref" >&2 + validation_status=1 + continue + fi + + base_sha=$(git merge-base "$local_sha" "$base_ref" 2>&1) + if [ $? -ne 0 ] + then + printf "ERROR: git merge-base %s %s failed: %s\n" "$local_sha" "$base_ref" "$base_sha" >&2 + validation_status=1 + continue + fi + else + base_sha="$remote_sha" + fi + + while IFS= read -r -d '' entry + do + IFS=$'\x01' read -r parents committer_name committer_email subject <<< "$entry" + + if [ -n "$parents" ] + then + IFS=' ' read -ra parents_array <<< "$parents" + parent_count=${#parents_array[@]} + else + parent_count=0 + fi + + if [ "$parent_count" -eq 2 ] \ + && [ "$committer_name" = "GitHub" ] \ + && [ "$committer_email" = "noreply@github.com" ] \ + && [[ "$subject" =~ $github_merge_pattern ]] + then + continue + fi + + "$validator" --subject "$subject" + status=$? + + if [ "$status" -ne 0 ] + then + validation_status=$status + fi + done < <(git log -z --format='%P%x01%cn%x01%ce%x01%s' "$base_sha..$local_sha") +done + +exit "$validation_status" diff --git a/git-hooks/commit-msg b/git-hooks/commit-msg index d17782e..7cc40a0 100755 --- a/git-hooks/commit-msg +++ b/git-hooks/commit-msg @@ -4,4 +4,4 @@ # mix git_hooks repo_top=$(git rev-parse --show-toplevel) || exit 1 -exec "$repo_top/git-hooks/validate-conventional-subject" "$1" +exec "$repo_top/ci/validate_conventional_subject.sh" "$1" diff --git a/git-hooks/pre-push b/git-hooks/pre-push index ce2d199..3eba12c 100755 --- a/git-hooks/pre-push +++ b/git-hooks/pre-push @@ -1,10 +1,7 @@ #!/bin/sh -# Runs the same complete validation gate as CI before every push, including -# Hex's retired/vulnerable-package audit. It also validates every commit since -# the branch diverged from its base (catching a commit made before the hooks -# were installed, an amend, a rebase, etc.). Activate with: +# Validates every commit subject introduced by this push. +# See app/usage-rules.md for the rule. Activate with: # mix git_hooks repo_top=$(git rev-parse --show-toplevel) || exit 1 -cd "$repo_top" || exit 1 -exec mix precommit +exec "$repo_top/ci/validate_push_refs.sh" "$@" diff --git a/lib/mix/tasks/precommit.ex b/lib/mix/tasks/precommit.ex index f6bfcd1..520d0ba 100644 --- a/lib/mix/tasks/precommit.ex +++ b/lib/mix/tasks/precommit.ex @@ -10,10 +10,10 @@ defmodule Mix.Tasks.Precommit do GitHub Actions. Cheap metadata guards run first so an invalid pull request title or commit subject fails before dependency setup and the test suite: - 1. `git-hooks/validate-pull-request-title` — require a Conventional - Commits pull request title when `PULL_REQUEST_TITLE_REQUIRED=true` - 2. `git-hooks/validate-commit-range` — validate every commit since the - branch diverged from its base + 1. `ci/validate_pull_request_title.sh` — require a Conventional Commits + pull request title when `PULL_REQUEST_TITLE_REQUIRED=true` + 2. `ci/validate_commit_range.sh` — validate every commit since the branch + diverged from its base 3. `mix deps.get` — ensure app dependencies are present 4. `mix hex.audit` — reject retired or vulnerable Hex packages 5. `mix deps.audit` — scan dependencies for known security advisories @@ -42,8 +42,8 @@ defmodule Mix.Tasks.Precommit do @doc false def run([], shell) do - shell.("./git-hooks/validate-pull-request-title", [], []) - shell.("./git-hooks/validate-commit-range", [], []) + shell.("./ci/validate_pull_request_title.sh", [], []) + shell.("./ci/validate_commit_range.sh", [], []) shell.("mix", ["deps.get"], cd: "app") shell.("mix", ["hex.audit"], cd: "app") shell.("mix", ["deps.audit"], cd: "app") diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index e08befa..f00a45f 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -1,9 +1,12 @@ defmodule GitHooksTest do use ExUnit.Case, async: true - @subject_guard Path.expand("../git-hooks/validate-conventional-subject", __DIR__) - @title_guard Path.expand("../git-hooks/validate-pull-request-title", __DIR__) - @range_guard Path.expand("../git-hooks/validate-commit-range", __DIR__) + @subject_guard Path.expand("../ci/validate_conventional_subject.sh", __DIR__) + @title_guard Path.expand("../ci/validate_pull_request_title.sh", __DIR__) + @range_guard Path.expand("../ci/validate_commit_range.sh", __DIR__) + @push_refs_guard Path.expand("../ci/validate_push_refs.sh", __DIR__) + + @zero_sha "0000000000000000000000000000000000000000" test "the shared subject guard accepts Conventional Commits" do assert {"", 0} = run(@subject_guard, ["--subject", "feat(api): add title validation"]) @@ -87,7 +90,7 @@ defmodule GitHooksTest do end test "the range guard compares local main against origin/main" do - {worktree, hooks_dir} = setup_hooks_worktree!() + {worktree, _} = setup_ci_worktree!() {initial_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) initial_sha = String.trim(initial_sha) @@ -95,11 +98,11 @@ defmodule GitHooksTest do git!(worktree, ["add", "README"]) git!(worktree, ["commit", "-m", "this is not conventional"]) - assert {output, 1} = run(Path.join(hooks_dir, "validate-commit-range"), [], cd: worktree) + assert {output, 1} = run(@range_guard, [], cd: worktree) assert output =~ "this is not conventional" assert {output, 1} = - run(Path.join(hooks_dir, "validate-commit-range"), [], + run(@range_guard, [], cd: worktree, env: [{"BASE_REF", initial_sha}] ) @@ -108,12 +111,12 @@ defmodule GitHooksTest do end test "the range guard passes when the commit range is empty" do - {worktree, hooks_dir} = setup_hooks_worktree!() - assert {"", 0} = run(Path.join(hooks_dir, "validate-commit-range"), [], cd: worktree) + {worktree, _} = setup_ci_worktree!() + assert {"", 0} = run(@range_guard, [], cd: worktree) end test "the range guard reports all invalid subjects in a mixed commit range" do - {worktree, hooks_dir} = setup_hooks_worktree!() + {worktree, _} = setup_ci_worktree!() File.write!(Path.join(worktree, "a"), "a") git!(worktree, ["add", "a"]) @@ -127,55 +130,122 @@ defmodule GitHooksTest do git!(worktree, ["add", "c"]) git!(worktree, ["commit", "-m", "also bad subject"]) - assert {output, 1} = run(Path.join(hooks_dir, "validate-commit-range"), [], cd: worktree) + assert {output, 1} = run(@range_guard, [], cd: worktree) assert output =~ "not conventional at all" assert output =~ "also bad subject" refute output =~ "feat: valid commit" end test "the range guard skips a GitHub Update-branch merge commit matching all three predicates" do - {worktree, hooks_dir} = setup_hooks_worktree!() + {worktree, _} = setup_ci_worktree!() add_github_merge!(worktree) - assert {"", 0} = run(Path.join(hooks_dir, "validate-commit-range"), [], cd: worktree) + assert {"", 0} = run(@range_guard, [], cd: worktree) end test "the range guard validates when the committer name is not GitHub" do - {worktree, hooks_dir} = setup_hooks_worktree!() + {worktree, _} = setup_ci_worktree!() add_github_merge!(worktree, committer_name: "Not GitHub") - assert {output, 1} = run(Path.join(hooks_dir, "validate-commit-range"), [], cd: worktree) + assert {output, 1} = run(@range_guard, [], cd: worktree) assert output =~ "Merge branch 'main' into feature" end test "the range guard validates when the committer email is not noreply@github.com" do - {worktree, hooks_dir} = setup_hooks_worktree!() + {worktree, _} = setup_ci_worktree!() add_github_merge!(worktree, committer_email: "not@github.com") - assert {output, 1} = run(Path.join(hooks_dir, "validate-commit-range"), [], cd: worktree) + assert {output, 1} = run(@range_guard, [], cd: worktree) assert output =~ "Merge branch 'main' into feature" end test "the range guard validates a single-parent commit whose subject matches the GitHub pattern" do - {worktree, hooks_dir} = setup_hooks_worktree!() + {worktree, _} = setup_ci_worktree!() git!(worktree, ["commit", "--allow-empty", "-m", "Merge branch 'main' into feature"]) - assert {output, 1} = run(Path.join(hooks_dir, "validate-commit-range"), [], cd: worktree) + assert {output, 1} = run(@range_guard, [], cd: worktree) assert output =~ "Merge branch 'main' into feature" end test "the range guard validates a two-parent GitHub-committer merge with a non-matching subject" do - {worktree, hooks_dir} = setup_hooks_worktree!() + {worktree, _} = setup_ci_worktree!() add_github_merge!(worktree, subject: "Merge feature into main") - assert {output, 1} = run(Path.join(hooks_dir, "validate-commit-range"), [], cd: worktree) + assert {output, 1} = run(@range_guard, [], cd: worktree) assert output =~ "Merge feature into main" end + test "the push_refs guard passes when there are no refs to push" do + {worktree, _} = setup_ci_worktree!() + assert {"", 0} = run_with_stdin(@push_refs_guard, "", cd: worktree) + end + + test "the push_refs guard skips deletion pushes" do + {worktree, _} = setup_ci_worktree!() + {head_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) + head_sha = String.trim(head_sha) + stdin = "refs/heads/old-branch #{@zero_sha} refs/heads/old-branch #{head_sha}\n" + assert {"", 0} = run_with_stdin(@push_refs_guard, stdin, cd: worktree) + end + + test "the push_refs guard passes a valid conventional commit on an existing branch" do + {worktree, _} = setup_ci_worktree!() + {base_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) + base_sha = String.trim(base_sha) + + File.write!(Path.join(worktree, "x"), "x") + git!(worktree, ["add", "x"]) + git!(worktree, ["commit", "-m", "feat: add x"]) + + {head_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) + head_sha = String.trim(head_sha) + + stdin = "refs/heads/main #{head_sha} refs/heads/main #{base_sha}\n" + assert {"", 0} = run_with_stdin(@push_refs_guard, stdin, cd: worktree) + end + + test "the push_refs guard rejects a non-conventional commit on an existing branch" do + {worktree, _} = setup_ci_worktree!() + {base_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) + base_sha = String.trim(base_sha) + + File.write!(Path.join(worktree, "y"), "y") + git!(worktree, ["add", "y"]) + git!(worktree, ["commit", "-m", "this is not conventional"]) + + {head_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) + head_sha = String.trim(head_sha) + + stdin = "refs/heads/main #{head_sha} refs/heads/main #{base_sha}\n" + assert {output, 1} = run_with_stdin(@push_refs_guard, stdin, cd: worktree) + assert output =~ "this is not conventional" + end + + test "the push_refs guard passes a valid conventional commit on a new branch" do + {worktree, _} = setup_ci_worktree!() + + git!(worktree, ["checkout", "-b", "new-feature"]) + File.write!(Path.join(worktree, "f"), "f") + git!(worktree, ["add", "f"]) + git!(worktree, ["commit", "-m", "feat: add f"]) + + {head_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) + head_sha = String.trim(head_sha) + + stdin = "refs/heads/new-feature #{head_sha} refs/heads/new-feature #{@zero_sha}\n" + assert {"", 0} = run_with_stdin(@push_refs_guard, stdin, cd: worktree) + end + + test "git-hooks/ directory contains only the commit-msg and pre-push adapters" do + hooks_dir = Path.expand("../git-hooks", __DIR__) + entries = File.ls!(hooks_dir) |> Enum.sort() + assert entries == ["commit-msg", "pre-push"] + end + # Creates a no-fast-forward merge commit on `main` from a throwaway `feature` # branch. Defaults simulate GitHub's "Update branch" committer identity and - # subject so the predicate in validate-commit-range matches. + # subject so the predicate in validate_commit_range.sh matches. defp add_github_merge!(worktree, opts \\ []) do committer_name = Keyword.get(opts, :committer_name, "GitHub") committer_email = Keyword.get(opts, :committer_email, "noreply@github.com") @@ -203,10 +273,12 @@ defmodule GitHooksTest do end end - # Creates a git repo in a temp dir with origin set up and the guard scripts - # copied in. Uses a cryptographic nonce so collisions cannot occur across - # BEAM VM restarts (unlike System.unique_integer/1 which resets each run). - defp setup_hooks_worktree! do + # Creates a git repo in a temp dir with origin set up and ci/ scripts copied + # in so validate_commit_range.sh and validate_push_refs.sh can find + # validate_conventional_subject.sh via $repo_top/ci/. + # Uses a cryptographic nonce so collisions cannot occur across BEAM VM + # restarts (unlike System.unique_integer/1 which resets each run). + defp setup_ci_worktree! do test_root = tmp_dir!("root") bare_repo = Path.join(test_root, "origin.git") worktree = Path.join(test_root, "worktree") @@ -224,16 +296,25 @@ defmodule GitHooksTest do git!(worktree, ["remote", "add", "origin", bare_repo]) git!(worktree, ["push", "--set-upstream", "origin", "main"]) - hooks_dir = Path.join(worktree, "git-hooks") - File.mkdir_p!(hooks_dir) + ci_dir = Path.join(worktree, "ci") + File.mkdir_p!(ci_dir) - for guard <- [@subject_guard, @range_guard] do - destination = Path.join(hooks_dir, Path.basename(guard)) + for guard <- [@subject_guard, @range_guard, @push_refs_guard] do + destination = Path.join(ci_dir, Path.basename(guard)) File.cp!(guard, destination) File.chmod!(destination, 0o755) end - {worktree, hooks_dir} + {worktree, ci_dir} + end + + defp run_with_stdin(command, stdin_content, opts \\ []) do + nonce = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false) + stdin_file = Path.join(System.tmp_dir!(), "linear_cli_stdin_#{nonce}") + File.write!(stdin_file, stdin_content) + on_exit(fn -> File.rm(stdin_file) end) + opts = Keyword.put(opts, :stderr_to_stdout, true) + System.cmd("sh", ["-c", "#{command} < #{stdin_file}"], opts) end defp write_commit_msg!(content) do diff --git a/test/mix/tasks/precommit_test.exs b/test/mix/tasks/precommit_test.exs index 25eb1ee..c55bd49 100644 --- a/test/mix/tasks/precommit_test.exs +++ b/test/mix/tasks/precommit_test.exs @@ -13,8 +13,8 @@ defmodule Mix.Tasks.PrecommitTest do assert :ok = Precommit.run([], shell) - assert_receive {:run, "./git-hooks/validate-pull-request-title", [], []} - assert_receive {:run, "./git-hooks/validate-commit-range", [], []} + assert_receive {:run, "./ci/validate_pull_request_title.sh", [], []} + assert_receive {:run, "./ci/validate_commit_range.sh", [], []} assert_receive {:run, "mix", ["deps.get"], [cd: "app"]} assert_receive {:run, "mix", ["hex.audit"], [cd: "app"]} assert_receive {:run, "mix", ["deps.audit"], [cd: "app"]}