diff --git a/README.md b/README.md index e35efd5..3e85810 100644 --- a/README.md +++ b/README.md @@ -142,11 +142,11 @@ alpine. We provide: - `script/install-precommit` — installs the git pre-commit hook (our own extension); `make hooks` shims to it - `script/install` — fetch the vendored policy files from this repo over HTTP - and write them into the repo given as its argument, or its own repo with no - argument (our own extension); it stops when run here + 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 run here. - `script/update-policy` — fetch the current `script/install`, 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 and stops when run here + 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/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index 0a1a783..8bdaeb3 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -943,12 +943,14 @@ style conventions are in separate documents: the files that changed and does not push, so a person reviews and pushes the commit. - `script/install` fetches each vendored file the repo carries from `prompts` - over HTTP. It also installs `script/install` and `script/update-policy` in - the repo, so a repo updated once can update itself. Every repo made from a - template repo already has both. The list of vendored files is at the top of - `script/install`. The script replaces each file and keeps only the repo's - own entries: + `script/install` 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 installs `script/install` and `script/update-policy` in + the repo, so a repo updated once can update itself. The list of vendored + files is at the top of `script/install`. 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 diff --git a/script/install b/script/install index d7667db..331be19 100755 --- a/script/install +++ b/script/install @@ -24,8 +24,9 @@ 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. A file the target repo does not carry is -# skipped, except the two scripts, which every repo gets. +# 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 @@ -58,15 +59,36 @@ fetch() { die "cannot fetch $1" } -# local_path : print where the target repo keeps the -# file, or nothing if git does not track it there. A prompts/NAME.md -# lands at docs/NAME.md or NAME.md. +# 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" ] && [ -n "$(git ls-files -- "docs/$name")" ]; then - echo "docs/$name" - elif [ -n "$(git ls-files -- "$name")" ]; then + 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 } @@ -177,9 +199,8 @@ $own" # 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. A repo without the script is fine. +# delete it. stop_on_own_script() { - [ -e "$2" ] || return 0 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: @@ -210,23 +231,24 @@ main() { fetch "$PROMPTS_URL/compare/$PROMPTS_FIRST_COMMIT...$commit.patch" \ "$tmp/history.patch" - carried="" + to_write="" for file in $VENDORED_FILES; do - case "$file" in - script/*) path="$file" ;; - *) path="$(local_path "$file")" ;; - esac - [ -n "$path" ] || continue + 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" - carried="$carried $path" + to_write="$to_write $path" done - for path in $carried; do + for path in $to_write; do + [ -e "$path" ] || continue case "$path" in .gitignore | .editorconfig | .dockerignore) keep_lines_below_last "$tmp/$path" "$path" @@ -241,12 +263,12 @@ main() { done echo "$commit" - for path in $carried; do + 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. - mkdir -p script cp "$tmp/$path" "$path.new" chmod 755 "$path.new" mv "$path.new" "$path" diff --git a/test/update-policy.sh b/test/update-policy.sh index c847283..9d0451f 100755 --- a/test/update-policy.sh +++ b/test/update-policy.sh @@ -117,13 +117,20 @@ 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 prompts script + .golangci.yml .prettierrc .gitea prompts script git -C "$dir/canonical" commit -q -m "Canonical" HASH="$(git -C "$dir/canonical" rev-parse HEAD)" } @@ -174,7 +181,8 @@ EOF } # start_scenario : make a new temp directory holding a repo with -# the outdated files, not yet committed, and an empty origin. +# a Go source file and the outdated files, not yet committed, and an +# empty origin. start_scenario() { scenario="$1" rm -rf "$dir" @@ -203,18 +211,46 @@ start_scenario() { cp "$INSTALL" "$UPDATE_POLICY" "$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, remove the -# rest of the outdated files, and record the commit as $before. +# 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 "$@" + 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 - git clean -q -f -x + ) + 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)" } @@ -365,25 +401,55 @@ EOF [ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left" } -test_files_not_carried_are_skipped() { - start_scenario "files the repo does not carry skipped" +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" - commit_and_push_fixture 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_update_policy 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_absent docs/API_POLICIES.md - expect_absent .gitignore - expect_absent .dockerignore - expect_absent .editorconfig + 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_update_policy + expect_success expect_absent .golangci.yml - expect_absent .prettierrc + 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 } @@ -463,6 +529,19 @@ EOF [ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left" } +test_install_without_script_directory() { + start_scenario "script/install with a path writes both scripts into a clone with no script directory" + commit_and_push_fixture .prettierrc + remove_from_fixture script + run_install "$dir/repo" + expect_success + expect_same_as_canonical script/install + expect_same_as_canonical script/update-policy + expect_executable script/install + expect_executable script/update-policy + expect_no_commit +} + test_install_without_argument_updates_own_repo() { start_scenario "script/install with no argument updates the repo it sits in" commit_and_push_fixture .prettierrc @@ -580,6 +659,21 @@ 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_install "$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() { start_scenario "a tracked script/install with a line the canonical file has never held stops" echo "echo our own installer" >> "$dir/repo/script/install" @@ -731,11 +825,13 @@ test_line_removed_from_another_file_still_stops() { main() { test_own_entries_kept - test_files_not_carried_are_skipped + test_files_the_repo_lacks_are_written_whole + test_language_files_need_a_source_file test_nothing_changed test_target_lacking_install_gets_both_scripts test_update_policy_replaces_itself test_install_with_path_commits_nothing + test_install_without_script_directory test_install_without_argument_updates_own_repo test_install_bad_target test_failed_fetch_stops @@ -744,6 +840,7 @@ main() { 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_fetched_golangci_without_deny_list