diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index b2692307..8aa34993 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -92,3 +92,29 @@ steps: notify: - github_commit_status: context: "install_swiftpm_dependencies Tests: Xcode Workspace and Project - Explicit" + + - group: ":github: pr_changed_files Tests" + steps: + - label: ":github: pr_changed_files Tests - Basic Changes" + command: tests/pr_changed_files/test_basic_changes.sh + notify: + - github_commit_status: + context: "pr_changed_files Tests: Basic Changes" + + - label: ":github: pr_changed_files Tests - Any Match" + command: tests/pr_changed_files/test_any_match_patterns.sh + notify: + - github_commit_status: + context: "pr_changed_files Tests: Any Match" + + - label: ":github: pr_changed_files Tests - All Match" + command: tests/pr_changed_files/test_all_match_patterns.sh + notify: + - github_commit_status: + context: "pr_changed_files Tests: All Match" + + - label: ":github: pr_changed_files Tests - Edge Cases" + command: tests/pr_changed_files/test_edge_cases.sh + notify: + - github_commit_status: + context: "pr_changed_files Tests: Edge Cases" diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd9bd09..26b81de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ _None._ ### New Features -_None._ +- Add `pr_changed_files` command to detect changes made in a Pull Request [#148] ### Bug Fixes diff --git a/bin/pr_changed_files b/bin/pr_changed_files new file mode 100755 index 00000000..c6bbb8a6 --- /dev/null +++ b/bin/pr_changed_files @@ -0,0 +1,141 @@ +#!/bin/bash -eu + +# This script checks if files are changed in a PR. +# +# Usage: +# pr_changed_files # Check if any files changed +# pr_changed_files --all-match # Check if changes are limited to given patterns +# pr_changed_files --any-match # Check if changes include files matching patterns +# +# Behavior: +# With no arguments: +# Returns "true" if the PR contains any changes, "false" otherwise +# +# With --all-match: +# Returns "true" if ALL changed files match AT LEAST ONE of the patterns +# Returns "false" if ANY changed file doesn't match ANY pattern +# Note: Will return "true" even if not all patterns are matched by the changed files. +# This mode is especially useful to check if the PR _only_ touches a particular subset of files/folders (but nothing else) +# +# With --any-match: +# Returns "true" if ANY changed file matches ANY of the patterns +# Returns "false" if NONE of the changed files match ANY pattern +# Note: Will return "true" even if the PR includes other files not matching the patterns. +# This mode is especially useful to check if the PR _includes_ (aka _contains at least_) particular files/folders +# +# Examples with expected outputs: +# # Check if any files changed, returning "true" if PR has changes, "false" otherwise +# $ pr_changed_files +# +# # Check if only documentation files changed (to skip UI tests for example) +# $ pr_changed_files --all-match "*.md" "docs/*" +# → "true" if PR only changes `docs/guide.md` and `README.md` +# → "true" if PR only changes `docs/image.png` (not all patterns need to match, ok if no *.md) +# → "false" if PR changes `docs/guide.md` and `src/main.swift` (ALL files need to match at least one pattern) +# +# # Check if any Swift files changed (to decide if we should run SwiftLint) +# $ pr_changed_files --any-match "*.swift" ".swiftlint.yml" +# → "true" if PR changes `src/main.swift` and `README.md` (AT LEAST one file matches one of the patterns) +# → "true" if PR changes `.swiftlint.yml` +# → "false" if PR only changes `README.md` (none of files match any of the patterns) +# +# Returns: +# Prints "true" if the condition is met, "false" otherwise +# Exits with code 0 regardless of if the condition was met or not, to allow easy variable assignment. +# Only exits with non-zero if the command invocation itself was incorrect (called outside of a PR context, incorrect arguments…) + +if [[ ! "${BUILDKITE_PULL_REQUEST:-invalid}" =~ ^[0-9]+$ ]]; then + echo "Error: this tool can only be called from a Buildkite PR job" >&2 + exit 1 +fi + +# Ensure we have the base branch locally +git fetch origin "$BUILDKITE_PULL_REQUEST_BASE_BRANCH" >/dev/null 2>&1 || { + echo "Error: failed to fetch base branch '$BUILDKITE_PULL_REQUEST_BASE_BRANCH'" >&2 + exit 1 +} + +mode="" +patterns=() + +# Define error message for mutually exclusive options +EXCLUSIVE_OPTIONS_ERROR="Error: either specify --all-match or --any-match; cannot specify both" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --all-match | --any-match) + if [[ -n "$mode" ]]; then + echo "$EXCLUSIVE_OPTIONS_ERROR" >&2 + exit 1 + fi + mode="${1#--}" + shift + # Check if there are any patterns after the flag + while [[ "$#" -gt 0 && "$1" != "--"* ]]; do + patterns+=("$1") + shift + done + if [[ "${#patterns[@]}" -eq 0 ]]; then + echo "Error: must specify at least one file pattern" >&2 + exit 1 + fi + ;; + --*) + echo "Error: unknown option $1" >&2 + exit 1 + ;; + *) + echo "Error: unexpected argument $1" >&2 + exit 1 + ;; + esac +done + +# Get list of changed files as an array +changed_files=() +while IFS= read -r -d '' file; do + changed_files+=("$file") +done < <(git --no-pager diff --name-only -z --merge-base "$BUILDKITE_PULL_REQUEST_BASE_BRANCH" HEAD | sort) + +if [[ -z "$mode" ]]; then + # No arguments = any change + if [[ ${#changed_files[@]} -gt 0 ]]; then + echo "true" + else + echo "false" + fi + exit 0 +fi + +# Returns 0 if the file matches any of the patterns, 1 otherwise +file_matches_any_pattern() { + local file="$1" + shift + for pattern in "$@"; do + # shellcheck disable=SC2053 # We don't quote the rhs in the condition on the next line because we want to interpret pattern as a glob pattern + if [[ "$file" == ${pattern} ]]; then + return 0 + fi + done + return 1 +} + +if [[ "$mode" == "all-match" ]]; then + # Check if all changed files match at least one pattern + for file in "${changed_files[@]}"; do + if ! file_matches_any_pattern "$file" "${patterns[@]}"; then + echo "false" + exit 0 + fi + done + echo "true" +elif [[ "$mode" == "any-match" ]]; then + # Check if any changed file matches any pattern + for file in "${changed_files[@]}"; do + if file_matches_any_pattern "$file" "${patterns[@]}"; then + echo "true" + exit 0 + fi + done + echo "false" +fi diff --git a/tests/pr_changed_files/test_all_match_patterns.sh b/tests/pr_changed_files/test_all_match_patterns.sh new file mode 100755 index 00000000..2314c94e --- /dev/null +++ b/tests/pr_changed_files/test_all_match_patterns.sh @@ -0,0 +1,59 @@ +#!/bin/bash -eu + +set -o pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/test_helpers.sh" + +echo "--- :git: Testing all-match pattern matching" + +# Create test repository +repo_path=$(create_tmp_repo_dir) +trap 'cleanup_git_repo "$repo_path"' EXIT + +# Set up environment variables +export BUILDKITE_PULL_REQUEST="123" +export BUILDKITE_PULL_REQUEST_BASE_BRANCH="base" + +# Initialize the repository +init_test_repo "$repo_path" + +# Create test files (using single quotes to avoid special chars being interpreted by the shell) +mkdir -p docs src/swift +echo "doc1" > 'docs/read me.md' +echo "doc2" > 'docs/guide with spaces.md' +echo "doc3" > 'docs/special\!@*#$chars.md' +git add . +git commit -m "Add doc files" + +# [Test] All changes in docs +result=$(pr_changed_files --all-match "docs/*") +assert_output "true" "$result" "Should return true when all changes match patterns" + +# [Test] All changes in docs with explicit patterns including spaces and special chars +# Note: we need to escape the '\` and `*` special chars in the pattern to match them literally instead of as special characters +result=$(pr_changed_files --all-match 'docs/read me.md' 'docs/guide with spaces.md' 'docs/special\\!@\*#$chars.md') +assert_output "true" "$result" "Should return true when all changes match patterns with spaces and special chars" + +# [Test] All changes in docs with globbing patterns including spaces and special chars +result=$(pr_changed_files --all-match 'docs/read me.md' 'docs/guide with spaces.md' 'docs/special\\!*.md') +assert_output "true" "$result" "Should return true when all changes match patterns with spaces and special chars, even when using globbing" + +# [Test] Changes outside pattern +echo "swift" > 'src/swift/main with spaces.swift' +echo "swift" > 'src/swift/special!\@#*$chars.swift' +git add . +git commit -m "Add swift file" + +result=$(pr_changed_files --all-match "docs/*") +assert_output "false" "$result" "Should return false when changes exist outside patterns" + +# [Test] Multiple patterns, all matching +# Note: we need to escape the '\` and `*` special chars in the pattern to match them literally instead of as special characters +result=$(pr_changed_files --all-match 'docs/*' 'src/swift/main with spaces.swift' 'src/swift/special\!\\@#\*$chars.swift') +assert_output "true" "$result" "Should return true when all changes match multiple patterns" + +# [Test] Multiple patterns, all matching, including some using globbing +result=$(pr_changed_files --all-match 'docs/*' 'src/swift/main with spaces.swift' 'src/swift/special*chars.swift') +assert_output "true" "$result" "Should return true when all changes match multiple patterns, including some using globbing" + +echo "✅ All-match pattern tests passed" diff --git a/tests/pr_changed_files/test_any_match_patterns.sh b/tests/pr_changed_files/test_any_match_patterns.sh new file mode 100755 index 00000000..f3f5f442 --- /dev/null +++ b/tests/pr_changed_files/test_any_match_patterns.sh @@ -0,0 +1,61 @@ +#!/bin/bash -eu + +set -o pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/test_helpers.sh" + +echo "--- :git: Testing any-match pattern matching" + +# Create test repository +repo_path=$(create_tmp_repo_dir) +trap 'cleanup_git_repo "$repo_path"' EXIT + +# Set up environment variables +export BUILDKITE_PULL_REQUEST="123" +export BUILDKITE_PULL_REQUEST_BASE_BRANCH="base" + +# Initialize the repository +init_test_repo "$repo_path" + +# Create test files (using single quotes to avoid special chars being interpreted by the shell) +mkdir -p docs src/swift src/ruby +echo "doc" > 'docs/read me.md' +echo "doc" > 'docs/special!@*#$chars.md' +echo "swift" > 'src/swift/main.swift' +echo "ruby" > 'src/ruby/main.rb' +git add . +git commit -m "Add test files" + +# [Test] Match specific extension +result=$(pr_changed_files --any-match '*.swift') +assert_output "true" "$result" "Should match .swift files" + +# [Test] Match multiple patterns +result=$(pr_changed_files --any-match 'docs/*.md' '*.rb') +assert_output "true" "$result" "Should match multiple patterns" + +# [Test] Match files with spaces and special characters +result=$(pr_changed_files --any-match 'docs/read me.md' 'docs/special!@\*#$chars.md') +assert_output "true" "$result" "Should match files with spaces and special characters" + +# [Test] Match files with spaces and special characters, even when using globbing +result=$(pr_changed_files --any-match 'docs/read me.md' 'docs/special*chars.md') +assert_output "true" "$result" "Should match files with spaces and special characters, even when using globbing" + +# [Test] No matches +result=$(pr_changed_files --any-match '*.js') +assert_output "false" "$result" "Should not match non-existent patterns" + +# [Test] Directory pattern +result=$(pr_changed_files --any-match 'docs/*') +assert_output "true" "$result" "Should match directory patterns" + +# [Test] Exact pattern matching +echo "swiftfile" > swiftfile.txt +git add swiftfile.txt +git commit -m "Add file with swift in name" + +result=$(pr_changed_files --any-match '*.swift') +assert_output "true" "$result" "Should only match exact patterns" + +echo "✅ Any-match pattern tests passed" diff --git a/tests/pr_changed_files/test_basic_changes.sh b/tests/pr_changed_files/test_basic_changes.sh new file mode 100755 index 00000000..0108a57c --- /dev/null +++ b/tests/pr_changed_files/test_basic_changes.sh @@ -0,0 +1,32 @@ +#!/bin/bash -eu + +set -o pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/test_helpers.sh" + +echo "--- :git: Testing basic changes detection" + +# Create test repository +repo_path=$(create_tmp_repo_dir) +trap 'cleanup_git_repo "$repo_path"' EXIT + +# Set up environment variables +export BUILDKITE_PULL_REQUEST="123" +export BUILDKITE_PULL_REQUEST_BASE_BRANCH="base" + +# Initialize the repository +init_test_repo "$repo_path" + +# [Test] No changes +result=$(pr_changed_files) +assert_output "false" "$result" "Should return false when no files changed" + +# [Test] Single file change +echo "change" > new.txt +git add new.txt +git commit -m "Add new file" + +result=$(pr_changed_files) +assert_output "true" "$result" "Should return true when files changed" + +echo "✅ Basic changes tests passed" diff --git a/tests/pr_changed_files/test_edge_cases.sh b/tests/pr_changed_files/test_edge_cases.sh new file mode 100755 index 00000000..7ad1bb8e --- /dev/null +++ b/tests/pr_changed_files/test_edge_cases.sh @@ -0,0 +1,79 @@ +#!/bin/bash -eu + +set -o pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/test_helpers.sh" + +echo "--- :git: Testing edge cases" + +# Create test repository +repo_path=$(create_tmp_repo_dir) +trap 'cleanup_git_repo "$repo_path"' EXIT + +# Set up environment variables +export BUILDKITE_PULL_REQUEST="123" +export BUILDKITE_PULL_REQUEST_BASE_BRANCH="base" + +# Initialize the repository +init_test_repo "$repo_path" + +# [Test] Invalid PR environment +unset BUILDKITE_PULL_REQUEST +if pr_changed_files 2>/dev/null; then + echo "Should fail when not in PR environment" + exit 1 +fi + +export BUILDKITE_PULL_REQUEST="123" + +# [Test] No patterns provided +if pr_changed_files --any-match 2>/dev/null; then + echo "Should fail when no patterns provided" + exit 1 +fi + +# [Test] Flag followed by another flag +output=$(pr_changed_files --any-match --something 2>&1 || true) +assert_output "Error: must specify at least one file pattern" "$output" "Should fail with correct error when flag is followed by another flag" + +# [Test] Mutually exclusive options +output=$(pr_changed_files --any-match "*.txt" --all-match "*.md" 2>&1 || true) +assert_output "Error: either specify --all-match or --any-match; cannot specify both" "$output" "Should fail with correct error when using mutually exclusive options" + +# [Test] Files with spaces and special characters +mkdir -p 'folder with spaces/nested!\@*#$folder' +echo "test" > 'folder with spaces/file with spaces.txt' +echo "test" > 'folder with spaces/nested!\@*#$folder/file_with_!@*#$chars.txt' +git add . +git commit -m "Add files with special characters" + +result=$(pr_changed_files) +assert_output "true" "$result" "Should handle files with spaces and special characters" + +# [Test] Pattern matching with spaces and special characters +result=$(pr_changed_files --any-match '*spaces.txt') +assert_output "true" "$result" "Should match files with special characters in path" + +result=$(pr_changed_files --all-match 'folder with spaces/*') +assert_output "true" "$result" "Should handle directory patterns with spaces" + +# [Test] No changes between branches +git checkout -b no_changes base +result=$(pr_changed_files) +assert_output "false" "$result" "Should handle no changes between branches" + +# [Test] Empty commit +git checkout -b empty_commit base +git commit --allow-empty -m "Empty commit" +result=$(pr_changed_files) +assert_output "false" "$result" "Should handle empty commit" + +# [Test] Empty repository state +git checkout --orphan empty +git rm -rf . +git commit --allow-empty -m "Empty initial commit" + +result=$(pr_changed_files 2>/dev/null) +assert_output "false" "$result" "Should handle empty repository state" + +echo -e "\n✅ Edge cases tests passed" diff --git a/tests/pr_changed_files/test_helpers.sh b/tests/pr_changed_files/test_helpers.sh new file mode 100755 index 00000000..753d851a --- /dev/null +++ b/tests/pr_changed_files/test_helpers.sh @@ -0,0 +1,78 @@ +#!/bin/bash -eu + +set -o pipefail + +# Add bin directory to PATH +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +export PATH="$REPO_ROOT/bin:$PATH" + +# Create a temporary git repository for testing +create_tmp_repo_dir() { + local temp_dir + temp_dir=$(mktemp -d) + echo "$temp_dir" +} + +# Initialize the test repository +init_test_repo() { + local repo_dir="$1" + ORIGINAL_DIR=$(pwd) + + # Create a bare repo to act as remote + mkdir -p "$repo_dir/remote" + git init --bare "$repo_dir/remote" + + # Create the working repo + mkdir -p "$repo_dir/local" + pushd "$repo_dir/local" + + # Initialize git repo + git init + git config user.email "test@example.com" + git config user.name "Test User" + + # Add remote + git remote add origin "$repo_dir/remote" + + # Create and commit initial files on main branch + echo "initial" > initial.txt + git add initial.txt + git commit -m "Initial commit" + + # Create base branch + git checkout -b base + echo "base" > base.txt + git add base.txt + git commit -m "Base branch commit" + + # Push base branch to remote + git push -u origin base + + # Create PR branch + git checkout -b pr +} + +# Clean up the temporary repository +cleanup_git_repo() { + # Return to original directory if we're still in the temp dir + if [[ "$(pwd)" == "$1/local" ]]; then + cd "$ORIGINAL_DIR" + fi + rm -rf "$1" +} + +# Helper to assert expected output +assert_output() { + local expected="$1" + local actual="$2" + local message="${3:-}" + + if [[ "$actual" == "$expected" ]]; then + echo "🟢 Assertion succeeded: $message" + elif [[ "$actual" != "$expected" ]]; then + echo "❌ Assertion failed: $message" + echo "Expected: $expected" + echo "Actual : $actual" + exit 1 + fi +}