#!/bin/sh # test/update-policy.sh: test script/update-policy on throwaway repos. # Each scenario gets one temp directory holding a repo, its local bare # origin, a local git repository of the canonical files in place of # prompts, and a stub curl that serves them, so nothing touches the # network. set -eu UPDATE_POLICY="$(cd "$(dirname "$0")/.." && pwd -P)/script/update-policy" dir="" trap 'rm -rf "$dir"' EXIT fail() { echo "FAIL: $scenario: $*" >&2 cat "$dir/output" >&2 exit 1 } write_stub_curl() { mkdir "$dir/bin" cat > "$dir/bin/curl" << 'EOF' #!/bin/sh previous="" fail_flag="" for argument in "$@"; do [ "$previous" = "--output" ] && output="$argument" [ "$argument" = "--fail" ] && fail_flag=yes previous="$argument" done url="$previous" base="https://git.eeqj.de/sneak/prompts/raw/commit/$HASH/" path="${url#"$base"}" source="$(dirname "$0")/../canonical/$path" if [ "$path" = "$url" ] || [ ! -f "$source" ]; then [ -n "$fail_flag" ] && exit 22 echo "404 page not found" > "$output" exit 0 fi cp "$source" "$output" EOF chmod +x "$dir/bin/curl" } write_canonical_files() { mkdir -p "$dir/canonical/prompts" cat > "$dir/canonical/.gitignore" << 'EOF' *.log *.tmp # Own entries go below this comment. EOF cat > "$dir/canonical/.dockerignore" << 'EOF' **/*.log **/*.tmp # Own entries go below this comment. EOF cat > "$dir/canonical/.editorconfig" << 'EOF' root = true [*] indent_size = 4 # Own sections go below this comment. EOF cat > "$dir/canonical/.golangci.yml" << 'EOF' linters: settings: depguard: rules: test-support: deny: - pkg: net/http/httptest desc: canonical - pkg: net/http/cgi desc: added canonically other: deny: - pkg: example.com/other EOF echo '{ "tabWidth": 4 }' > "$dir/canonical/.prettierrc" echo "new policy" > "$dir/canonical/prompts/REPO_POLICIES.md" echo "new api policy" > "$dir/canonical/prompts/API_POLICIES.md" } # commit_canonical: commit the canonical files as they stand. The stub # curl serves only the newest commit; every other commit gets a 404. commit_canonical() { git -C "$dir/canonical" add .gitignore .dockerignore .editorconfig \ .golangci.yml .prettierrc prompts git -C "$dir/canonical" commit -q -m "Canonical" HASH="$(git -C "$dir/canonical" rev-parse HEAD)" } # write_outdated_files: this repo's files, one canonical version behind, # with the repo's own entries in the places the script keeps them. The # comment above the closing comment in .gitignore is replaced, not kept. write_outdated_files() { cat > "$dir/repo/.gitignore" << 'EOF' *.log # An outdated comment. # Own entries go below this comment. /myapp EOF cat > "$dir/repo/.dockerignore" << 'EOF' **/*.log # Own entries go below this comment. /myapp EOF cat > "$dir/repo/.editorconfig" << 'EOF' root = true [*] # Own sections go below this comment. [*.py] indent_size = 2 EOF cat > "$dir/repo/.golangci.yml" << 'EOF' linters: settings: depguard: rules: test-support: deny: - pkg: net/http/httptest desc: canonical # Our own test helpers. - pkg: example.com/app/internal/apptest desc: own entry other: deny: - pkg: example.com/other EOF echo '{ "tabWidth": 2 }' > "$dir/repo/.prettierrc" echo "old policy" > "$dir/repo/REPO_POLICIES.md" } # start_scenario : make a new temp directory holding a repo with # the outdated files, not yet committed, and an empty origin. start_scenario() { scenario="$1" rm -rf "$dir" dir="$(mktemp -d)" : > "$dir/output" write_stub_curl write_canonical_files # git clone reads no repo's own config, so the setting that points # git at the local canonical repository has to be in this file. export GIT_CONFIG_GLOBAL="$dir/gitconfig" git config --global user.name "Test" git config --global user.email "test@example.com" git config --global "url.$dir/canonical.insteadOf" \ "https://git.eeqj.de/sneak/prompts.git" git init -q -b main "$dir/canonical" export HASH commit_canonical git init -q --bare -b main "$dir/origin.git" git init -q -b main "$dir/repo" git -C "$dir/repo" remote add origin "$dir/origin.git" mkdir "$dir/repo/script" cp "$UPDATE_POLICY" "$dir/repo/script/update-policy" printf '#!/bin/sh\necho example\n' > "$dir/repo/script/projectname" chmod +x "$dir/repo/script/projectname" write_outdated_files } # commit_and_push_fixture : commit those paths, remove the # rest of the outdated files, and record the commit as $before. commit_and_push_fixture() { ( cd "$dir/repo" git add script "$@" git commit -q -m "Fixture" git push -q origin HEAD git clean -q -f -x ) before="$(git -C "$dir/repo" rev-parse HEAD)" } run_update_policy() { status=0 (cd "$dir/repo" && PATH="$dir/bin:$PATH" script/update-policy "$@") \ > "$dir/output" 2>&1 || status=$? } reject_pushes() { printf '#!/bin/sh\nexit 1\n' > "$dir/origin.git/hooks/pre-receive" chmod +x "$dir/origin.git/hooks/pre-receive" } accept_pushes() { rm "$dir/origin.git/hooks/pre-receive" } head_of_repo() { git -C "$dir/repo" rev-parse HEAD } head_of_origin() { git -C "$dir/origin.git" rev-parse refs/heads/main } expect_success() { [ "$status" -eq 0 ] || fail "exited $status" } expect_failure() { [ "$status" -ne 0 ] || fail "exited 0" } expect_output() { grep -q -F -e "$1" "$dir/output" || fail "output lacks: $1" } expect_output_without() { if grep -q -F -e "$1" "$dir/output"; then fail "output has: $1" fi } # expect_file : the file must equal standard input. expect_file() { cat > "$dir/expected" diff "$dir/expected" "$dir/repo/$1" >&2 || fail "$1 is wrong" } expect_absent() { [ ! -e "$dir/repo/$1" ] || fail "$1 exists" } expect_nothing_written() { [ -z "$(git -C "$dir/repo" status --porcelain)" ] || fail "the working tree changed" [ "$(head_of_repo)" = "$before" ] || fail "the repo has a new commit" [ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit" } test_own_entries_kept() { start_scenario "own entries kept in all four files" commit_and_push_fixture .gitignore .dockerignore .editorconfig \ .golangci.yml .prettierrc REPO_POLICIES.md run_update_policy "$HASH" expect_success expect_file .gitignore << 'EOF' *.log *.tmp # Own entries go below this comment. /myapp EOF expect_file .dockerignore << 'EOF' **/*.log **/*.tmp # Own entries go below this comment. /myapp EOF expect_file .editorconfig << 'EOF' root = true [*] indent_size = 4 # Own sections go below this comment. [*.py] indent_size = 2 EOF expect_file .golangci.yml << 'EOF' linters: settings: depguard: rules: test-support: deny: - pkg: net/http/httptest desc: canonical - pkg: net/http/cgi desc: added canonically # Our own test helpers. - pkg: example.com/app/internal/apptest desc: own entry other: deny: - pkg: example.com/other EOF expect_file .prettierrc << 'EOF' { "tabWidth": 4 } EOF expect_file REPO_POLICIES.md << 'EOF' new policy EOF [ "$(head_of_origin)" = "$(head_of_repo)" ] || fail "the commit was not pushed" [ "$(head_of_repo)" != "$before" ] || fail "no commit was made" git -C "$dir/repo" log -1 --format=%s | grep -q -F -e "$HASH" || fail "the commit message does not name the hash" } test_files_not_carried_are_skipped() { start_scenario "files the repo does not carry skipped" mkdir "$dir/repo/docs" mv "$dir/repo/REPO_POLICIES.md" "$dir/repo/docs/REPO_POLICIES.md" commit_and_push_fixture docs/REPO_POLICIES.md run_update_policy "$HASH" expect_success expect_file docs/REPO_POLICIES.md << 'EOF' new policy EOF expect_absent REPO_POLICIES.md expect_absent API_POLICIES.md expect_absent docs/API_POLICIES.md expect_absent .gitignore expect_absent .dockerignore expect_absent .editorconfig expect_absent .golangci.yml expect_absent .prettierrc [ "$(head_of_origin)" = "$(head_of_repo)" ] || fail "the commit was not pushed" } test_nothing_changed() { start_scenario "nothing changed" cp "$dir/canonical/.prettierrc" "$dir/repo/.prettierrc" commit_and_push_fixture .prettierrc run_update_policy "$HASH" expect_success expect_output "nothing to commit" expect_nothing_written } test_bad_commit_hash() { for hash in not-a-hash 0000000000000000000000000000000000000000; do start_scenario "bad commit hash $hash stops with nothing written" commit_and_push_fixture .gitignore .prettierrc REPO_POLICIES.md run_update_policy "$hash" expect_failure expect_nothing_written done # The last hash has the right form, so it is the one that reaches curl. expect_output "cannot fetch" } test_push_failure() { start_scenario "push failure exits non-zero" commit_and_push_fixture .prettierrc reject_pushes run_update_policy "$HASH" expect_failure expect_output "push failed" [ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit" } # Each of these files, with the repo's own entries above the closing # comment. The comment added with them must not be named. test_own_entry_above_closing_comment() { for file in .gitignore .dockerignore .editorconfig; do start_scenario "own entries above the closing comment of $file stop" { printf '/myapp\n/.tool/\n\n# A comment of our own.\n' cat "$dir/repo/$file" } > "$dir/merged" mv "$dir/merged" "$dir/repo/$file" commit_and_push_fixture .gitignore .dockerignore .editorconfig \ .prettierrc REPO_POLICIES.md run_update_policy "$HASH" expect_failure expect_output "$file has entries" expect_output "/myapp" expect_output "/.tool/" expect_output_without "A comment of our own." expect_nothing_written done } test_retry_after_failed_push() { start_scenario "a retry after a failed push delivers the commit" commit_and_push_fixture .prettierrc reject_pushes run_update_policy "$HASH" expect_failure committed="$(head_of_repo)" [ "$committed" != "$before" ] || fail "no commit was made" [ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit" accept_pushes run_update_policy "$HASH" expect_success [ "$(head_of_repo)" = "$committed" ] || fail "the retry made a new commit" [ "$(head_of_origin)" = "$committed" ] || fail "the retry did not deliver the commit" } test_uncommitted_change_stops() { start_scenario "a carried file with uncommitted changes stops" commit_and_push_fixture .prettierrc REPO_POLICIES.md echo "uncommitted" >> "$dir/repo/REPO_POLICIES.md" run_update_policy "$HASH" expect_failure expect_output "REPO_POLICIES.md has uncommitted changes" expect_file REPO_POLICIES.md << 'EOF' old policy uncommitted EOF [ "$(head_of_repo)" = "$before" ] || fail "the repo has a new commit" [ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit" } test_gitignore_lacks_canonical_last_line() { start_scenario ".gitignore lacking the canonical last line stops" printf '*.log\n/myapp\n' > "$dir/repo/.gitignore" commit_and_push_fixture .gitignore .prettierrc run_update_policy "$HASH" expect_failure expect_output ".gitignore lacks this line" expect_nothing_written } test_fetched_golangci_without_deny_list() { start_scenario "a fetched .golangci.yml with no deny list stops" cat > "$dir/canonical/.golangci.yml" << 'EOF' linters: settings: depguard: rules: test-support: files: - "**/*.go" other: deny: - pkg: example.com/other EOF commit_and_push_fixture .golangci.yml .prettierrc run_update_policy "$HASH" expect_failure expect_output "no deny list in its test-support rule" expect_nothing_written } test_own_golangci_setting_stops() { start_scenario "a .golangci.yml setting the canonical file lacks stops" cat >> "$dir/repo/.golangci.yml" << 'EOF' # A comment of our own. forbidigo: forbid: - pattern: model.Operator EOF commit_and_push_fixture .golangci.yml .prettierrc run_update_policy "$HASH" expect_failure expect_output ".golangci.yml has lines" expect_output "forbidigo:" expect_output "- pattern: model.Operator" expect_output_without "A comment of our own." expect_nothing_written } test_source_repo_stops() { start_scenario "the repo the policy files come from stops" printf '#!/bin/sh\necho prompts\n' > "$dir/repo/script/projectname" commit_and_push_fixture .prettierrc REPO_POLICIES.md run_update_policy "$HASH" expect_failure expect_output "this repo is the source of the policy files" expect_nothing_written } test_staged_file_stays_out_of_commit() { start_scenario "a file staged before the script starts stays out of its commit" commit_and_push_fixture .prettierrc echo "notes" > "$dir/repo/notes.txt" git -C "$dir/repo" add notes.txt run_update_policy "$HASH" expect_success [ "$(git -C "$dir/repo" show --name-only --format= HEAD)" = ".prettierrc" ] || fail "the commit holds more than the policy file" [ "$(git -C "$dir/repo" diff --cached --name-only)" = "notes.txt" ] || fail "notes.txt is no longer staged" } test_short_commit_hash_refused() { start_scenario "a hexadecimal hash shorter than 40 characters is refused" commit_and_push_fixture .prettierrc run_update_policy "${HASH%?}" expect_failure expect_output "not a full commit hash" expect_nothing_written } # git asks the local canonical repository for the head of main, and the # stub curl serves only that commit. test_no_hash_uses_head_of_main() { start_scenario "with no hash the script uses the head of main" commit_and_push_fixture .prettierrc run_update_policy expect_success expect_file .prettierrc << 'EOF' { "tabWidth": 4 } EOF git -C "$dir/repo" log -1 --format=%s | grep -q -F -e "$HASH" || fail "the commit message does not name the head of main" [ "$(head_of_origin)" = "$(head_of_repo)" ] || fail "the commit was not pushed" } # expect_former_canonical_line_replaced : an earlier # canonical commit held the line and the newest does not. This repo's # file still has it, and the script replaces it and pushes. expect_former_canonical_line_replaced() { cp "$dir/canonical/$1" "$dir/newest" { echo "$2"; cat "$dir/newest"; } > "$dir/canonical/$1" commit_canonical cp "$dir/newest" "$dir/canonical/$1" commit_canonical { echo "$2"; cat "$dir/repo/$1"; } > "$dir/merged" mv "$dir/merged" "$dir/repo/$1" commit_and_push_fixture "$1" .prettierrc run_update_policy "$HASH" expect_success if grep -q -F -x -e "$2" "$dir/repo/$1"; then fail "$1 still has: $2" fi [ "$(head_of_repo)" != "$before" ] || fail "no commit was made" [ "$(head_of_origin)" = "$(head_of_repo)" ] || fail "the commit was not pushed" } test_former_canonical_golangci_line_replaced() { start_scenario "a .golangci.yml line an earlier canonical commit held is replaced" expect_former_canonical_line_replaced .golangci.yml "version: 1" } test_former_canonical_gitignore_line_replaced() { start_scenario "a .gitignore line above the closing comment that an earlier canonical commit held is replaced" expect_former_canonical_line_replaced .gitignore "*.old" } main() { test_own_entries_kept test_files_not_carried_are_skipped test_nothing_changed test_bad_commit_hash test_push_failure test_own_entry_above_closing_comment test_retry_after_failed_push test_uncommitted_change_stops test_gitignore_lacks_canonical_last_line test_fetched_golangci_without_deny_list test_own_golangci_setting_stops test_source_repo_stops test_staged_file_stays_out_of_commit test_short_commit_hash_refused test_no_hash_uses_head_of_main test_former_canonical_golangci_line_replaced test_former_canonical_gitignore_line_replaced echo "update-policy tests passed" } main "$@"