diff --git a/Dockerfile b/Dockerfile index e490670..d823881 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ RUN script/bootstrap COPY . . -RUN timeout 90 test/update-policy.sh +RUN test/commit-upstream-policy-files.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 6488473..29f7056 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks update-policy +.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks write-upstream-policy-files commit-upstream-policy-files # Makefile targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -31,5 +31,8 @@ docker: hooks: @script/install-precommit -update-policy: - @script/update-policy +write-upstream-policy-files: + @script/write-upstream-policy-files + +commit-upstream-policy-files: + @script/commit-upstream-policy-files diff --git a/README.md b/README.md index 740c99a..c642584 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ alpine. We provide: - `script/test` — `docker build --build-arg CACHEBUST="$(date +%s)-$$" --target test --output type=cacheonly .`, building the `test` phase of the `Dockerfile`, which runs - `test/update-policy.sh`, without writing an image + `test/commit-upstream-policy-files.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,9 +141,14 @@ 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 +- `script/write-upstream-policy-files` — fetch the vendored policy files from + this repo over HTTP and write them into a repo (our own extension). It writes + into the repo given as its argument, or its own repo with no argument. It + stops when its target is this repo. +- `script/commit-upstream-policy-files` — fetch the current + `script/write-upstream-policy-files`, run it on its own repo and commit the + result without pushing (our own extension). It is for the repos that vendor + the policy files. It stops when run here. `make hooks` installs the pre-commit hook that runs `script/precommit`. diff --git a/TODO.md b/TODO.md index 3eb1e5b..d1b6885 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,12 @@ fmt-check, and commit. # Completed Steps +- 2026-11-09: `script/write-upstream-policy-files` writes the vendored policy + files into a repo over HTTP, itself and `script/commit-upstream-policy-files` + among them (https://git.eeqj.de/sneak/prompts/issues/165). + `make commit-upstream-policy-files` replaces `make update-policy`. It fetches + the current `script/write-upstream-policy-files`, runs it and commits without + pushing. The template repositories get both scripts in their own issues. - 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 diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index 8c3849d..d329e28 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-09 +last_modified: 2026-11-11 --- Use this checklist when beginning work in a repo that may not yet conform to our @@ -205,21 +205,30 @@ 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`, `update-policy` + `hooks`, `commit-upstream-policy-files` - [ ] 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 has never held makes the script stop before it writes - anything, naming the file and the entry. It stops the same way on a line - in `.golangci.yml` that the canonical file has never held, apart from - comments and the repo's entries in the `deny` list of the `test-support` - rule. +- [ ] `script/write-upstream-policy-files` and + `script/commit-upstream-policy-files` exist, identical to the model + scripts, and `make commit-upstream-policy-files` shims to + `script/commit-upstream-policy-files`. `make commit-upstream-policy-files` + commits without pushing, so a person reviews and pushes the commit. A + `.gitignore` or `.dockerignore` without the comment that ends the + canonical file needs no preparation. The script moves each line the + canonical file has never held to below the canonical content. Blank lines + and comments are dropped. Before its first use, add that comment to an + `.editorconfig` that has lines of its own, and put those lines below it. + Otherwise the script stops before it writes anything, naming the file and + each line. In a file that has the comment, an entry above it which the + canonical file has never held also stops the script. It stops the same way + on a line in `.golangci.yml` that the canonical file has never held, apart + from comments and the repo's entries in the `deny` list of the + `test-support` rule. It also stops on a repo's own + `script/write-upstream-policy-files` or + `script/commit-upstream-policy-files`. - [ ] 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 ff769b1..be79a8c 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-09 +last_modified: 2026-11-11 --- Use this checklist when creating a new repository from scratch. Follow the steps @@ -266,9 +266,13 @@ 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 +- [ ] `script/write-upstream-policy-files` — fetches the vendored policy files + from the `prompts` repo over HTTP and writes them into the repo; identical across repos +- [ ] `script/commit-upstream-policy-files` / + `make commit-upstream-policy-files` — fetches the current + `script/write-upstream-policy-files`, runs it on the repo and commits the + result without pushing; 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 528bcfa..d1927e9 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-11-09 +last_modified: 2026-11-11 --- This document covers repository structure, tooling, and workflow standards. Code @@ -93,8 +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) + - `make commit-upstream-policy-files` (brings the repo up to standards and + commits) A model Makefile is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. @@ -111,7 +111,8 @@ style conventions are in separate documents: - `script/fmt-check` - `script/check` - `script/docker` - - `script/update-policy` + - `script/write-upstream-policy-files` + - `script/commit-upstream-policy-files` 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 @@ -185,15 +186,19 @@ style conventions are in separate documents: host. On a pristine checkout with nothing installed, the run dies there, after the containerised gates have passed. - Five further scripts are our own extensions to the standard: + Six 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/write-upstream-policy-files` fetches the vendored policy files + from the `prompts` repo over HTTP and writes them into the repo it is + given, or its own repo with no argument. + - `script/commit-upstream-policy-files` fetches the current + `script/write-upstream-policy-files` from the `prompts` repo, runs it on + its own repo and commits the result. It does not push. The + `make commit-upstream-policy-files` target shims to it. - `script/projectname` (literally that filename) simply outputs the project's name. @@ -933,14 +938,26 @@ 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: +- One command brings a repo up to standards: + `make commit-upstream-policy-files`. It runs + `script/commit-upstream-policy-files`, which fetches the current + `script/write-upstream-policy-files` from the head of `main` in the `prompts` + repo and runs it on the repo. It then commits the files that changed and does + not push, so a person reviews and pushes the commit. + + `script/write-upstream-policy-files` fetches each vendored file from + `prompts` over HTTP and writes it into the repo. It skips a language's files + when git tracks no source file of that language. A file the repo lacks is + written whole. A `prompts/NAME.md` goes to `docs/NAME.md`, unless the repo + keeps it at `NAME.md`. It also writes `script/write-upstream-policy-files` + and `script/commit-upstream-policy-files` into the repo, so a repo updated + once can update itself. The list of vendored files is at the top of + `script/write-upstream-policy-files`. 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 a `.gitignore` or `.dockerignore` lacking that comment: every entry the + canonical file has never held - in `.golangci.yml`: the entries the repo added to the `deny` list of the `test-support` depguard rule @@ -1169,7 +1186,8 @@ style conventions are in separate documents: - `Makefile` - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, - `install-precommit`, `update-policy`) + `install-precommit`, `write-upstream-policy-files`, + `commit-upstream-policy-files`) - `Dockerfile`, `.dockerignore` - `.gitea/workflows/check.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml` diff --git a/script/commit-upstream-policy-files b/script/commit-upstream-policy-files new file mode 100755 index 0000000..66987f0 --- /dev/null +++ b/script/commit-upstream-policy-files @@ -0,0 +1,45 @@ +#!/bin/sh +# script/commit-upstream-policy-files: bring this repo up to standards. +# It fetches the current script/write-upstream-policy-files from the +# prompts repo over HTTP, runs it on this repo and commits the files it +# wrote. It does not push. Identical in all repos. Our own extension to +# scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +WRITE_UPSTREAM_POLICY_FILES_URL="https://git.eeqj.de/sneak/prompts/raw/branch/main/script/write-upstream-policy-files" + +die() { + echo "commit-upstream-policy-files: $*" >&2 + exit 1 +} + +main() { + [ "$#" -eq 0 ] || die "usage: script/commit-upstream-policy-files" + + cd "$ROOT" + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + curl --fail --silent --show-error --max-time 60 \ + --output "$tmp/write-upstream-policy-files" \ + "$WRITE_UPSTREAM_POLICY_FILES_URL" || + die "cannot fetch $WRITE_UPSTREAM_POLICY_FILES_URL" + + sh "$tmp/write-upstream-policy-files" "$ROOT" > "$tmp/written" + commit="$(head -n 1 "$tmp/written")" + paths="$(tail -n +2 "$tmp/written")" + + for path in $paths; do + git add -- "$path" + done + + if git diff --cached --quiet -- $paths; then + echo "commit-upstream-policy-files: already at prompts $commit, nothing to commit" + else + git commit -m "Update policy files from prompts $commit" -- $paths + fi +} + +main "$@" diff --git a/script/update-policy b/script/update-policy deleted file mode 100755 index eaf4bea..0000000 --- a/script/update-policy +++ /dev/null @@ -1,222 +0,0 @@ -#!/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 has never held stops the script. -# - .golangci.yml: the entries the repo added to the deny list of the -# test-support rule. Any other line the canonical file has never -# held stops the script, unless it is blank or a comment. -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 -} - -# local_only_lines [end]: print each line of the local -# file, above line end if given, that the canonical file has never held: -# not in the fetched file or in .former, the lines earlier -# commits removed from it. Blank lines and comments do not count. -local_only_lines() { - cat "$1" "$1.former" | awk -v end="${3:-}" ' - NR == FNR { held[$0]; next } - (end == "" || FNR < end) && !/^[ \t]*(#|$)/ && !($0 in held) - ' - "$2" -} - -# keep_lines_below_last : append to the fetched file -# whatever follows its last line in the local file. Stop if the canonical -# file has never held a line above that one, 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="$(local_only_lines "$1" "$2" "$number")" - 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. Stop if the local file still has a line that neither the -# result nor the canonical file has ever held, unless it is blank or a -# comment: replacing the file would delete 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 - } - - in_rule && /^ *[^ #]/ && indent($0) <= rule_indent { in_rule = 0 } - /^ *test-support:/ { in_rule = 1; rule_indent = indent($0) } - 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" - own="$(local_only_lines "$1" "$2")" - if [ -n "$own" ]; then - die "$2 has lines the canonical file has never held; only the deny list of the test-support rule may differ from canonical: -$own" - fi -} - -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 - - git clone --quiet --bare "$PROMPTS_URL.git" "$tmp/prompts.git" || - die "cannot clone $PROMPTS_URL.git" - - 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" - git -C "$tmp/prompts.git" log -p --format= "$commit" -- "$file" | - sed -n 's/^-//p' > "$tmp/$path.former" - 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/script/write-upstream-policy-files b/script/write-upstream-policy-files new file mode 100755 index 0000000..e76200e --- /dev/null +++ b/script/write-upstream-policy-files @@ -0,0 +1,302 @@ +#!/bin/sh +# script/write-upstream-policy-files: write the current policy files +# from the prompts repo into a repo clone, over HTTP. It commits +# nothing; script/commit-upstream-policy-files does that. Identical in +# all repos. Our own extension to scripts-to-rule-them-all. +# +# Usage: script/write-upstream-policy-files [path of a repo clone] +# Without a path it updates the repo this script sits in. +# +# Standard output is the full hash of the prompts commit, then each path +# written, one per line. Messages go to standard error. +# +# 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 has never held stops the script. +# - .gitignore, .dockerignore lacking that last line: each line the +# canonical file has never held, unless it is blank or a comment, +# goes below the canonical content. +# - .editorconfig lacking that last line: it is replaced if it has no +# such line. Otherwise the script stops, because a setting means +# nothing away from its section. +# - .golangci.yml: the entries the repo added to the deny list of the +# test-support rule. Any other line the canonical file has never +# held stops the script, unless it is blank or a comment. +set -eu + +PROMPTS_URL="https://git.eeqj.de/sneak/prompts" +PROMPTS_API_URL="https://git.eeqj.de/api/v1/repos/sneak/prompts" +PROMPTS_FIRST_COMMIT="af257bef63f162a681bddc80d9b941c47a44b748" + +# Paths in the prompts repo. Every repo gets each file, written whole if +# the repo lacks it. A language's files are skipped when git tracks no +# source file of that language. +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 +script/write-upstream-policy-files +script/commit-upstream-policy-files +" + +die() { + echo "write-upstream-policy-files: $*" >&2 + exit 1 +} + +# fetch +fetch() { + curl --fail --silent --show-error --max-time 60 --output "$2" "$1" || + die "cannot fetch $1" +} + +# file_applies : fail if the file is for a language of +# which git tracks no source file in the target repo. +file_applies() { + case "$1" in + .golangci.yml | prompts/CODE_STYLEGUIDE_GO.md | \ + prompts/GO_HTTP_SERVER_CONVENTIONS.md) + [ -n "$(git ls-files -- '*.go')" ] + ;; + prompts/CODE_STYLEGUIDE_JS.md) + [ -n "$(git ls-files -- '*.js' '*.mjs' '*.ts')" ] + ;; + prompts/CODE_STYLEGUIDE_PYTHON.md) + [ -n "$(git ls-files -- '*.py')" ] + ;; + esac +} + +# local_path : print where the file goes in the target +# repo. A prompts/NAME.md stays at NAME.md if git tracks it there and not +# at docs/NAME.md; otherwise it goes to docs/NAME.md. Any other file goes +# to its own path. +local_path() { + name="${1#prompts/}" + if [ "$name" = "$1" ]; then + echo "$1" + elif [ -z "$(git ls-files -- "docs/$name")" ] && + [ -n "$(git ls-files -- "$name")" ]; then + echo "$name" + else + echo "docs/$name" + fi +} + +# former_lines : print the lines earlier commits +# removed from the file. The file's section of the patch ends at the +# next diff or the next commit. +former_lines() { + awk -v header="diff --git a/$2 b/$2" ' + /^diff --git / { inside = ($0 == header); next } + /^From / { inside = 0; next } + inside && /^-/ { print substr($0, 2) } + ' "$1" +} + +# local_only_lines [end]: print each line of the local +# file, above line end if given, that the canonical file has never held: +# not in the fetched file or in .former, the lines earlier +# commits removed from it. Blank lines and comments do not count. +local_only_lines() { + cat "$1" "$1.former" | awk -v end="${3:-}" ' + NR == FNR { held[$0]; next } + (end == "" || FNR < end) && !/^[ \t]*(#|$)/ && !($0 in held) + ' - "$2" +} + +# keep_lines_below_last : append to the fetched file +# whatever follows its last line in the local file. Stop if the canonical +# file has never held a line above that one, unless it is blank or a +# comment: replacing the file would delete it. If the local file lacks +# that last line, append each line the canonical file has never held, +# unless it is blank or a comment. An .editorconfig with such a line +# stops the script instead, because a setting means nothing away from +# its section. +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 + own="$(local_only_lines "$1" "$2")" + [ -n "$own" ] || return 0 + if [ "$2" = .editorconfig ]; then + die "$2 lacks this line and has lines the canonical file has never held; add it and put those lines below it: $last +$own" + fi + printf '%s\n' "$own" >> "$1" + return 0 + fi + own="$(local_only_lines "$1" "$2" "$number")" + 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. Stop if the local file still has a line that neither the +# result nor the canonical file has ever held, unless it is blank or a +# comment: replacing the file would delete 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 + } + + in_rule && /^ *[^ #]/ && indent($0) <= rule_indent { in_rule = 0 } + /^ *test-support:/ { in_rule = 1; rule_indent = indent($0) } + 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" + own="$(local_only_lines "$1" "$2")" + if [ -n "$own" ]; then + die "$2 has lines the canonical file has never held; only the deny list of the test-support rule may differ from canonical: +$own" + fi +} + +# stop_on_own_script : stop if the local script has a +# line the canonical file has never held, unless it is blank or a +# comment: the repo wrote that script itself, and replacing it would +# delete it. +stop_on_own_script() { + own="$(local_only_lines "$1" "$2")" + if [ -n "$own" ]; then + die "$2 has lines the canonical file has never held; rename the repo's own script to keep it: +$own" + fi +} + +main() { + [ "$#" -le 1 ] || + die "usage: script/write-upstream-policy-files [path of a repo clone]" + + target="${1:-$(dirname "$0")/..}" + cd "$target" || die "no such directory: $target" + if [ "$(git rev-parse --show-toplevel 2> /dev/null)" != "$(pwd -P)" ]; then + die "not the top of a git clone: $target" + fi + if [ -e prompts/REPO_POLICIES.md ]; then + die "this repo is the source of the policy files" + fi + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + fetch "$PROMPTS_API_URL/branches/main" "$tmp/branch.json" + commit="$(grep -Eo '"id":"[0-9a-f]{40}"' "$tmp/branch.json" | + head -n 1 | cut -d'"' -f4)" + [ -n "$commit" ] || die "no commit hash in $PROMPTS_API_URL/branches/main" + + fetch "$PROMPTS_URL/compare/$PROMPTS_FIRST_COMMIT...$commit.patch" \ + "$tmp/history.patch" + + to_write="" + for file in $VENDORED_FILES; do + file_applies "$file" || continue + path="$(local_path "$file")" + if git check-ignore -q -- "$path"; then + die "$path is ignored by git; stop ignoring it" + fi + if [ -n "$(git status --porcelain -- "$path")" ]; then + die "$path has uncommitted changes" + fi + mkdir -p "$tmp/$(dirname "$path")" + fetch "$PROMPTS_URL/raw/commit/$commit/$file" "$tmp/$path" + former_lines "$tmp/history.patch" "$file" > "$tmp/$path.former" + to_write="$to_write $path" + done + + for path in $to_write; do + [ -e "$path" ] || continue + case "$path" in + .gitignore | .editorconfig | .dockerignore) + keep_lines_below_last "$tmp/$path" "$path" + ;; + .golangci.yml) + keep_deny_entries "$tmp/$path" "$path" + ;; + script/*) + stop_on_own_script "$tmp/$path" "$path" + ;; + esac + done + + echo "$commit" + for path in $to_write; do + mkdir -p "$(dirname "$path")" + case "$path" in + script/*) + # A new name and mv, because the script running may be + # script/commit-upstream-policy-files. + cp "$tmp/$path" "$path.new" + chmod 755 "$path.new" + mv "$path.new" "$path" + ;; + *) + cp "$tmp/$path" "$path" + ;; + esac + echo "$path" + done +} + +main "$@" diff --git a/test/commit-upstream-policy-files.sh b/test/commit-upstream-policy-files.sh new file mode 100755 index 0000000..0061b63 --- /dev/null +++ b/test/commit-upstream-policy-files.sh @@ -0,0 +1,922 @@ +#!/bin/sh +# test/commit-upstream-policy-files.sh: test +# script/write-upstream-policy-files and script/commit-upstream-policy-files +# 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 + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd -P)/script" +WRITE_UPSTREAM_POLICY_FILES="$SCRIPT_DIR/write-upstream-policy-files" +COMMIT_UPSTREAM_POLICY_FILES="$SCRIPT_DIR/commit-upstream-policy-files" + +dir="" +trap 'rm -rf "$dir"' EXIT + +fail() { + echo "FAIL: $scenario: $*" >&2 + cat "$dir/output" >&2 + exit 1 +} + +# The stub stops with curl's HTTP error status on any URL containing +# FAIL_URL, and answers with no commit hash when BAD_BRANCH_JSON is set. +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" +canonical="$(dirname "$0")/../canonical" +site="https://git.eeqj.de/sneak/prompts" +first="af257bef63f162a681bddc80d9b941c47a44b748" + +not_found() { + [ -n "$fail_flag" ] && exit 22 + echo "404 page not found" > "$output" + exit 0 +} + +serve_file() { + [ -f "$canonical/$1" ] || not_found + cat "$canonical/$1" > "$output" +} + +case "$url" in + *"${FAIL_URL:-no url contains this}"*) + not_found + ;; + "https://git.eeqj.de/api/v1/repos/sneak/prompts/branches/main") + if [ -n "${BAD_BRANCH_JSON:-}" ]; then + echo '{"message":"no such branch"}' > "$output" + else + printf '{"name":"main","commit":{"id":"%s"},"x":{"id":"%s"}}\n' \ + "$HASH" 0000000000000000000000000000000000000000 > "$output" + fi + ;; + "$site/raw/branch/main/script/write-upstream-policy-files") + serve_file script/write-upstream-policy-files + ;; + "$site/compare/$first...$HASH.patch") + git -C "$canonical" format-patch --stdout --root "$HASH" > "$output" + ;; + "$site/raw/commit/$HASH/"*) + serve_file "${url#"$site/raw/commit/$HASH/"}" + ;; + *) + not_found + ;; +esac +EOF + chmod +x "$dir/bin/curl" +} + +write_canonical_files() { + mkdir -p "$dir/canonical/prompts" "$dir/canonical/script" + cp "$WRITE_UPSTREAM_POLICY_FILES" "$COMMIT_UPSTREAM_POLICY_FILES" \ + "$dir/canonical/script/" + 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" + for name in CODE_STYLEGUIDE CODE_STYLEGUIDE_GO CODE_STYLEGUIDE_JS \ + CODE_STYLEGUIDE_PYTHON EXISTING_REPO_CHECKLIST \ + GO_HTTP_SERVER_CONVENTIONS LLM_PROSE_TELLS NEW_REPO_CHECKLIST; do + echo "new $name" > "$dir/canonical/prompts/$name.md" + done + mkdir -p "$dir/canonical/.gitea/workflows" + echo "name: check" > "$dir/canonical/.gitea/workflows/check.yml" +} + +# 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 .gitea prompts script + 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 +# a Go source file and the outdated files, not yet committed, and an +# empty origin. +start_scenario() { + scenario="$1" + rm -rf "$dir" + dir="$(mktemp -d)" + : > "$dir/output" + mkdir "$dir/tmp" + write_stub_curl + write_canonical_files + + export GIT_CONFIG_GLOBAL="$dir/gitconfig" + git config --global user.name "Test" + git config --global user.email "test@example.com" + + export FAIL_URL="" + export BAD_BRANCH_JSON="" + + 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 "$WRITE_UPSTREAM_POLICY_FILES" "$COMMIT_UPSTREAM_POLICY_FILES" \ + "$dir/repo/script/" + printf '#!/bin/sh\necho example\n' > "$dir/repo/script/projectname" + chmod +x "$dir/repo/script/projectname" + mkdir -p "$dir/repo/cmd/app" + echo "package main" > "$dir/repo/cmd/app/main.go" + write_outdated_files +} + +# commit_and_push_fixture : commit those paths as they stand +# and every other shared file as the canonical file has it, so the +# script changes only those paths. Record the commit as $before. +commit_and_push_fixture() { + ( + cd "$dir/repo" + git add script cmd "$@" + git clean -q -f -x + for file in $(git -C "$dir/canonical" ls-files); do + case "$file" in + script/*) continue ;; + prompts/REPO_POLICIES.md) path="REPO_POLICIES.md" ;; + prompts/*) path="docs/${file#prompts/}" ;; + *) path="$file" ;; + esac + if [ -z "$(git ls-files -- "$path")" ]; then + mkdir -p "$(dirname "$path")" + cp "$dir/canonical/$file" "$path" + git add "$path" + fi + done + git commit -q -m "Fixture" + git push -q origin HEAD + ) + before="$(git -C "$dir/repo" rev-parse HEAD)" +} + +# remove_from_fixture : delete those paths from the repo and +# its origin, and record the commit as $before. +remove_from_fixture() { + ( + cd "$dir/repo" + git rm -q -r "$@" + git commit -q -m "Remove from fixture" + git push -q origin HEAD + ) + before="$(git -C "$dir/repo" rev-parse HEAD)" +} + +run_commit_upstream_policy_files() { + status=0 + (cd "$dir/repo" && PATH="$dir/bin:$PATH" TMPDIR="$dir/tmp" \ + script/commit-upstream-policy-files "$@") > "$dir/output" 2>&1 || + status=$? +} + +# run_write_upstream_policy_files : run the script in this +# source tree, from outside the repo it updates. +run_write_upstream_policy_files() { + status=0 + (cd "$dir" && PATH="$dir/bin:$PATH" TMPDIR="$dir/tmp" \ + "$WRITE_UPSTREAM_POLICY_FILES" "$@") > "$dir/output" 2>&1 || + status=$? +} + +head_of_repo() { + git -C "$dir/repo" rev-parse HEAD +} + +head_of_origin() { + git -C "$dir/origin.git" rev-parse refs/heads/main +} + +files_in_commit() { + git -C "$dir/repo" show --name-only --format= HEAD | tr '\n' ' ' +} + +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_same_as_canonical() { + diff "$dir/canonical/$1" "$dir/repo/$1" >&2 || fail "$1 is not canonical" +} + +expect_absent() { + [ ! -e "$dir/repo/$1" ] || fail "$1 exists" +} + +expect_executable() { + [ "$(stat -c %a "$dir/repo/$1")" = 755 ] || fail "$1 is not mode 755" +} + +expect_committed_executable() { + git -C "$dir/repo" ls-files -s -- "$1" | grep -q '^100755 ' || + fail "$1 is not committed as executable" +} + +expect_new_commit_not_pushed() { + [ "$(head_of_repo)" != "$before" ] || fail "no commit was made" + [ "$(head_of_origin)" = "$before" ] || fail "the commit was pushed" +} + +expect_no_commit() { + [ "$(head_of_repo)" = "$before" ] || fail "the repo has a new commit" + [ "$(head_of_origin)" = "$before" ] || fail "origin has a new commit" +} + +expect_nothing_written() { + [ -z "$(git -C "$dir/repo" status --porcelain)" ] || + fail "the working tree changed" + expect_no_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_commit_upstream_policy_files + 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 + + expect_new_commit_not_pushed + git -C "$dir/repo" log -1 --format=%s | grep -q -F -e "$HASH" || + fail "the commit message does not name the head of main" + [ "$(files_in_commit)" = ".dockerignore .editorconfig .gitignore .golangci.yml .prettierrc REPO_POLICIES.md " ] || + fail "the commit holds other files: $(files_in_commit)" + [ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left" +} + +test_files_the_repo_lacks_are_written_whole() { + start_scenario "files the repo lacks are written whole" + mkdir "$dir/repo/docs" + mv "$dir/repo/REPO_POLICIES.md" "$dir/repo/docs/REPO_POLICIES.md" + echo "print()" > "$dir/repo/tool.py" + echo "export {}" > "$dir/repo/web.ts" + commit_and_push_fixture docs/REPO_POLICIES.md tool.py web.ts + remove_from_fixture REPO_POLICIES.md .gitignore .golangci.yml .gitea \ + docs/API_POLICIES.md docs/CODE_STYLEGUIDE_GO.md \ + docs/CODE_STYLEGUIDE_JS.md docs/CODE_STYLEGUIDE_PYTHON.md + run_commit_upstream_policy_files + expect_success + + expect_same_as_canonical .gitignore + expect_same_as_canonical .golangci.yml + expect_same_as_canonical .gitea/workflows/check.yml + expect_file docs/REPO_POLICIES.md << 'EOF' +new policy +EOF + expect_absent REPO_POLICIES.md + expect_file docs/API_POLICIES.md << 'EOF' +new api policy +EOF + expect_absent API_POLICIES.md + expect_file docs/CODE_STYLEGUIDE_GO.md << 'EOF' +new CODE_STYLEGUIDE_GO +EOF + expect_file docs/CODE_STYLEGUIDE_JS.md << 'EOF' +new CODE_STYLEGUIDE_JS +EOF + expect_file docs/CODE_STYLEGUIDE_PYTHON.md << 'EOF' +new CODE_STYLEGUIDE_PYTHON +EOF + expect_new_commit_not_pushed +} + +test_language_files_need_a_source_file() { + start_scenario "a repo with no source file of a language gets none of its files" + commit_and_push_fixture .prettierrc + remove_from_fixture cmd .golangci.yml docs/CODE_STYLEGUIDE_GO.md \ + docs/GO_HTTP_SERVER_CONVENTIONS.md docs/CODE_STYLEGUIDE_JS.md \ + docs/CODE_STYLEGUIDE_PYTHON.md + run_commit_upstream_policy_files + expect_success + expect_absent .golangci.yml + expect_absent docs/CODE_STYLEGUIDE_GO.md + expect_absent docs/GO_HTTP_SERVER_CONVENTIONS.md + expect_absent docs/CODE_STYLEGUIDE_JS.md + expect_absent docs/CODE_STYLEGUIDE_PYTHON.md + expect_new_commit_not_pushed +} + +test_javascript_source_brings_its_styleguide() { + for extension in js mjs; do + start_scenario "a repo whose only JavaScript source is a .$extension file gets the JavaScript style guide" + echo "export {}" > "$dir/repo/web.$extension" + commit_and_push_fixture "web.$extension" + remove_from_fixture docs/CODE_STYLEGUIDE_JS.md + run_commit_upstream_policy_files + expect_success + expect_file docs/CODE_STYLEGUIDE_JS.md << 'EOF' +new CODE_STYLEGUIDE_JS +EOF + expect_new_commit_not_pushed + done +} + +test_nothing_changed() { + start_scenario "nothing changed" + cp "$dir/canonical/.prettierrc" "$dir/repo/.prettierrc" + commit_and_push_fixture .prettierrc + run_commit_upstream_policy_files + expect_success + expect_output "nothing to commit" + expect_nothing_written +} + +test_target_lacking_write_upstream_policy_files_gets_both_scripts() { + start_scenario "a repo lacking script/write-upstream-policy-files gets it, and the current script/commit-upstream-policy-files, in the commit" + rm "$dir/repo/script/write-upstream-policy-files" + echo "# An older version." >> "$dir/repo/script/commit-upstream-policy-files" + commit_and_push_fixture .prettierrc + run_commit_upstream_policy_files + expect_success + expect_same_as_canonical script/write-upstream-policy-files + expect_same_as_canonical script/commit-upstream-policy-files + expect_committed_executable script/write-upstream-policy-files + expect_committed_executable script/commit-upstream-policy-files + expect_new_commit_not_pushed + [ "$(files_in_commit)" = ".prettierrc script/commit-upstream-policy-files script/write-upstream-policy-files " ] || + fail "the commit holds other files: $(files_in_commit)" +} + +# The shell reads a script in blocks, so the padding makes it read the +# last line only after main has returned. It finds that line only if +# script/write-upstream-policy-files left the old file in place under a +# new name. The old file is an earlier canonical version, so +# script/write-upstream-policy-files replaces it. +test_commit_upstream_policy_files_replaces_itself() { + start_scenario "script/commit-upstream-policy-files replaces its own file and still exits 0" + { + cat "$dir/repo/script/commit-upstream-policy-files" + i=0 + while [ "$i" -lt 2000 ]; do + echo "# Padding." + i=$((i + 1)) + done + echo "echo 'the old file ran to its end'" + } > "$dir/merged" + cp "$dir/merged" "$dir/canonical/script/commit-upstream-policy-files" + commit_canonical + cp "$COMMIT_UPSTREAM_POLICY_FILES" \ + "$dir/canonical/script/commit-upstream-policy-files" + commit_canonical + mv "$dir/merged" "$dir/repo/script/commit-upstream-policy-files" + chmod 755 "$dir/repo/script/commit-upstream-policy-files" + commit_and_push_fixture + run_commit_upstream_policy_files + expect_success + expect_output "the old file ran to its end" + expect_same_as_canonical script/commit-upstream-policy-files + expect_executable script/commit-upstream-policy-files + expect_new_commit_not_pushed +} + +test_write_upstream_policy_files_with_path_commits_nothing() { + start_scenario "script/write-upstream-policy-files with a path writes into that clone and commits nothing" + rm "$dir/repo/script/write-upstream-policy-files" \ + "$dir/repo/script/commit-upstream-policy-files" + commit_and_push_fixture .prettierrc + run_write_upstream_policy_files "$dir/repo" + expect_success + expect_file .prettierrc << 'EOF' +{ "tabWidth": 4 } +EOF + expect_same_as_canonical script/write-upstream-policy-files + expect_same_as_canonical script/commit-upstream-policy-files + expect_executable script/write-upstream-policy-files + expect_executable script/commit-upstream-policy-files + expect_no_commit + [ "$(head -n 1 "$dir/output")" = "$HASH" ] || + fail "the first line of output is not the hash" + expect_output "script/write-upstream-policy-files" + [ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left" +} + +test_write_upstream_policy_files_without_script_directory() { + start_scenario "script/write-upstream-policy-files with a path writes both scripts into a clone with no script directory" + commit_and_push_fixture .prettierrc + remove_from_fixture script + run_write_upstream_policy_files "$dir/repo" + expect_success + expect_same_as_canonical script/write-upstream-policy-files + expect_same_as_canonical script/commit-upstream-policy-files + expect_executable script/write-upstream-policy-files + expect_executable script/commit-upstream-policy-files + expect_no_commit +} + +test_write_upstream_policy_files_without_argument_updates_own_repo() { + start_scenario "script/write-upstream-policy-files with no argument updates the repo it sits in" + commit_and_push_fixture .prettierrc + status=0 + (cd "$dir" && PATH="$dir/bin:$PATH" \ + repo/script/write-upstream-policy-files) \ + > "$dir/output" 2>&1 || status=$? + expect_success + expect_file .prettierrc << 'EOF' +{ "tabWidth": 4 } +EOF + expect_no_commit +} + +test_write_upstream_policy_files_bad_target() { + start_scenario "script/write-upstream-policy-files refuses a path that is not the top of a clone" + commit_and_push_fixture .prettierrc + run_write_upstream_policy_files "$dir/repo/script" + expect_failure + expect_output "not the top of a git clone" + run_write_upstream_policy_files "$dir/bin" + expect_failure + expect_output "not the top of a git clone" + run_write_upstream_policy_files "$dir/no-such-directory" + expect_failure + expect_output "no such directory" + run_write_upstream_policy_files "$dir/repo" extra + expect_failure + expect_output "usage:" + expect_nothing_written +} + +test_failed_fetch_stops() { + for url in \ + "/api/v1/repos/sneak/prompts/branches/main" \ + "/raw/branch/main/script/write-upstream-policy-files" \ + "/compare/" \ + "/raw/commit/"; do + start_scenario "a failed fetch of $url stops with nothing written" + commit_and_push_fixture .gitignore .prettierrc REPO_POLICIES.md + FAIL_URL="$url" + run_commit_upstream_policy_files + expect_failure + expect_output "cannot fetch" + expect_nothing_written + done +} + +test_one_missing_file_stops_all() { + start_scenario "one file missing from prompts stops before any file is written" + rm "$dir/canonical/prompts/REPO_POLICIES.md" + commit_and_push_fixture .prettierrc REPO_POLICIES.md + run_commit_upstream_policy_files + expect_failure + expect_output "cannot fetch" + expect_nothing_written +} + +test_branch_response_without_hash_stops() { + start_scenario "a branch response with no commit hash stops" + commit_and_push_fixture .prettierrc + BAD_BRANCH_JSON=yes + run_commit_upstream_policy_files + expect_failure + expect_output "no commit hash" + expect_nothing_written +} + +# 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_commit_upstream_policy_files + 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_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_commit_upstream_policy_files + expect_failure + expect_output "REPO_POLICIES.md has uncommitted changes" + expect_file REPO_POLICIES.md << 'EOF' +old policy +uncommitted +EOF + expect_no_commit +} + +test_untracked_script_stops() { + start_scenario "an untracked script/write-upstream-policy-files is not overwritten" + rm "$dir/repo/script/write-upstream-policy-files" + commit_and_push_fixture .prettierrc + echo "our own script" > "$dir/repo/script/write-upstream-policy-files" + run_write_upstream_policy_files "$dir/repo" + expect_failure + expect_output "script/write-upstream-policy-files has uncommitted changes" + expect_file script/write-upstream-policy-files << 'EOF' +our own script +EOF + expect_no_commit +} + +test_ignored_file_stops() { + start_scenario "an ignored file at a listed path is not overwritten" + commit_and_push_fixture .prettierrc + remove_from_fixture docs/API_POLICIES.md + echo "docs/API_POLICIES.md" >> "$dir/repo/.git/info/exclude" + echo "our own notes" > "$dir/repo/docs/API_POLICIES.md" + run_write_upstream_policy_files "$dir/repo" + expect_failure + expect_output "docs/API_POLICIES.md is ignored by git" + expect_file docs/API_POLICIES.md << 'EOF' +our own notes +EOF + expect_no_commit +} + +test_own_script_stops() { + for script in script/write-upstream-policy-files \ + script/commit-upstream-policy-files; do + start_scenario "a tracked $script with a line the canonical file has never held stops" + echo "echo our own script" >> "$dir/repo/$script" + commit_and_push_fixture .prettierrc + run_commit_upstream_policy_files + expect_failure + expect_output "$script has lines" + expect_output "echo our own script" + expect_nothing_written + done +} + +test_gitignore_lacks_canonical_last_line() { + start_scenario ".gitignore lacking the canonical last line keeps the repo's own line below the canonical content" + printf '*.log\n\n# A comment of our own.\n/myapp\n' > "$dir/repo/.gitignore" + commit_and_push_fixture .gitignore .prettierrc + run_commit_upstream_policy_files + expect_success + expect_file .gitignore << 'EOF' +*.log +*.tmp + +# Own entries go below this comment. +/myapp +EOF + expect_new_commit_not_pushed +} + +test_dockerignore_lacks_canonical_last_line() { + start_scenario ".dockerignore lacking the canonical last line drops a line an earlier canonical commit held" + echo "**/*.log" > "$dir/repo/.dockerignore" + expect_former_canonical_line_replaced .dockerignore ".git" + expect_same_as_canonical .dockerignore +} + +test_editorconfig_lacks_canonical_last_line() { + start_scenario ".editorconfig lacking the canonical last line with a line of its own stops" + printf 'root = true\n[*.py]\nindent_size = 2\n' > "$dir/repo/.editorconfig" + commit_and_push_fixture .editorconfig .prettierrc + run_commit_upstream_policy_files + expect_failure + expect_output ".editorconfig lacks this line" + expect_output "[*.py]" + expect_output "indent_size = 2" + expect_nothing_written +} + +test_editorconfig_lacks_canonical_last_line_no_own_line() { + start_scenario ".editorconfig lacking the canonical last line with no line of its own is replaced" + printf 'root = true\n[*]\n' > "$dir/repo/.editorconfig" + expect_former_canonical_line_replaced .editorconfig "indent_size = 8" + expect_same_as_canonical .editorconfig +} + +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_commit_upstream_policy_files + 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_commit_upstream_policy_files + 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" + mkdir "$dir/repo/prompts" + echo "policy" > "$dir/repo/prompts/REPO_POLICIES.md" + commit_and_push_fixture .prettierrc prompts/REPO_POLICIES.md + run_commit_upstream_policy_files + 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_commit_upstream_policy_files + expect_success + [ "$(files_in_commit)" = ".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_commit_upstream_policy_files_takes_no_argument() { + start_scenario "script/commit-upstream-policy-files refuses an argument" + commit_and_push_fixture .prettierrc + run_commit_upstream_policy_files "$HASH" + expect_failure + expect_output "usage:" + expect_nothing_written +} + +# 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. +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_commit_upstream_policy_files + expect_success + if grep -q -F -x -e "$2" "$dir/repo/$1"; then + fail "$1 still has: $2" + fi + expect_new_commit_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" +} + +test_former_canonical_script_line_replaced() { + start_scenario "a script/write-upstream-policy-files line an earlier canonical commit held is replaced" + expect_former_canonical_line_replaced \ + script/write-upstream-policy-files "echo an older version" + expect_same_as_canonical script/write-upstream-policy-files +} + +# A line another file's history removed does not count as held by this +# one. +test_line_removed_from_another_file_still_stops() { + start_scenario "a line only another canonical file once held stops" + { echo "*.old"; cat "$dir/canonical/.dockerignore"; } > "$dir/merged" + cp "$dir/merged" "$dir/canonical/.dockerignore" + commit_canonical + tail -n +2 "$dir/merged" > "$dir/canonical/.dockerignore" + commit_canonical + { echo "*.old"; cat "$dir/repo/.gitignore"; } > "$dir/merged" + mv "$dir/merged" "$dir/repo/.gitignore" + commit_and_push_fixture .gitignore .prettierrc + run_commit_upstream_policy_files + expect_failure + expect_output ".gitignore has entries" + expect_output "*.old" + expect_nothing_written +} + +main() { + test_own_entries_kept + test_files_the_repo_lacks_are_written_whole + test_language_files_need_a_source_file + test_javascript_source_brings_its_styleguide + test_nothing_changed + test_target_lacking_write_upstream_policy_files_gets_both_scripts + test_commit_upstream_policy_files_replaces_itself + test_write_upstream_policy_files_with_path_commits_nothing + test_write_upstream_policy_files_without_script_directory + test_write_upstream_policy_files_without_argument_updates_own_repo + test_write_upstream_policy_files_bad_target + test_failed_fetch_stops + test_one_missing_file_stops_all + test_branch_response_without_hash_stops + test_own_entry_above_closing_comment + test_uncommitted_change_stops + test_untracked_script_stops + test_ignored_file_stops + test_own_script_stops + test_gitignore_lacks_canonical_last_line + test_dockerignore_lacks_canonical_last_line + test_editorconfig_lacks_canonical_last_line + test_editorconfig_lacks_canonical_last_line_no_own_line + test_fetched_golangci_without_deny_list + test_own_golangci_setting_stops + test_source_repo_stops + test_staged_file_stays_out_of_commit + test_commit_upstream_policy_files_takes_no_argument + test_former_canonical_golangci_line_replaced + test_former_canonical_gitignore_line_replaced + test_former_canonical_script_line_replaced + test_line_removed_from_another_file_still_stops + echo "commit-upstream-policy-files tests passed" +} + +main "$@" diff --git a/test/update-policy.sh b/test/update-policy.sh deleted file mode 100755 index 08bdee5..0000000 --- a/test/update-policy.sh +++ /dev/null @@ -1,567 +0,0 @@ -#!/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 "$@"