script/install: write every listed file the repo lacks
check / check (push) Successful in 48s

A listed file the repo lacks is now written whole, with no keep rule.
A prompts/NAME.md goes to docs/NAME.md unless the repo keeps it at
NAME.md, and any other file goes to its own path. A language's files
are skipped when git tracks no source file of that language.

A listed path git ignores now stops the script before it writes.

README bullets split into short sentences, and a false sentence about
template repos deleted.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-09 19:38:41 +00:00
1 parent f4275b9ac0
commit 76b75fa79f
4 files changed
+166 -45

No files matched your search

+4 -4
View File
@@ -142,11 +142,11 @@ alpine. We provide:
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it extension); `make hooks` shims to it
- `script/install` — fetch the vendored policy files from this repo over HTTP - `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 and write them into a repo (our own extension). It writes into the repo given
argument (our own extension); it stops when run here 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 - `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 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 repos that vendor the policy files. It stops when run here.
`make hooks` installs the pre-commit hook that runs `script/precommit`. `make hooks` installs the pre-commit hook that runs `script/precommit`.
+8 -6
View File
@@ -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 the files that changed and does not push, so a person reviews and pushes the
commit. commit.
`script/install` fetches each vendored file the repo carries from `prompts` `script/install` fetches each vendored file from `prompts` over HTTP and
over HTTP. It also installs `script/install` and `script/update-policy` in writes it into the repo. It skips a language's files when git tracks no
the repo, so a repo updated once can update itself. Every repo made from a source file of that language. A file the repo lacks is written whole. A
template repo already has both. The list of vendored files is at the top of `prompts/NAME.md` goes to `docs/NAME.md`, unless the repo keeps it at
`script/install`. The script replaces each file and keeps only the repo's `NAME.md`. It also installs `script/install` and `script/update-policy` in
own entries: 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 - in `.gitignore`, `.editorconfig` and `.dockerignore`: the lines below the
comment that ends the canonical file comment that ends the canonical file
- in `.golangci.yml`: the entries the repo added to the `deny` list of the - in `.golangci.yml`: the entries the repo added to the `deny` list of the
+42 -20
View File
@@ -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_API_URL="https://git.eeqj.de/api/v1/repos/sneak/prompts"
PROMPTS_FIRST_COMMIT="af257bef63f162a681bddc80d9b941c47a44b748" PROMPTS_FIRST_COMMIT="af257bef63f162a681bddc80d9b941c47a44b748"
# Paths in the prompts repo. A file the target repo does not carry is # Paths in the prompts repo. Every repo gets each file, written whole if
# skipped, except the two scripts, which every repo gets. # the repo lacks it. A language's files are skipped when git tracks no
# source file of that language.
VENDORED_FILES=" VENDORED_FILES="
.dockerignore .dockerignore
.editorconfig .editorconfig
@@ -58,15 +59,36 @@ fetch() {
die "cannot fetch $1" die "cannot fetch $1"
} }
# local_path <path in prompts>: print where the target repo keeps the # file_applies <path in prompts>: fail if the file is for a language of
# file, or nothing if git does not track it there. A prompts/NAME.md # which git tracks no source file in the target repo.
# lands at docs/NAME.md or NAME.md. 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 <path in prompts>: 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() { local_path() {
name="${1#prompts/}" name="${1#prompts/}"
if [ "$name" != "$1" ] && [ -n "$(git ls-files -- "docs/$name")" ]; then if [ "$name" = "$1" ]; then
echo "docs/$name" echo "$1"
elif [ -n "$(git ls-files -- "$name")" ]; then elif [ -z "$(git ls-files -- "docs/$name")" ] &&
[ -n "$(git ls-files -- "$name")" ]; then
echo "$name" echo "$name"
else
echo "docs/$name"
fi fi
} }
@@ -177,9 +199,8 @@ $own"
# stop_on_own_script <fetched> <local>: stop if the local script has a # stop_on_own_script <fetched> <local>: stop if the local script has a
# line the canonical file has never held, unless it is blank or 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 # 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() { stop_on_own_script() {
[ -e "$2" ] || return 0
own="$(local_only_lines "$1" "$2")" own="$(local_only_lines "$1" "$2")"
if [ -n "$own" ]; then if [ -n "$own" ]; then
die "$2 has lines the canonical file has never held; rename the repo's own script to keep it: 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" \ fetch "$PROMPTS_URL/compare/$PROMPTS_FIRST_COMMIT...$commit.patch" \
"$tmp/history.patch" "$tmp/history.patch"
carried="" to_write=""
for file in $VENDORED_FILES; do for file in $VENDORED_FILES; do
case "$file" in file_applies "$file" || continue
script/*) path="$file" ;; path="$(local_path "$file")"
*) path="$(local_path "$file")" ;; if git check-ignore -q -- "$path"; then
esac die "$path is ignored by git; stop ignoring it"
[ -n "$path" ] || continue fi
if [ -n "$(git status --porcelain -- "$path")" ]; then if [ -n "$(git status --porcelain -- "$path")" ]; then
die "$path has uncommitted changes" die "$path has uncommitted changes"
fi fi
mkdir -p "$tmp/$(dirname "$path")" mkdir -p "$tmp/$(dirname "$path")"
fetch "$PROMPTS_URL/raw/commit/$commit/$file" "$tmp/$path" fetch "$PROMPTS_URL/raw/commit/$commit/$file" "$tmp/$path"
former_lines "$tmp/history.patch" "$file" > "$tmp/$path.former" former_lines "$tmp/history.patch" "$file" > "$tmp/$path.former"
carried="$carried $path" to_write="$to_write $path"
done done
for path in $carried; do for path in $to_write; do
[ -e "$path" ] || continue
case "$path" in case "$path" in
.gitignore | .editorconfig | .dockerignore) .gitignore | .editorconfig | .dockerignore)
keep_lines_below_last "$tmp/$path" "$path" keep_lines_below_last "$tmp/$path" "$path"
@@ -241,12 +263,12 @@ main() {
done done
echo "$commit" echo "$commit"
for path in $carried; do for path in $to_write; do
mkdir -p "$(dirname "$path")"
case "$path" in case "$path" in
script/*) script/*)
# A new name and mv, because script/update-policy may # A new name and mv, because script/update-policy may
# be the script running. # be the script running.
mkdir -p script
cp "$tmp/$path" "$path.new" cp "$tmp/$path" "$path.new"
chmod 755 "$path.new" chmod 755 "$path.new"
mv "$path.new" "$path" mv "$path.new" "$path"
+112 -15
View File
@@ -117,13 +117,20 @@ EOF
echo '{ "tabWidth": 4 }' > "$dir/canonical/.prettierrc" echo '{ "tabWidth": 4 }' > "$dir/canonical/.prettierrc"
echo "new policy" > "$dir/canonical/prompts/REPO_POLICIES.md" echo "new policy" > "$dir/canonical/prompts/REPO_POLICIES.md"
echo "new api policy" > "$dir/canonical/prompts/API_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 # commit_canonical: commit the canonical files as they stand. The stub
# curl serves only the newest commit; every other commit gets a 404. # curl serves only the newest commit; every other commit gets a 404.
commit_canonical() { commit_canonical() {
git -C "$dir/canonical" add .gitignore .dockerignore .editorconfig \ 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" git -C "$dir/canonical" commit -q -m "Canonical"
HASH="$(git -C "$dir/canonical" rev-parse HEAD)" HASH="$(git -C "$dir/canonical" rev-parse HEAD)"
} }
@@ -174,7 +181,8 @@ EOF
} }
# start_scenario <name>: make a new temp directory holding a repo with # start_scenario <name>: 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() { start_scenario() {
scenario="$1" scenario="$1"
rm -rf "$dir" rm -rf "$dir"
@@ -203,18 +211,46 @@ start_scenario() {
cp "$INSTALL" "$UPDATE_POLICY" "$dir/repo/script/" cp "$INSTALL" "$UPDATE_POLICY" "$dir/repo/script/"
printf '#!/bin/sh\necho example\n' > "$dir/repo/script/projectname" printf '#!/bin/sh\necho example\n' > "$dir/repo/script/projectname"
chmod +x "$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 write_outdated_files
} }
# commit_and_push_fixture <paths...>: commit those paths, remove the # commit_and_push_fixture <paths...>: commit those paths as they stand
# rest of the outdated files, and record the commit as $before. # 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() { commit_and_push_fixture() {
( (
cd "$dir/repo" 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 commit -q -m "Fixture"
git push -q origin HEAD git push -q origin HEAD
git clean -q -f -x )
before="$(git -C "$dir/repo" rev-parse HEAD)"
}
# remove_from_fixture <paths...>: 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)" before="$(git -C "$dir/repo" rev-parse HEAD)"
} }
@@ -365,25 +401,55 @@ EOF
[ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left" [ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left"
} }
test_files_not_carried_are_skipped() { test_files_the_repo_lacks_are_written_whole() {
start_scenario "files the repo does not carry skipped" start_scenario "files the repo lacks are written whole"
mkdir "$dir/repo/docs" mkdir "$dir/repo/docs"
mv "$dir/repo/REPO_POLICIES.md" "$dir/repo/docs/REPO_POLICIES.md" 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 run_update_policy
expect_success 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' expect_file docs/REPO_POLICIES.md << 'EOF'
new policy new policy
EOF EOF
expect_absent REPO_POLICIES.md expect_absent REPO_POLICIES.md
expect_file docs/API_POLICIES.md << 'EOF'
new api policy
EOF
expect_absent API_POLICIES.md expect_absent API_POLICIES.md
expect_absent docs/API_POLICIES.md expect_file docs/CODE_STYLEGUIDE_GO.md << 'EOF'
expect_absent .gitignore new CODE_STYLEGUIDE_GO
expect_absent .dockerignore EOF
expect_absent .editorconfig 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 .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 expect_new_commit_not_pushed
} }
@@ -463,6 +529,19 @@ EOF
[ -z "$(ls -A "$dir/tmp")" ] || fail "a temporary directory is left" [ -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() { test_install_without_argument_updates_own_repo() {
start_scenario "script/install with no argument updates the repo it sits in" start_scenario "script/install with no argument updates the repo it sits in"
commit_and_push_fixture .prettierrc commit_and_push_fixture .prettierrc
@@ -580,6 +659,21 @@ EOF
expect_no_commit 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() { test_own_script_stops() {
start_scenario "a tracked script/install with a line the canonical file has never held 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" echo "echo our own installer" >> "$dir/repo/script/install"
@@ -731,11 +825,13 @@ test_line_removed_from_another_file_still_stops() {
main() { main() {
test_own_entries_kept 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_nothing_changed
test_target_lacking_install_gets_both_scripts test_target_lacking_install_gets_both_scripts
test_update_policy_replaces_itself test_update_policy_replaces_itself
test_install_with_path_commits_nothing test_install_with_path_commits_nothing
test_install_without_script_directory
test_install_without_argument_updates_own_repo test_install_without_argument_updates_own_repo
test_install_bad_target test_install_bad_target
test_failed_fetch_stops test_failed_fetch_stops
@@ -744,6 +840,7 @@ main() {
test_own_entry_above_closing_comment test_own_entry_above_closing_comment
test_uncommitted_change_stops test_uncommitted_change_stops
test_untracked_script_stops test_untracked_script_stops
test_ignored_file_stops
test_own_script_stops test_own_script_stops
test_gitignore_lacks_canonical_last_line test_gitignore_lacks_canonical_last_line
test_fetched_golangci_without_deny_list test_fetched_golangci_without_deny_list