#!/bin/sh # script/install: write the current policy files from the prompts repo # into a repo clone, over HTTP. It commits nothing; script/update-policy # does that. Identical in all repos. Our own extension to # scripts-to-rule-them-all. # # Usage: script/install [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. # - .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/install script/update-policy " die() { echo "install: $*" >&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. 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 } # 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/install [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 script/update-policy may # be the script running. cp "$tmp/$path" "$path.new" chmod 755 "$path.new" mv "$path.new" "$path" ;; *) cp "$tmp/$path" "$path" ;; esac echo "$path" done } main "$@"