From b9a2695cbec2128e17fb3754b318380ac2465c41 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 9 Nov 2026 07:30:32 +0000 Subject: [PATCH] Add script/update-policy to re-vendor policy files (closes #162) One command fetches the vendored files from prompts at a commit, keeps a repo's own entries, commits and pushes. An entry above a file's closing comment that the canonical file lacks stops it before it writes. A run with nothing to commit still pushes, so a retry delivers a failed push. The canonical .dockerignore gains the closing comment the script needs. test/update-policy.sh tests the script in the Dockerfile test phase. Model: opus-5-5 --- .dockerignore | 4 + Dockerfile | 2 +- Makefile | 5 +- README.md | 7 +- TODO.md | 6 + prompts/EXISTING_REPO_CHECKLIST.md | 10 +- prompts/NEW_REPO_CHECKLIST.md | 7 +- prompts/REPO_POLICIES.md | 23 +- script/update-policy | 200 +++++++++++++++ test/update-policy.sh | 389 +++++++++++++++++++++++++++++ 10 files changed, 643 insertions(+), 10 deletions(-) create mode 100755 script/update-policy create mode 100755 test/update-policy.sh diff --git a/.dockerignore b/.dockerignore index 7f133c5..d3cc0a8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -76,3 +76,7 @@ **/.idea **/.vscode **/*.sublime-* + +# This repository's own entries, such as its build outputs, go below +# this comment, and a re-vendor keeps them. Anchor a binary built at the +# root: `/myapp`, never `**/myapp`, which also matches `cmd/myapp/`. diff --git a/Dockerfile b/Dockerfile index 43fadc9..e490670 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ RUN script/bootstrap COPY . . -RUN echo "No tests defined." +RUN timeout 90 test/update-policy.sh # Development environment, and the last stage: a plain `docker build .` # names no target and so builds this one. Nothing is wanted from the two diff --git a/Makefile b/Makefile index dde6752..6488473 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks +.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks update-policy # Makefile targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -30,3 +30,6 @@ docker: hooks: @script/install-precommit + +update-policy: + @script/update-policy diff --git a/README.md b/README.md index e2cdfe0..740c99a 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,8 @@ alpine. We provide: `script/docker` for the image tag - `script/test` — `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target test --output type=cacheonly .`, - building the `test` phase of the `Dockerfile` without writing an image (no - tests defined here) + building the `test` phase of the `Dockerfile`, which runs + `test/update-policy.sh`, without writing an image - `script/lint` — `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target lint --output type=cacheonly .`, building the `lint` phase, which runs prettier over the markdown files, @@ -141,6 +141,9 @@ alpine. We provide: `script/check` - `script/install-precommit` — installs the git pre-commit hook (our own extension); `make hooks` shims to it +- `script/update-policy` — fetch the vendored policy files from this repo, + commit them and push (our own extension); it is for the repos that vendor them + and stops when run here `make hooks` installs the pre-commit hook that runs `script/precommit`. diff --git a/TODO.md b/TODO.md index d68a307..3eb1e5b 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,12 @@ fmt-check, and commit. # Completed Steps +- 2026-11-09: `script/update-policy` and `make update-policy` update a repo's + vendored policy files with one command + (https://git.eeqj.de/sneak/prompts/issues/162). They fetch the files from + `prompts`, keep the repo's own entries, commit and push. The canonical + `.dockerignore` now ends with the comment that the repo's own entries go + below. The template repositories get the script in their own issues. - 2026-11-07: Go code is now formatted with `gofmt` and then `golines -m 77` (https://git.eeqj.de/sneak/prompts/issues/160). `prompts/REPO_POLICIES.md` says how `script/fmt`, `script/fmt-check` and `script/bootstrap` do it and diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index 51933a7..6557c14 100644 --- a/prompts/EXISTING_REPO_CHECKLIST.md +++ b/prompts/EXISTING_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: Existing Repo Checklist -last_modified: 2026-11-07 +last_modified: 2026-11-09 --- Use this checklist when beginning work in a repo that may not yet conform to our @@ -205,12 +205,18 @@ with your task. - [ ] `Makefile` exists in root — reference `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile` - [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, - `hooks` + `hooks`, `update-policy` - [ ] Target implementations live in `script/` (scripts-to-rule-them-all); Makefile targets are thin shims calling them — model scripts at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/` - [ ] `script/precommit` exists and the pre-commit hook (installed by `script/install-precommit`, shimmed by `make hooks`) runs it +- [ ] `script/update-policy` exists, identical to the model script, and + `make update-policy` shims to it. Before its first use, the repo's own + entries in `.gitignore`, `.editorconfig` and `.dockerignore` go below the + comment that ends each canonical file. An entry above that comment which + the canonical file lacks makes the script stop before it writes anything, + naming the file and the entry. - [ ] README has an **Entrypoints** section documenting the `script/` entrypoints and linking the standard - [ ] `script/lint` and `script/test` each run diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index 0332076..ff769b1 100644 --- a/prompts/NEW_REPO_CHECKLIST.md +++ b/prompts/NEW_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: New Repo Checklist -last_modified: 2026-11-07 +last_modified: 2026-11-09 --- Use this checklist when creating a new repository from scratch. Follow the steps @@ -62,6 +62,8 @@ Template files can be fetched from: `CLAUDE.md` or `.claude/`, and never separate memory files - [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` + - The repo's own entries go at the end of the file, where a re-vendor keeps + them. - Extend `.dockerignore` with the repo's own host-built artifacts, giving every depth-independent pattern a `**/` prefix — but write a repo-root binary anchored, `/myapp` and never `**/myapp`, which would also match @@ -264,6 +266,9 @@ are thin shims calling them. Model scripts: - [ ] `script/install-precommit` — installs the pre-commit hook that runs `script/precommit` - [ ] `make hooks` — shims to `script/install-precommit` +- [ ] `script/update-policy` / `make update-policy` — fetches the vendored + policy files from the `prompts` repo, commits them and pushes; identical + across repos - [ ] README **Entrypoints** section documents the scripts and links the standard diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index ffceb30..528bcfa 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-11-07 +last_modified: 2026-11-09 --- This document covers repository structure, tooling, and workflow standards. Code @@ -93,6 +93,8 @@ style conventions are in separate documents: - `make check` (runs `test`, `lint`, `fmt-check`) - `make docker` - `make hooks` (installs pre-commit hook) + - `make update-policy` (fetches the vendored policy files, commits and + pushes) A model Makefile is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. @@ -109,6 +111,7 @@ style conventions are in separate documents: - `script/fmt-check` - `script/check` - `script/docker` + - `script/update-policy` The scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in minimal containers; alpine images, for example, have no bash. Locate @@ -182,12 +185,15 @@ style conventions are in separate documents: host. On a pristine checkout with nothing installed, the run dies there, after the containerised gates have passed. - Four further scripts are our own extensions to the standard: + Five further scripts are our own extensions to the standard: - `script/check` runs `script/test`, `script/lint` and `script/fmt-check`. - `script/precommit` is what the git pre-commit hook runs, and it calls `script/check`. - `script/install-precommit` installs the git pre-commit hook. The `make hooks` target shims to it. + - `script/update-policy` fetches the vendored policy files from the + `prompts` repo, commits them and pushes. The `make update-policy` target + shims to it. - `script/projectname` (literally that filename) simply outputs the project's name. @@ -927,6 +933,17 @@ style conventions are in separate documents: - Make all changes on a feature branch. You can do whatever you want on a feature branch. +- A repo updates its policy files with one command: `make update-policy`. It + runs `script/update-policy`, which fetches each vendored file the repo carries + from the `prompts` repo, commits them and pushes. It uses the head of `main`, + or the commit given by a full hash: `script/update-policy `. The list of + vendored files is at the top of that script. The script replaces each file and + keeps only the repo's own entries: + - in `.gitignore`, `.editorconfig` and `.dockerignore`: the lines below the + comment that ends the canonical file + - in `.golangci.yml`: the entries the repo added to the `deny` list of the + `test-support` depguard rule + - `.golangci.yml` is standardized. The vendored copy in a consuming repo must _NEVER_ be modified by an agent. Fetch it from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it @@ -1152,7 +1169,7 @@ style conventions are in separate documents: - `Makefile` - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, - `install-precommit`) + `install-precommit`, `update-policy`) - `Dockerfile`, `.dockerignore` - `.gitea/workflows/check.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml` diff --git a/script/update-policy b/script/update-policy new file mode 100755 index 0000000..0e09f33 --- /dev/null +++ b/script/update-policy @@ -0,0 +1,200 @@ +#!/bin/sh +# script/update-policy: replace this repo's vendored policy files with +# the canonical ones from the prompts repo, commit them and push. +# Identical in all repos. Our own extension to +# scripts-to-rule-them-all. +# +# Usage: script/update-policy [full commit hash of prompts] +# Without a hash it uses the head of the prompts main branch. +# +# The script writes nothing until every file has been fetched and +# merged. A repo's own entries are kept: +# - .gitignore, .editorconfig, .dockerignore: the lines below the +# canonical file's last line. An entry above that line which the +# canonical file lacks stops the script. +# - .golangci.yml: the entries the repo added to the deny list of the +# test-support rule +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +PROMPTS_URL="https://git.eeqj.de/sneak/prompts" + +# Paths in the prompts repo. A file this repo does not carry is skipped. +VENDORED_FILES=" +.dockerignore +.editorconfig +.gitea/workflows/check.yml +.gitignore +.golangci.yml +.prettierrc +prompts/API_POLICIES.md +prompts/CODE_STYLEGUIDE.md +prompts/CODE_STYLEGUIDE_GO.md +prompts/CODE_STYLEGUIDE_JS.md +prompts/CODE_STYLEGUIDE_PYTHON.md +prompts/EXISTING_REPO_CHECKLIST.md +prompts/GO_HTTP_SERVER_CONVENTIONS.md +prompts/LLM_PROSE_TELLS.md +prompts/NEW_REPO_CHECKLIST.md +prompts/REPO_POLICIES.md +" + +die() { + echo "update-policy: $*" >&2 + exit 1 +} + +# local_path : print where this repo keeps the file, or +# nothing if git does not track it here. A prompts/NAME.md lands at +# docs/NAME.md or NAME.md. +local_path() { + name="${1#prompts/}" + if [ "$name" != "$1" ] && [ -n "$(git ls-files -- "docs/$name")" ]; then + echo "docs/$name" + elif [ -n "$(git ls-files -- "$name")" ]; then + echo "$name" + fi +} + +# keep_lines_below_last : append to the fetched file +# whatever follows its last line in the local file. Stop if a line above +# that one is missing from the fetched file, unless it is blank or a +# comment: replacing the file would delete it. +keep_lines_below_last() { + last="$(tail -n 1 "$1")" + number="$(grep -n -F -x -- "$last" "$2" | head -n 1 | cut -d: -f1)" + if [ -z "$last" ] || [ -z "$number" ]; then + die "$2 lacks this line; add it and put the repo's own entries below it: $last" + fi + own="$(awk -v end="$number" ' + NR == FNR { fetched[$0]; next } + FNR < end && !/^#|^[ \t]*$/ && !($0 in fetched) + ' "$1" "$2")" + if [ -n "$own" ]; then + die "$2 has entries above its last canonical line that the canonical file lacks; move each below that line to keep it, or delete it: +$own" + fi + tail -n "+$((number + 1))" "$2" >> "$1" +} + +# keep_deny_entries : add to the deny list of the +# test-support rule in the fetched file the entries only the local file +# has. An entry is a "- pkg:" line, the lines under it and any comment +# above it. +keep_deny_entries() { + awk ' + function indent(line) { return match(line, /[^ ]/) - 1 } + function print_local_entries(i) { + for (i = 1; i <= count; i++) + if (!(pkg[i] in fetched_pkg)) + printf "%s", entry[i] + } + + FNR == 1 { in_rule = 0; in_list = 0 } + + in_list && /[^ ]/ && indent($0) <= list_indent { + in_list = 0 + if (FILENAME == ARGV[2]) print_local_entries() + } + + in_list { + is_entry = ($1 == "-" && $2 == "pkg:") + if (FILENAME == ARGV[2]) { + if (is_entry) fetched_pkg[$3] + print + } else if (is_entry) { + count++ + pkg[count] = $3 + entry[count] = comment $0 "\n" + comment = "" + } else if (/^ *#/) { + comment = comment $0 "\n" + } else if (count) { + entry[count] = entry[count] $0 "\n" + } + next + } + + /^ *test-support:/ { in_rule = 1 } + in_rule && /^ *deny:/ { + in_rule = 0 + in_list = 1 + list_indent = indent($0) + if (FILENAME == ARGV[2]) found = 1 + } + FILENAME == ARGV[2] { print } + + END { + if (in_list) print_local_entries() + exit !found + } + ' "$2" "$1" > "$1.merged" || + die "the fetched $2 has no deny list in its test-support rule" + mv "$1.merged" "$1" +} + +main() { + cd "$ROOT" + + [ "$#" -le 1 ] || die "usage: script/update-policy [full commit hash]" + + project="$(script/projectname)" + if [ "$project" = "prompts" ]; then + die "this repo is the source of the policy files" + fi + + commit="${1:-}" + if [ -z "$commit" ]; then + head_line="$(git ls-remote "$PROMPTS_URL.git" refs/heads/main)" + commit="${head_line%%[[:space:]]*}" + fi + printf '%s\n' "$commit" | grep -Eqx '[0-9a-f]{40}' || + die "not a full commit hash: '$commit'" + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + carried="" + for file in $VENDORED_FILES; do + path="$(local_path "$file")" + [ -n "$path" ] || continue + if [ -n "$(git status --porcelain -- "$path")" ]; then + die "$path has uncommitted changes" + fi + mkdir -p "$tmp/$(dirname "$path")" + curl --fail --silent --show-error --max-time 60 \ + --output "$tmp/$path" \ + "$PROMPTS_URL/raw/commit/$commit/$file" || + die "cannot fetch $file at $commit" + carried="$carried $path" + done + [ -n "$carried" ] || die "this repo carries none of the vendored files" + + for path in $carried; do + case "$path" in + .gitignore | .editorconfig | .dockerignore) + keep_lines_below_last "$tmp/$path" "$path" + ;; + .golangci.yml) + keep_deny_entries "$tmp/$path" "$path" + ;; + esac + done + + for path in $carried; do + cp "$tmp/$path" "$path" + git add -- "$path" + done + + if git diff --cached --quiet -- $carried; then + echo "update-policy: already at prompts $commit, nothing to commit" + else + git commit -m "Update policy files from prompts $commit" -- $carried + fi + + # Also pushes when there was nothing to commit, so a run after a + # failed push delivers the commit that push left behind. + git push origin HEAD || die "push failed; fix that and run this again" +} + +main "$@" diff --git a/test/update-policy.sh b/test/update-policy.sh new file mode 100755 index 0000000..afc76a8 --- /dev/null +++ b/test/update-policy.sh @@ -0,0 +1,389 @@ +#!/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, the canonical files and a stub curl that serves them, so +# nothing touches the network. +set -eu + +UPDATE_POLICY="$(cd "$(dirname "$0")/.." && pwd -P)/script/update-policy" + +# The one commit the stub curl serves; every other commit gets a 404. +export HASH=1111111111111111111111111111111111111111 + +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="" +for argument in "$@"; do + [ "$previous" = "--output" ] && output="$argument" + 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 + exit 22 +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" +} + +# 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 init -q --bare -b main "$dir/origin.git" + git init -q -b main "$dir/repo" + git -C "$dir/repo" config user.name "Test" + git -C "$dir/repo" config user.email "test@example.com" + 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 +} + +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" +} + +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 + echo "update-policy tests passed" +} + +main "$@"