diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 479704e..7a98aeb 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -100,12 +100,10 @@ defmodule LinearCli.CLI.IssueCommandsTest do # `System.tmp_dir!()` - never the real project working directory. See house # rule 6 and `LinearCli.GitTest`'s own identical setup. defp git_repo! do - origin_path = tmp_path("origin") - File.mkdir_p!(origin_path) + origin_path = tmp_dir!("origin") {_output, 0} = System.cmd("git", ["init", "--bare", "-q"], cd: origin_path) - repo_path = tmp_path("repo") - File.mkdir_p!(repo_path) + repo_path = tmp_dir!("repo") {_output, 0} = System.cmd("git", ["init", "-q"], cd: repo_path) {_output, 0} = System.cmd("git", ["config", "user.name", "Test User"], cd: repo_path) {_output, 0} = System.cmd("git", ["config", "user.email", "test@example.com"], cd: repo_path) @@ -116,14 +114,21 @@ defmodule LinearCli.CLI.IssueCommandsTest do {_output, 0} = System.cmd("git", ["remote", "add", "origin", origin_path], cd: repo_path) {_output, 0} = System.cmd("git", ["push", "-q", "-u", "origin", "main"], cd: repo_path) - on_exit(fn -> - File.rm_rf!(origin_path) - File.rm_rf!(repo_path) - end) - repo_path end + # `System.unique_integer/1` resets across BEAM VM restarts, so an interrupted + # prior run can reuse a stale /tmp directory. A cryptographic nonce avoids + # collisions across processes; `on_exit` is registered before any git command + # so a setup failure still cleans up. + defp tmp_dir!(prefix) do + nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) + path = Path.join(System.tmp_dir!(), "linear_cli_issue_commands_test_#{prefix}_#{nonce}") + File.mkdir!(path) + on_exit(fn -> File.rm_rf!(path) end) + path + end + defp tmp_path(prefix) do Path.join( System.tmp_dir!(), diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index 3ede985..6358d42 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -110,12 +110,10 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do # `System.tmp_dir!()` - never the real project working directory. See # `LinearCli.CLI.IssueCommandsTest`'s own identical setup. defp git_repo! do - origin_path = tmp_path("origin") - File.mkdir_p!(origin_path) + origin_path = tmp_dir!("origin") {_output, 0} = System.cmd("git", ["init", "--bare", "-q"], cd: origin_path) - repo_path = tmp_path("repo") - File.mkdir_p!(repo_path) + repo_path = tmp_dir!("repo") {_output, 0} = System.cmd("git", ["init", "-q"], cd: repo_path) {_output, 0} = System.cmd("git", ["config", "user.name", "Test User"], cd: repo_path) {_output, 0} = System.cmd("git", ["config", "user.email", "test@example.com"], cd: repo_path) @@ -126,19 +124,19 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do {_output, 0} = System.cmd("git", ["remote", "add", "origin", origin_path], cd: repo_path) {_output, 0} = System.cmd("git", ["push", "-q", "-u", "origin", "main"], cd: repo_path) - on_exit(fn -> - File.rm_rf!(origin_path) - File.rm_rf!(repo_path) - end) - repo_path end - defp tmp_path(prefix) do - Path.join( - System.tmp_dir!(), - "linear_cli_profile_defaults_test_#{prefix}_#{System.unique_integer([:positive, :monotonic])}" - ) + # `System.unique_integer/1` resets across BEAM VM restarts, so an interrupted + # prior run can reuse a stale /tmp directory. A cryptographic nonce avoids + # collisions across processes; `on_exit` is registered before any git command + # so a setup failure still cleans up. + defp tmp_dir!(prefix) do + nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) + path = Path.join(System.tmp_dir!(), "linear_cli_profile_defaults_test_#{prefix}_#{nonce}") + File.mkdir!(path) + on_exit(fn -> File.rm_rf!(path) end) + path end describe "Commands.issue_list/1 falls back to the active profile" do diff --git a/ci/conventional_commits.sh b/ci/conventional_commits.sh index 8b6502b..e9394f7 100755 --- a/ci/conventional_commits.sh +++ b/ci/conventional_commits.sh @@ -1,111 +1,4 @@ #!/usr/bin/env bash - -usage() { - cat <<-EOT - Validate conventional commit subjects in the current branch range. - - Usage: - $0 - - Environment: - BASE_REF Base branch or ref. Defaults to GITHUB_BASE_REF, main, then origin/main. - HEAD_REF Head branch name. Defaults to GITHUB_HEAD_REF, then current branch. - FETCH_BASE_REF When "true", fetch BASE_REF from origin before validating. -EOT -} - -die() { - printf "ERROR: %s\n\n" "$*" >&2 - usage >&2 - exit 1 -} - -git_or_die() { - output=$(git "$@" 2>&1) - status=$? - - if [ "$status" -ne 0 ] - then - printf "%s\n" "$output" >&2 - die "git $* failed" - fi - - printf "%s" "$output" -} - -repo_top=$(git_or_die rev-parse --show-toplevel) -validator="$repo_top/ci/validate_conventional_commit.sh" - -[ -x "$validator" ] || die "validator is not executable: $validator" - -base_input=${BASE_REF:-${GITHUB_BASE_REF:-}} -head_branch=${HEAD_REF:-${GITHUB_HEAD_REF:-}} -base_name=${base_input#refs/heads/} -base_name=${base_name#origin/} - -if [ -z "$head_branch" ] -then - head_branch=$(git branch --show-current 2>/dev/null) -fi - -if [ "${FETCH_BASE_REF:-}" = "true" ] && [ -n "$base_input" ] -then - git_or_die fetch --no-tags origin "$base_name:refs/remotes/origin/$base_name" >/dev/null -fi - -base_ref= - -if [ -n "$base_input" ] -then - base_ref_candidates="$base_input origin/$base_name $base_name" -else - base_ref_candidates="main origin/main" -fi - -for candidate in $base_ref_candidates -do - if git rev-parse --verify --quiet "$candidate" >/dev/null - then - base_ref=$candidate - break - fi -done - -[ -n "$base_ref" ] || die "unable to resolve commit comparison base" - -base_branch=${base_name:-$base_ref} -base_branch=${base_branch#refs/heads/} -base_branch=${base_branch#origin/} -expected_update_branch_subject="Merge branch '$base_branch' into $head_branch" -base_sha=$(git_or_die merge-base HEAD "$base_ref") - -validation_status=0 - -while IFS= read -r -d '' sha && - IFS= read -r -d '' parents && - IFS= read -r -d '' committer_name && - IFS= read -r -d '' committer_email && - IFS= read -r -d '' subject -do - parent_count=$(printf "%s\n" "$parents" | wc -w | tr -d ' ') - - if [ "$parent_count" = "2" ] && - [ "$committer_name" = "GitHub" ] && - [ "$committer_email" = "noreply@github.com" ] && - [ -n "$head_branch" ] && - [ "$subject" = "$expected_update_branch_subject" ] - then - printf "Skipping GitHub update-branch merge commit: %s %s\n" "$sha" "$subject" >&2 - continue - fi - - "$validator" --subject "$subject" - status=$? - - if [ "$status" -ne 0 ] - then - validation_status=$status - fi -done < <(git log --format='%H%x00%P%x00%cN%x00%cE%x00%s%x00' "$base_sha..HEAD") - -exit "$validation_status" +# 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/ci/validate_conventional_commit.sh b/ci/validate_conventional_commit.sh index 3a85fcd..158528b 100755 --- a/ci/validate_conventional_commit.sh +++ b/ci/validate_conventional_commit.sh @@ -1,85 +1,4 @@ #!/usr/bin/env bash - -VALID_TYPES="fix|feat|perf|observability|obs|config|configuration|chore|ci|docs|refactor|sec|security|style|cleanup|test" - -allowed_types=$VALID_TYPES -header_pattern="^(${allowed_types})(\\([A-Za-z0-9._/-]+\\))?(!)?: .+" - -usage() { - cat <<-EOT - Validate a conventional commit subject. - - Usage: - $0 - $0 --subject "" -EOT -} - -die() { - printf "ERROR: %s\n\n" "$*" >&2 - usage >&2 - exit 1 -} - -if [ "$#" -eq 2 ] && [ "$1" = "--subject" ] -then - subject=$2 -elif [ "$#" -eq 1 ] -then - message_file=$1 - [ -f "$message_file" ] || die "Commit message file not found: $message_file" - - subject=$( - sed -n \ - -e '/^[[:space:]]*#/d' \ - -e '/^[[:space:]]*$/d' \ - -e 'p;q' \ - "$message_file" - ) -else - die "Invalid arguments" -fi - -[ -n "${subject:-}" ] || die "Commit subject is empty" - -if [[ "$subject" =~ [[:space:]]$ ]] -then - die "Commit subject must not end with whitespace: $subject" -fi - -if [[ "$subject" =~ ^Merge\ branch\ .+ ]] -then - cat >&2 <<-EOT -ERROR: Merge commit subject must use conventional-commit format. - -Subject: - $subject - -Recommended fix: - Rebase and reword the commit with chore: in front of the merge subject: - chore: $subject -EOT - exit 1 -fi - -if [[ ! "$subject" =~ $header_pattern ]] -then - cat >&2 <<-EOT -ERROR: Commit subject must use conventional-commit format. - -Subject: - $subject - -Expected: - [(scope)][!]: - -Allowed types: - ${allowed_types//|/, } - -Examples: - docs: update wallet one-pager - feat(api): add wallet debit endpoint - fix(db)!: change ledger migration format -EOT - exit 1 -fi +# 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/test/git_hooks_test.exs b/test/git_hooks_test.exs index 8f1b0c8..c375225 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -16,6 +16,27 @@ defmodule GitHooksTest do assert output =~ "Commit subject must use Conventional Commits format" end + test "the shared subject guard rejects the squash title from pull request 196" do + title = "EXT-19: isolate Burrito musl loader per user (#196)" + + assert {output, 1} = run(@subject_guard, ["--subject", title]) + assert output =~ "Commit subject must use Conventional Commits format" + end + + test "the shared subject guard accepts a commit message file with body and footer" do + path = + write_commit_msg!( + "feat(ci): add range validation\n\nBody.\n\nCo-authored-by: X \n" + ) + + assert {"", 0} = run(@subject_guard, [path]) + end + + test "the shared subject guard skips comment lines in a commit message file" do + path = write_commit_msg!("# This is a comment\n\nfeat: valid subject after comment lines\n") + assert {"", 0} = run(@subject_guard, [path]) + end + test "the pull request guard accepts a valid required title" do env = [ {"PULL_REQUEST_TITLE_REQUIRED", "true"}, @@ -36,6 +57,25 @@ defmodule GitHooksTest do assert output =~ "Pull request title must use Conventional Commits format" end + test "the pull request guard rejects a missing required title" do + env = [{"PULL_REQUEST_TITLE_REQUIRED", "true"}] + + assert {output, 1} = run(@title_guard, [], env: env) + assert output =~ "PULL_REQUEST_TITLE must be set" + end + + test "the shared subject guard rejects an empty subject" do + assert {output, 1} = run(@subject_guard, ["--subject", ""]) + assert output =~ "Commit subject is empty" + end + + test "the pull request guard rejects an invalid PULL_REQUEST_TITLE_REQUIRED value" do + env = [{"PULL_REQUEST_TITLE_REQUIRED", "maybe"}] + + assert {output, 1} = run(@title_guard, [], env: env) + assert output =~ "PULL_REQUEST_TITLE_REQUIRED must be" + end + test "the pull request guard skips non-pull-request events" do env = [ {"PULL_REQUEST_TITLE_REQUIRED", "false"}, @@ -47,15 +87,61 @@ defmodule GitHooksTest do end test "the range guard compares local main against origin/main" do - test_root = - Path.join(System.tmp_dir!(), "linear_cli_git_hooks_#{System.unique_integer([:positive])}") + {worktree, hooks_dir} = setup_hooks_worktree!() + {initial_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) + initial_sha = String.trim(initial_sha) + + File.write!(Path.join(worktree, "README"), "bad commit\n", [:append]) + 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 =~ "this is not conventional" + + assert {output, 1} = + run(Path.join(hooks_dir, "validate-commit-range"), [], + cd: worktree, + env: [{"BASE_REF", initial_sha}] + ) + + assert output =~ "this is not conventional" + 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) + end + test "the range guard reports all invalid subjects in a mixed commit range" do + {worktree, hooks_dir} = setup_hooks_worktree!() + + File.write!(Path.join(worktree, "a"), "a") + git!(worktree, ["add", "a"]) + git!(worktree, ["commit", "-m", "feat: valid commit"]) + + File.write!(Path.join(worktree, "b"), "b") + git!(worktree, ["add", "b"]) + git!(worktree, ["commit", "-m", "not conventional at all"]) + + File.write!(Path.join(worktree, "c"), "c") + 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 =~ "not conventional at all" + assert output =~ "also bad subject" + refute output =~ "feat: valid commit" + 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 + test_root = tmp_dir!("root") bare_repo = Path.join(test_root, "origin.git") worktree = Path.join(test_root, "worktree") + File.mkdir!(worktree) - on_exit(fn -> File.rm_rf!(test_root) end) - - File.mkdir_p!(worktree) git!(test_root, ["init", "--bare", bare_repo]) git!(worktree, ["init", "--initial-branch", "main"]) git!(worktree, ["config", "user.name", "Git Hooks Test"]) @@ -67,8 +153,6 @@ defmodule GitHooksTest do git!(worktree, ["commit", "-m", "chore: create test repository"]) git!(worktree, ["remote", "add", "origin", bare_repo]) git!(worktree, ["push", "--set-upstream", "origin", "main"]) - {initial_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) - initial_sha = String.trim(initial_sha) hooks_dir = Path.join(worktree, "git-hooks") File.mkdir_p!(hooks_dir) @@ -79,20 +163,23 @@ defmodule GitHooksTest do File.chmod!(destination, 0o755) end - File.write!(Path.join(worktree, "README"), "bad commit\n", [:append]) - 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 =~ "this is not conventional" + {worktree, hooks_dir} + end - assert {output, 1} = - run(Path.join(hooks_dir, "validate-commit-range"), [], - cd: worktree, - env: [{"BASE_REF", initial_sha}] - ) + defp write_commit_msg!(content) do + nonce = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false) + path = Path.join(System.tmp_dir!(), "linear_cli_git_hooks_commit_msg_#{nonce}") + File.write!(path, content) + on_exit(fn -> File.rm(path) end) + path + end - assert output =~ "this is not conventional" + defp tmp_dir!(prefix) do + nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) + path = Path.join(System.tmp_dir!(), "linear_cli_git_hooks_#{prefix}_#{nonce}") + File.mkdir!(path) + on_exit(fn -> File.rm_rf!(path) end) + path end defp run(command, args, opts \\ []) do diff --git a/test/mix/tasks/stokowski_test.exs b/test/mix/tasks/stokowski_test.exs index e3f1be3..dafea6f 100644 --- a/test/mix/tasks/stokowski_test.exs +++ b/test/mix/tasks/stokowski_test.exs @@ -24,10 +24,12 @@ defmodule Mix.Tasks.StokowskiTest do # Each test gets its own directory rather than sharing System.tmp_dir!() # directly - both tests run async and would otherwise race on the same - # workflow.yaml. + # workflow.yaml. A cryptographic nonce avoids collisions across BEAM VM + # restarts (unlike System.unique_integer/1 which resets each run). defp in_tmp_dir(fun) do - dir = Path.join(System.tmp_dir!(), "stokowski_test_#{System.unique_integer([:positive])}") - File.mkdir_p!(dir) + nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) + dir = Path.join(System.tmp_dir!(), "stokowski_test_#{nonce}") + File.mkdir!(dir) try do File.cd!(dir, fun)