Add the GitHub and Gitea steps and split the script into library files

Dry run by default: the script reads from both hosts (tokens, owners,
names, license, SSH) and prints a plan; --apply creates the repositories
and the Gitea -> GitHub push mirror after a final yes. Choosing GitHub
applies the AGPL-3.0 license to the Gitea repository. A failed step is
reported with what exists and how to continue; nothing is ever deleted.

create-project.sh is now the entry point; the work lives in src/lib/, one
responsibility per file. .gitignore gets !src/lib (the Python template
ignores any lib/ folder). Tests grow to 599 checks, with a stub curl and
ssh, and guards for the file structure.

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
Refs #9
Refs #10
Refs #11
Refs #12
Refs #13
Refs #14

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 15:29:10 +08:00
co-authored by Claude Sonnet 5.5
parent 903c948d85
commit 736cdb42fc
30 changed files with 2269 additions and 668 deletions
+134 -10
View File
@@ -11,12 +11,18 @@
# shellcheck disable=SC2016,SC2034 # stub and snippet text is literal on purpose; OUT, ERR and STATUS are read by the test files
REPO_ROOT="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
readonly REPO_ROOT
readonly SCRIPT="$REPO_ROOT/src/create-project.sh"
readonly SRC_DIR="$REPO_ROOT/src"
readonly SCRIPT="$SRC_DIR/create-project.sh"
# Distinctive fake credentials; the tests search all output for them.
readonly FAKE_GITEA_TOKEN="giteaFAKEtoken1234567890"
readonly FAKE_GITHUB_PAT="ghpFAKEtoken1234567890"
# Answers to the prompts: name, description, visibility, Gitea owner, GitHub
# yes or no, GitHub owner, directory, plan gate.
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
TESTS_RUN=0
TESTS_FAILED=0
CURRENT_TEST=""
@@ -59,6 +65,14 @@ assert_not_contains() {
fi
}
# assert_before NAME A B: the line numbers A and B are set and A comes first.
assert_before() {
check
if [[ -z $2 || -z $3 ]] || ((10#$2 >= 10#$3)); then
fail "$1: expected the step at line '$2' before the step at line '$3'"
fi
}
assert_file_exists() {
check
if [[ ! -e $2 ]]; then
@@ -111,25 +125,133 @@ write_stub() {
chmod +x "$WORK/bin/$1"
}
# write_curl_stub: a curl that records its arguments and configuration and
# answers with STUB_CURL_STATUS (default 200) and body STUB_CURL_BODY.
# write_curl_stub: a curl that records every call and answers from the
# routes file in the work directory (see write_routes). Without a routes file
# it answers STUB_CURL_STATUS (default 200) with the body STUB_CURL_BODY.
# Records: curl.args (all arguments), curl.config (the private config file),
# curl.calls ("METHOD URL" per call) and curl.bodies (each request body).
write_curl_stub() {
write_stub curl '
printf "%s\n" "$@" >>"$STUB_DIR/curl.args"
out="" cfg=""
cat >"$WORK/bin/curl" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$@" >>"$STUB_DIR/curl.args"
out="" cfg="" data=""
while (($# > 0)); do
case "$1" in
--output) out="$2"; shift 2 ;;
--config) cfg="$2"; shift 2 ;;
--data-binary) data="${2#@}"; shift 2 ;;
*) shift ;;
esac
done
if [[ -n $cfg ]]; then cat "$cfg" >>"$STUB_DIR/curl.config"; fi
url="" method=""
if [[ -n $cfg ]]; then
cat "$cfg" >>"$STUB_DIR/curl.config"
url="$(sed -n 's/^url = "\(.*\)"$/\1/p' "$cfg")"
method="$(sed -n 's/^request = "\(.*\)"$/\1/p' "$cfg")"
fi
printf '%s %s\n' "$method" "$url" >>"$STUB_DIR/curl.calls"
if [[ -n $data && -f $data ]]; then
printf '%s %s\n%s\n' "$method" "$url" "$(cat "$data")" >>"$STUB_DIR/curl.bodies"
fi
status="${STUB_CURL_STATUS:-200}"
body="${STUB_CURL_BODY:-}"
if [[ -z $body ]]; then body="{\"ok\":true}"; fi
if [[ -n $out ]]; then printf "%s" "$body" >"$out"; fi
printf "%s" "${STUB_CURL_STATUS:-200}"
exit "${STUB_CURL_EXIT:-0}"'
if [[ -f $STUB_DIR/routes ]]; then
status=404
body="{\"message\":\"Not Found\"}"
n=0 first_unused="" last_match=""
while IFS='|' read -r m pattern st rest; do
n=$((n + 1))
if [[ $m == "$method" && $url == *"$pattern" ]]; then
last_match=$n
if [[ -z $first_unused ]] && ! grep -qx "$n" "$STUB_DIR/routes.used" 2>/dev/null; then
first_unused=$n
fi
fi
done <"$STUB_DIR/routes"
pick="${first_unused:-$last_match}"
if [[ -n $pick ]]; then
if [[ -n $first_unused ]]; then printf '%s\n' "$pick" >>"$STUB_DIR/routes.used"; fi
IFS='|' read -r _ _ status body <<<"$(sed -n "${pick}p" "$STUB_DIR/routes")"
fi
fi
if [[ $status == exit* ]]; then exit "${status#exit}"; fi
if [[ -n $out ]]; then printf '%s' "$body" >"$out"; fi
printf '%s' "$status"
exit "${STUB_CURL_EXIT:-0}"
STUB
chmod +x "$WORK/bin/curl"
}
# write_ssh_stub [EXIT]: an ssh that records its arguments and, by default,
# answers like a Gitea server that accepted the key.
write_ssh_stub() {
cat >"$WORK/bin/ssh" <<STUB
#!/usr/bin/env bash
printf '%s\n' "\$@" >>"\$STUB_DIR/ssh.args"
if [[ ${1:-0} == 0 ]]; then
echo "Hi there, gitea-user! You've successfully authenticated, but Gitea does not provide shell access."
else
echo "Permission denied (publickey)."
fi
exit ${1:-0}
STUB
chmod +x "$WORK/bin/ssh"
}
# write_routes: read the routes for the stub curl from stdin. One route per
# line: METHOD|URL-SUFFIX|STATUS|BODY. A request takes the first matching
# route that was not used yet, so repeated calls can get different answers;
# when all are used, the last match answers again. STATUS "exitN" makes curl
# fail with exit code N.
write_routes() {
cat >"$WORK/routes"
: >"$WORK/routes.used"
}
# prepend_route LINE: answer a request before the routes already written.
prepend_route() {
local rest
rest="$(cat "$WORK/routes")"
printf '%s\n%s\n' "$1" "$rest" >"$WORK/routes"
# The lines moved, so the record of used routes no longer applies.
: >"$WORK/routes.used"
}
# write_happy_routes: both hosts accept the tokens; Gitea owner TirSystem and
# GitHub owner acme-org are organizations; nothing exists yet.
write_happy_routes() {
write_routes <<'ROUTES'
GET|/api/v1/user|200|{"login":"gitea-user"}
GET|/api/v1/orgs/TirSystem|200|{"username":"TirSystem"}
GET|/api/v1/users/gitea-user/orgs/TirSystem/permissions|200|{"can_create_repository":true,"is_owner":true}
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0","name":"AGPL-3.0"}]
GET|/api/v1/repos/TirSystem/my-app|404|{"message":"not found"}
GET|api.github.com/user|200|{"login":"octo-user"}
GET|api.github.com/user/memberships/orgs/acme-org|200|{"state":"active","role":"member"}
GET|api.github.com/repos/acme-org/my-app|404|{"message":"Not Found"}
GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[]
GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[{"remote_address":"https://github.com/acme-org/my-app.git","sync_on_commit":true,"interval":"10m0s","last_error":""}]
POST|api.github.com/orgs/acme-org/repos|201|{"html_url":"https://github.com/acme-org/my-app"}
POST|/api/v1/orgs/TirSystem/repos|201|{"html_url":"https://git.example.test/TirSystem/my-app"}
POST|/api/v1/repos/TirSystem/my-app/push_mirrors|200|{"remote_address":"https://github.com/acme-org/my-app.git"}
POST|/api/v1/repos/TirSystem/my-app/push_mirrors-sync|200|{}
ROUTES
}
# setup_hosts: fixtures, stub curl and ssh, and the happy routes.
setup_hosts() {
write_fixtures
write_curl_stub
write_ssh_stub 0
write_happy_routes
}
# calls: the "METHOD URL" lines the stub curl received (empty if none).
calls() {
if [[ -f $WORK/curl.calls ]]; then
cat "$WORK/curl.calls"
fi
}
# run_cli STDIN ARGS...: run create-project.sh with answers from STDIN (a
@@ -139,6 +261,7 @@ run_cli() {
shift
STATUS=0
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
REPOFOUNDRY_SYNC_WAIT=0 \
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
STATUS=$?
OUT="$(cat "$WORK/out.txt")"
@@ -155,6 +278,7 @@ run_lib() {
printf '%s\n' "$2"
} >"$WORK/snippet.sh"
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
REPOFOUNDRY_SYNC_WAIT=0 \
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
STATUS=$?
OUT="$(cat "$WORK/out.txt")"
+8 -3
View File
@@ -23,15 +23,20 @@ failed_checks=0
run_static_checks() {
printf '== static checks\n'
bash -n "$SCRIPT" || failed_checks=$((failed_checks + 1))
local file
for file in "$SCRIPT" "$SRC_DIR"/lib/*.sh; do
bash -n "$file" || failed_checks=$((failed_checks + 1))
done
if command -v shellcheck >/dev/null 2>&1; then
shellcheck "$SCRIPT" "$TEST_DIR"/*.sh ||
# -x follows the source lines; the library files are named as well,
# because shellcheck only reports on the files it is given.
shellcheck -x "$SCRIPT" "$SRC_DIR"/lib/*.sh "$TEST_DIR"/*.sh ||
failed_checks=$((failed_checks + 1))
else
printf 'skipped: shellcheck is not installed\n'
fi
if command -v shfmt >/dev/null 2>&1; then
shfmt -i 2 -ci -d "$SCRIPT" "$TEST_DIR"/*.sh ||
shfmt -i 2 -ci -d "$SCRIPT" "$SRC_DIR"/lib/*.sh "$TEST_DIR"/*.sh ||
failed_checks=$((failed_checks + 1))
else
printf 'skipped: shfmt is not installed\n'
+475
View File
@@ -0,0 +1,475 @@
#!/usr/bin/env bash
# test-hosts.sh - tests for the GitHub and Gitea steps (MIL-002): preflight
# checks, the dry run, creating the repositories and the push mirror, reusing
# existing repositories and reporting a partial failure. A stub curl answers
# from a routes file and records every call; no real host is contacted.
# Sourced by run-tests.sh.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
run_dry() {
run_cli "$1" --config "$WORK/config.env" --env "$WORK/.env"
}
run_apply() {
run_cli "$1" --apply --config "$WORK/config.env" --env "$WORK/.env"
}
# position LINE: the number of the first recorded call equal to LINE.
position() {
calls | grep -n -x -F "$1" | head -n 1 | cut -d: -f1
}
readonly GITHUB_REPO_CALL="POST https://api.github.com/orgs/acme-org/repos"
readonly GITEA_REPO_CALL="POST https://git.example.test/api/v1/orgs/TirSystem/repos"
readonly MIRROR_CALL="POST https://git.example.test/api/v1/repos/TirSystem/my-app/push_mirrors"
readonly SYNC_CALL="POST https://git.example.test/api/v1/repos/TirSystem/my-app/push_mirrors-sync"
# ------------------------------------------------------------------ dry run
test_dry_run_prints_the_plan_and_only_reads() {
setup_hosts
run_dry "$ANSWERS_GITHUB"
assert_status "dry run" 0 "$STATUS"
assert_contains "Gitea plan" "$OUT" "Gitea repository : create (private) with the AGPL-3.0 license https://git.example.test/TirSystem/my-app"
assert_contains "GitHub plan" "$OUT" "GitHub repository : create (private), empty https://github.com/acme-org/my-app"
assert_contains "mirror plan" "$OUT" "Push mirror : Gitea -> GitHub every 10m0s"
assert_contains "origin plan" "$OUT" "Local origin : will use SSH (the SSH test passed)"
assert_contains "says it is a dry run" "$OUT" "Dry run: nothing was created. Run again with --apply"
assert_not_contains "no creation report" "$OUT" "This is what exists now"
assert_not_contains "only reads" "$(calls)" "POST"
assert_not_contains "never deletes" "$(calls)" "DELETE"
assert_not_contains "no PUT or PATCH" "$(calls)" "PATCH"
}
test_dry_run_does_not_ask_to_confirm() {
setup_hosts
run_dry "$ANSWERS_GITHUB"
assert_not_contains "no final question" "$ERR" "Create these now"
}
test_ssh_failure_means_https() {
setup_hosts
write_ssh_stub 255
run_dry "$ANSWERS_GITHUB"
assert_status "ssh failure is not fatal" 0 "$STATUS"
assert_contains "origin falls back to HTTPS" "$OUT" "will use HTTPS (SSH test: failed"
}
test_ssh_without_the_ssh_tool_is_not_fatal() {
setup_hosts
# A PATH that has the stubs and the basic tools but no ssh.
rm -f "$WORK/bin/ssh"
run_lib "" 'command() { if [[ $1 == -v && $2 == ssh ]]; then return 1; fi; builtin command "$@"; }
CONFIG[GITEA_URL]=https://git.example.test CONFIG[GITEA_SSH_PORT]=10022
test_gitea_ssh
echo "${STATE[is_ssh_ok]}|${STATE[ssh_note]}"'
assert_eq "no ssh installed" "0|not tested (ssh is not installed)" "$OUT"
}
test_ssh_uses_the_configured_port() {
setup_hosts
printf 'GITEA_SSH_PORT=2222\n' >>"$WORK/config.env"
run_dry "$ANSWERS_GITHUB"
assert_status "custom port" 0 "$STATUS"
assert_contains "port passed to ssh" "$(cat "$WORK/ssh.args")" "2222"
assert_contains "Gitea host" "$(cat "$WORK/ssh.args")" "git@git.example.test"
assert_contains "batch mode, no prompts" "$(cat "$WORK/ssh.args")" "BatchMode=yes"
assert_contains "unknown host keys are refused" "$(cat "$WORK/ssh.args")" "StrictHostKeyChecking=yes"
}
test_config_validates_ssh_port_and_interval() {
local key value expected
while IFS='|' read -r key value expected; do
printf 'GITEA_URL=https://git.example.test\n%s=%s\n' "$key" "$value" >"$WORK/c.env"
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
validate_config"
assert_status "$key=$value" 1 "$STATUS"
assert_contains "$key=$value message" "$ERR" "$expected"
done <<'EOF'
GITEA_SSH_PORT|abc|port number
GITEA_SSH_PORT|0|port number
GITEA_SSH_PORT|70000|port number
MIRROR_INTERVAL|soon|10m0s or 8h0m0s
MIRROR_INTERVAL|10|10m0s or 8h0m0s
MIRROR_INTERVAL|10 m|10m0s or 8h0m0s
EOF
}
# -------------------------------------------------------------- preflight
test_gitea_token_rejected() {
setup_hosts
prepend_route 'GET|/api/v1/user|401|{"message":"token is required"}'
run_apply "$ANSWERS_GITHUB"
assert_status "bad Gitea token" 1 "$STATUS"
assert_contains "says what failed" "$ERR" "Gitea rejected the token: authentication failed"
assert_contains "shows the server's words" "$ERR" "token is required"
assert_not_contains "nothing created" "$(calls)" "POST"
assert_not_contains "no creation report before anything was created" "$OUT" "This is what exists now"
assert_not_contains "GitHub not contacted first" "$(calls)" "api.github.com"
}
test_github_token_rejected() {
setup_hosts
prepend_route 'GET|api.github.com/user|401|{"message":"Bad credentials"}'
run_apply "$ANSWERS_GITHUB"
assert_status "bad GitHub token" 1 "$STATUS"
assert_contains "says what failed" "$ERR" "GitHub rejected the token: authentication failed"
assert_not_contains "nothing created" "$(calls)" "POST"
}
test_gitea_owner_must_exist() {
setup_hosts
prepend_route 'GET|/api/v1/orgs/TirSystem|404|{"message":"not found"}'
run_apply "$ANSWERS_GITHUB"
assert_status "unknown Gitea owner" 1 "$STATUS"
assert_contains "message" "$ERR" "neither your account (gitea-user) nor an organization"
assert_not_contains "nothing created" "$(calls)" "POST"
}
test_gitea_organization_needs_create_permission() {
setup_hosts
prepend_route 'GET|/api/v1/users/gitea-user/orgs/TirSystem/permissions|200|{"can_create_repository":false}'
run_apply "$ANSWERS_GITHUB"
assert_status "no permission" 1 "$STATUS"
assert_contains "message" "$ERR" "you may not create repositories in the Gitea organization 'TirSystem'"
}
test_github_owner_must_be_a_member() {
setup_hosts
prepend_route 'GET|api.github.com/user/memberships/orgs/acme-org|404|{"message":"Not Found"}'
run_apply "$ANSWERS_GITHUB"
assert_status "not a member" 1 "$STATUS"
assert_contains "message" "$ERR" "neither your account (octo-user) nor an organization you belong to"
prepend_route 'GET|api.github.com/user/memberships/orgs/acme-org|200|{"state":"pending"}'
run_apply "$ANSWERS_GITHUB"
assert_status "pending membership" 1 "$STATUS"
assert_contains "pending message" "$ERR" "membership of the GitHub organization 'acme-org' is not active"
}
test_license_must_be_offered_when_github_is_chosen() {
setup_hosts
prepend_route 'GET|/api/v1/licenses|200|[{"key":"MIT","name":"MIT"}]'
run_apply "$ANSWERS_GITHUB"
assert_status "no AGPL" 1 "$STATUS"
assert_contains "message" "$ERR" "does not offer the AGPL-3.0 license"
assert_not_contains "nothing created" "$(calls)" "POST"
# Without GitHub no license is needed, so the same server is fine.
setup_hosts
prepend_route 'GET|/api/v1/licenses|200|[{"key":"MIT","name":"MIT"}]'
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
run_dry "$ANSWERS_GITEA_ONLY"
assert_status "Gitea only" 0 "$STATUS"
}
test_existing_repository_with_content_stops_the_run() {
setup_hosts
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"README.md","type":"file"}]'
run_apply "$ANSWERS_GITHUB"
assert_status "repository with content" 1 "$STATUS"
assert_contains "message" "$ERR" "the Gitea repository TirSystem/my-app already exists and has content"
assert_not_contains "nothing created" "$(calls)" "POST"
}
test_network_failure_stops_before_creating() {
setup_hosts
prepend_route 'GET|/api/v1/user|exit7|'
run_apply "$ANSWERS_GITHUB"
assert_status "no connection" 1 "$STATUS"
assert_contains "message" "$ERR" "could not reach git.example.test: could not connect"
assert_not_contains "nothing created" "$(calls)" "POST"
}
test_token_for_another_github_account_is_reported() {
setup_hosts
prepend_route 'GET|api.github.com/user|200|{"login":"someone-else"}'
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "run continues" 0 "$STATUS"
assert_contains "warns" "$ERR" "GITHUB_USER is 'octo-user' but the token belongs to 'someone-else'"
assert_contains "mirror uses the account the token belongs to" "$(cat "$WORK/curl.bodies")" '"remote_username":"someone-else"'
}
# ------------------------------------------------------------ apply: success
test_apply_creates_repositories_and_mirror_in_order() {
setup_hosts
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_before "GitHub before Gitea" "$(position "$GITHUB_REPO_CALL")" "$(position "$GITEA_REPO_CALL")"
assert_before "Gitea before the mirror" "$(position "$GITEA_REPO_CALL")" "$(position "$MIRROR_CALL")"
assert_before "mirror before the sync" "$(position "$MIRROR_CALL")" "$(position "$SYNC_CALL")"
assert_contains "final report" "$OUT" "Done. This is what exists now:"
assert_contains "GitHub created" "$OUT" "GitHub repository : created https://github.com/acme-org/my-app"
assert_contains "Gitea created" "$OUT" "Gitea repository : created https://git.example.test/TirSystem/my-app"
assert_contains "mirror created" "$OUT" "Push mirror : created Gitea -> https://github.com/acme-org/my-app.git"
assert_not_contains "never deletes" "$(calls)" "DELETE"
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}
test_apply_sends_the_right_request_bodies() {
setup_hosts
run_apply "$ANSWERS_GITHUB"$'y\n'
local bodies
bodies="$(cat "$WORK/curl.bodies")"
assert_contains "GitHub name" "$bodies" '"name":"my-app"'
assert_contains "GitHub is created empty" "$bodies" '"private":true,"auto_init":false}'
assert_contains "Gitea gets the license" "$bodies" '"license":"AGPL-3.0"'
assert_contains "Gitea is initialised with it" "$bodies" '"auto_init":true'
assert_contains "default branch" "$bodies" '"default_branch":"main"'
assert_contains "description" "$bodies" '"description":"A test app"'
assert_contains "mirror target without credentials" "$bodies" '"remote_address":"https://github.com/acme-org/my-app.git"'
assert_not_contains "no credentials in the address" "$bodies" 'https://octo-user:'
assert_not_contains "no credentials in the address (at sign)" "$bodies" '@github.com'
assert_contains "mirror account" "$bodies" '"remote_username":"octo-user"'
assert_contains "the token is the mirror password" "$bodies" "\"remote_password\":\"$FAKE_GITHUB_PAT\""
assert_contains "mirror interval" "$bodies" '"interval":"10m0s"'
assert_contains "push on commit asked for" "$bodies" '"sync_on_commit":true'
}
test_apply_never_prints_or_passes_a_token() {
setup_hosts
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_not_contains "no Gitea token in output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
assert_not_contains "no GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
assert_not_contains "no token on a command line" "$(cat "$WORK/curl.args")" "$FAKE_GITEA_TOKEN"
assert_not_contains "no GitHub token on a command line" "$(cat "$WORK/curl.args")" "$FAKE_GITHUB_PAT"
assert_contains "Gitea token in the private config" "$(cat "$WORK/curl.config")" "Authorization: token $FAKE_GITEA_TOKEN"
assert_contains "GitHub token in the private config" "$(cat "$WORK/curl.config")" "Authorization: Bearer $FAKE_GITHUB_PAT"
}
test_apply_with_gitea_only() {
setup_hosts
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
run_apply "$ANSWERS_GITEA_ONLY"$'y\n'
assert_status "Gitea only" 0 "$STATUS"
assert_contains "Gitea repository created" "$(calls)" "$GITEA_REPO_CALL"
assert_contains "created empty" "$(cat "$WORK/curl.bodies")" '"auto_init":false'
assert_not_contains "no license" "$(cat "$WORK/curl.bodies")" "license"
assert_not_contains "no GitHub call" "$(calls)" "api.github.com"
assert_not_contains "no mirror call" "$(calls)" "push_mirrors"
assert_contains "GitHub not used" "$OUT" "GitHub repository : not used"
assert_contains "mirror not used" "$OUT" "Push mirror : not used"
}
test_apply_declined_creates_nothing() {
setup_hosts
run_apply "$ANSWERS_GITHUB"$'n\n'
assert_status "answered no" 0 "$STATUS"
assert_contains "says so" "$OUT" "Nothing was created."
assert_not_contains "no POST" "$(calls)" "POST"
assert_not_contains "no report" "$OUT" "This is what exists now"
}
test_apply_for_user_owners_uses_the_user_endpoints() {
setup_hosts
write_routes <<'ROUTES'
GET|/api/v1/user|200|{"login":"gitea-user"}
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0"}]
GET|/api/v1/repos/gitea-user/my-app|404|{"message":"not found"}
GET|api.github.com/user|200|{"login":"octo-user"}
GET|api.github.com/repos/octo-user/my-app|404|{"message":"Not Found"}
GET|/api/v1/repos/gitea-user/my-app/push_mirrors|200|[]
GET|/api/v1/repos/gitea-user/my-app/push_mirrors|200|[{"remote_address":"https://github.com/octo-user/my-app.git","sync_on_commit":true}]
POST|api.github.com/user/repos|201|{}
POST|/api/v1/user/repos|201|{}
POST|/api/v1/repos/gitea-user/my-app/push_mirrors|200|{}
POST|/api/v1/repos/gitea-user/my-app/push_mirrors-sync|200|{}
ROUTES
run_apply $'my-app\n\n\ngitea-user\ny\n\n\n\ny\n'
assert_status "user owners" 0 "$STATUS"
assert_contains "GitHub user endpoint" "$(calls)" "POST https://api.github.com/user/repos"
assert_contains "Gitea user endpoint" "$(calls)" "POST https://git.example.test/api/v1/user/repos"
assert_contains "mirror target of the GitHub account" "$(cat "$WORK/curl.bodies")" '"remote_address":"https://github.com/octo-user/my-app.git"'
assert_not_contains "no organization endpoint" "$(calls)" "/orgs/"
}
test_public_visibility_and_special_characters_in_the_description() {
setup_hosts
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
run_apply $'my-app\nSay "hi" \\ there\npublic\nTirSystem\nn\n\nn\ny\n'
assert_status "apply" 0 "$STATUS"
assert_contains "public" "$(cat "$WORK/curl.bodies")" '"private":false'
assert_contains "description escaped" "$(cat "$WORK/curl.bodies")" '"description":"Say \"hi\" \\ there"'
}
test_mirror_interval_comes_from_the_configuration() {
setup_hosts
printf 'MIRROR_INTERVAL=1h0m0s\n' >>"$WORK/config.env"
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "apply" 0 "$STATUS"
assert_contains "interval sent" "$(cat "$WORK/curl.bodies")" '"interval":"1h0m0s"'
assert_contains "interval planned" "$OUT" "every 1h0m0s"
}
# ------------------------------------------------------------------- reuse
test_empty_github_repository_can_be_reused() {
setup_hosts
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
run_dry "$ANSWERS_GITHUB"
assert_contains "plan offers reuse" "$OUT" "reuse the existing empty repository (you will be asked to confirm)"
assert_not_contains "dry run does not ask" "$ERR" "Reuse it"
setup_hosts
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
assert_status "reuse" 0 "$STATUS"
assert_contains "asked" "$ERR" "GitHub repository https://github.com/acme-org/my-app already exists"
assert_not_contains "GitHub repository not created again" "$(calls)" "$GITHUB_REPO_CALL"
assert_contains "reported as reused" "$OUT" "GitHub repository : reused https://github.com/acme-org/my-app"
assert_contains "the rest is created" "$(calls)" "$GITEA_REPO_CALL"
}
test_declining_the_reuse_stops_the_run() {
setup_hosts
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
run_apply "$ANSWERS_GITHUB"$'y\nn\n'
assert_status "reuse declined" 1 "$STATUS"
assert_contains "message" "$ERR" "stopped: choose another name or remove the existing repository"
assert_not_contains "nothing created" "$(calls)" "POST"
}
test_gitea_repository_with_only_the_license_can_be_reused() {
setup_hosts
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"LICENSE","type":"file","path":"LICENSE"}]'
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
assert_status "license only" 0 "$STATUS"
assert_not_contains "Gitea repository not created again" "$(calls)" "$GITEA_REPO_CALL"
assert_eq "mirror created once" "1" "$(calls | grep -c -x -F "$MIRROR_CALL")"
assert_contains "reported" "$OUT" "Gitea repository : reused https://git.example.test/TirSystem/my-app"
# Without GitHub the license is not expected, so the repository has content.
setup_hosts
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"LICENSE","type":"file"}]'
run_dry "$ANSWERS_GITEA_ONLY"
assert_status "license without GitHub" 1 "$STATUS"
assert_contains "has content" "$ERR" "already exists and has content"
}
test_reusing_an_empty_gitea_repository_warns_about_the_license() {
setup_hosts
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":true}'
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
assert_status "reuse empty Gitea repository" 0 "$STATUS"
assert_contains "warning" "$ERR" "the AGPL-3.0 license is not added to it"
}
test_an_existing_mirror_is_reused() {
setup_hosts
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[{"remote_address":"https://github.com/acme-org/my-app.git","sync_on_commit":true,"last_error":""}]'
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "mirror exists" 0 "$STATUS"
assert_eq "mirror not created twice" "" "$(position "$MIRROR_CALL")"
assert_contains "first sync still requested" "$(calls)" "$SYNC_CALL"
assert_contains "reported" "$OUT" "Push mirror : reused Gitea -> https://github.com/acme-org/my-app.git"
}
# ----------------------------------------------------------------- failures
test_partial_failure_reports_what_exists() {
setup_hosts
prepend_route 'POST|/api/v1/orgs/TirSystem/repos|422|{"message":"repository already exists"}'
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "Gitea creation fails" 1 "$STATUS"
assert_contains "reason" "$ERR" "cannot create the Gitea repository: rejected"
assert_contains "server's words" "$ERR" "repository already exists"
assert_contains "report header" "$OUT" "The run stopped before it finished. This is what exists now:"
assert_contains "GitHub was created" "$OUT" "GitHub repository : created https://github.com/acme-org/my-app"
assert_contains "Gitea failed" "$OUT" "Gitea repository : FAILED"
assert_contains "mirror not attempted" "$OUT" "Push mirror : not attempted"
assert_contains "how to continue" "$OUT" "To continue: fix the problem named above and run the same command again with --apply."
assert_contains "nothing deleted" "$OUT" "Nothing is deleted automatically."
assert_not_contains "never deletes" "$(calls)" "DELETE"
assert_not_contains "no mirror call" "$(calls)" "push_mirrors"
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}
test_failure_of_the_first_step_leaves_the_rest_not_attempted() {
setup_hosts
prepend_route 'POST|api.github.com/orgs/acme-org/repos|403|{"message":"Resource not accessible by personal access token"}'
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "GitHub creation fails" 1 "$STATUS"
assert_contains "reason" "$ERR" "cannot create the GitHub repository: the token is valid but not allowed to do this"
assert_contains "GitHub failed" "$OUT" "GitHub repository : FAILED"
assert_contains "Gitea not attempted" "$OUT" "Gitea repository : not attempted"
assert_not_contains "no Gitea call" "$(calls)" "$GITEA_REPO_CALL"
}
test_mirror_failure_keeps_the_repositories_and_says_so() {
setup_hosts
prepend_route 'POST|/api/v1/repos/TirSystem/my-app/push_mirrors|403|{"message":"push mirrors are disabled"}'
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "mirror fails" 1 "$STATUS"
assert_contains "reason" "$ERR" "cannot create the push mirror"
assert_contains "server's words" "$ERR" "push mirrors are disabled"
assert_contains "GitHub kept" "$OUT" "GitHub repository : created"
assert_contains "Gitea kept" "$OUT" "Gitea repository : created"
assert_contains "mirror failed" "$OUT" "Push mirror : FAILED"
}
test_sync_on_commit_not_applied_is_reported() {
setup_hosts
sed -i 's/"sync_on_commit":true,"interval"/"sync_on_commit":false,"interval"/' "$WORK/routes"
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "still succeeds" 0 "$STATUS"
assert_contains "warning" "$ERR" "Gitea did not apply sync_on_commit (a known server issue)"
assert_contains "report mentions the interval" "$OUT" "(syncs every 10m0s, not on every commit)"
}
test_first_sync_error_is_reported() {
setup_hosts
sed -i 's/"last_error":""/"last_error":"push failed: authentication required"/' "$WORK/routes"
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "still succeeds" 0 "$STATUS"
assert_contains "warning" "$ERR" "the first mirror sync reported: push failed: authentication required"
}
test_first_sync_request_failure_is_only_a_warning() {
setup_hosts
prepend_route 'POST|/api/v1/repos/TirSystem/my-app/push_mirrors-sync|500|{"message":"boom"}'
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "still succeeds" 0 "$STATUS"
assert_contains "warning" "$ERR" "could not ask Gitea for the first sync (HTTP 500)"
}
test_a_token_in_a_server_message_is_redacted() {
setup_hosts
prepend_route "POST|/api/v1/orgs/TirSystem/repos|422|{\"message\":\"bad credentials $FAKE_GITHUB_PAT given\"}"
run_apply "$ANSWERS_GITHUB"$'y\n'
assert_status "Gitea creation fails" 1 "$STATUS"
assert_not_contains "token redacted" "$OUT$ERR" "$FAKE_GITHUB_PAT"
assert_contains "redaction visible" "$ERR" "[redacted]"
}
# ------------------------------------------------------------ JSON helpers
test_json_has_value_and_values() {
printf '[{"key": "AGPL-3.0","name":"a"},{"key":"MIT","name":"b"}]\n' >"$WORK/l.json"
run_lib "" "json_has_value '$WORK/l.json' key AGPL-3.0 && echo yes1
json_has_value '$WORK/l.json' key MIT && echo yes2
json_has_value '$WORK/l.json' key GPL-3.0 || echo no3
json_values '$WORK/l.json' name"
assert_eq "pairs and values" $'yes1\nyes2\nno3\na\nb' "$OUT"
}
test_mirror_field_with_and_without_jq() {
local mode
printf '[{"remote_address":"https://github.com/o/a.git","sync_on_commit":false,"last_error":"x"},{"remote_address":"https://github.com/o/b.git","sync_on_commit":true,"last_error":""}]\n' >"$WORK/m.json"
for mode in 0 1; do
if ((mode)) && ! command -v jq >/dev/null 2>&1; then
continue
fi
run_lib "" "HAS_JQ=$mode
mirror_field '$WORK/m.json' https://github.com/o/a.git sync_on_commit
mirror_field '$WORK/m.json' https://github.com/o/a.git last_error"
assert_eq "first mirror (HAS_JQ=$mode)" $'false\nx' "$OUT"
done
if command -v jq >/dev/null 2>&1; then
run_lib "" "HAS_JQ=1
mirror_field '$WORK/m.json' https://github.com/o/b.git sync_on_commit"
assert_eq "the right mirror is chosen with jq" "true" "$OUT"
fi
}
+31 -18
View File
@@ -4,43 +4,47 @@
# (MIL-001 Go/No-Go criteria 2 to 4). Sourced by run-tests.sh.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
# listing: all files under the work directory except the test's own captures.
listing() {
find "$WORK" -type f ! -name out.txt ! -name err.txt ! -name snippet.sh \
! -name 'curl.*' | sort
! -name 'curl.*' ! -name 'routes*' ! -name 'ssh.args' | sort
}
test_full_run_with_github() {
write_fixtures
write_curl_stub
setup_hosts
local before after
before="$(listing)"
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
after="$(listing)"
assert_status "full run" 0 "$STATUS"
assert_contains "summary" "$OUT" "nothing has been created yet"
assert_contains "dry run" "$OUT" "Dry run: nothing was created"
assert_contains "plan" "$OUT" "create (private) with the AGPL-3.0 license"
assert_contains "Gitea link derived from config" "$OUT" "https://git.example.test/TirSystem/my-app"
assert_contains "GitHub link uses the chosen organization" "$OUT" "https://github.com/acme-org/my-app"
assert_contains "AGPL noted" "$OUT" "AGPL license applied"
assert_contains "credential state" "$OUT" "GITEA_TOKEN set, GITHUB_PAT set"
assert_not_contains "no Gitea token in output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
assert_not_contains "no GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
assert_file_missing "no network call" "$WORK/curl.args"
assert_contains "reads from Gitea" "$(calls)" "GET https://git.example.test/api/v1/user"
assert_contains "reads from GitHub" "$(calls)" "GET https://api.github.com/user"
assert_not_contains "a dry run only reads" "$(calls)" "POST"
assert_not_contains "a dry run never deletes" "$(calls)" "DELETE"
assert_eq "no file created or changed" "$before" "$after"
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}
test_full_run_without_github() {
write_fixtures
setup_hosts
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/config.env" --env "$WORK/.env"
assert_status "Gitea-only run needs no GitHub credentials" 0 "$STATUS"
assert_contains "GitHub not used" "$OUT" "GitHub : not used"
assert_not_contains "no AGPL line" "$OUT" "AGPL"
assert_contains "GITHUB_PAT not set" "$OUT" "GITHUB_PAT not set"
assert_not_contains "no call to GitHub" "$(calls)" "api.github.com"
assert_not_contains "no license lookup without GitHub" "$(calls)" "/licenses"
assert_contains "plan says GitHub is not used" "$OUT" "GitHub repository : not used"
}
test_github_chosen_without_credentials_fails() {
@@ -99,11 +103,11 @@ test_usage_errors() {
assert_contains "help shows exit codes" "$OUT" "Exit codes"
run_cli "" --version
assert_status "version" 0 "$STATUS"
assert_contains "version output" "$OUT" "RepoFoundry 0.1.0"
assert_contains "version output" "$OUT" "RepoFoundry 0.2.0"
}
test_warns_when_env_is_not_ignored_by_git() {
write_fixtures
setup_hosts
git init -q "$WORK/repo"
cp "$WORK/.env" "$WORK/repo/.env"
cp "$WORK/config.env" "$WORK/repo/config.env"
@@ -117,12 +121,20 @@ test_warns_when_env_is_not_ignored_by_git() {
test_script_uses_no_unsafe_constructs() {
local code
code="$(grep -vE '^[[:space:]]*#' "$SCRIPT")"
code="$(cat "$SCRIPT" "$SRC_DIR"/lib/*.sh | grep -vE '^[[:space:]]*#')"
assert_not_contains "no rm -rf" "$code" "rm -rf"
assert_not_contains "no rm -r" "$code" "rm -r "
assert_not_contains "no eval" "$code" "eval "
assert_not_contains "no source" "$code" "source "
assert_not_contains "no dot-source" "$code" $'\n. '
# Only the script's own library files are sourced, by a fixed path; a
# configuration file never is.
local line
while IFS= read -r line; do
check
if [[ $line != 'source "$SCRIPT_DIR/lib/'*'.sh"' ]]; then
fail "unexpected source line: $line"
fi
done < <(grep -hE '(^|[[:space:];])source ' <<<"$code")
check
if grep -Eq '^[[:space:]]*set -[A-Za-z]*x' <<<"$code"; then
fail "the script turns tracing on"
@@ -133,9 +145,9 @@ test_script_uses_no_unsafe_constructs() {
test_tracing_does_not_leak_secrets() {
# bash -x would print every assignment and command, secrets included, so
# the script switches tracing off and says so.
write_fixtures
setup_hosts
STATUS=0
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
--config "$WORK/config.env" --env "$WORK/.env" <<<"$ANSWERS_GITHUB" \
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
assert_status "run under bash -x" 0 "$STATUS"
@@ -157,7 +169,7 @@ test_termination_removes_temp_files() {
if ! mkfifo "$WORK/in" 2>/dev/null; then
return 0
fi
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
--config "$WORK/config.env" --env "$WORK/.env" <"$WORK/in" \
>/dev/null 2>&1 &
local pid=$! tries=0
@@ -182,19 +194,20 @@ test_script_lives_in_src() {
test_default_files_are_in_the_project_root() {
# A project copy: script in src/, config.env and .env one level up.
write_fixtures
setup_hosts
mkdir -p "$WORK/project/src"
cp "$SCRIPT" "$WORK/project/src/create-project.sh"
cp -R "$SRC_DIR/lib" "$WORK/project/src/lib"
cp "$WORK/config.env" "$WORK/project/config.env"
cp "$WORK/.env" "$WORK/project/.env"
STATUS=0
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
<<<"$ANSWERS_GITHUB" >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
assert_status "run with the default files" 0 "$STATUS"
assert_contains "found config.env in the project root" "$(cat "$WORK/out.txt")" "https://git.example.test/TirSystem/my-app"
# Run from another directory: the defaults follow the script, not the cwd.
STATUS=0
(cd "$WORK" && PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
<<<"$ANSWERS_GITEA_ONLY" >"$WORK/out.txt" 2>"$WORK/err.txt") || STATUS=$?
assert_status "run from another directory" 0 "$STATUS"
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# test-structure.sh - guards for the split of create-project.sh into files
# with one responsibility each. Sourced by run-tests.sh.
# shellcheck disable=SC2016 # snippet and pattern text is literal on purpose
# lib_names: the names of the files in src/lib, one per line.
lib_names() {
local file
for file in "$SRC_DIR"/lib/*.sh; do
file="${file##*/}"
printf '%s\n' "${file%.sh}"
done
}
test_every_library_file_is_loaded_and_nothing_else() {
local loaded on_disk
loaded="$(grep -E '^source "\$SCRIPT_DIR/lib/' "$SCRIPT" | sed -e 's|.*/lib/||' -e 's|\.sh"$||' | sort)"
on_disk="$(lib_names | sort)"
assert_eq "the files that are loaded are the files in src/lib" "$on_disk" "$loaded"
}
test_every_library_file_states_one_responsibility() {
local name line
for name in $(lib_names); do
line="$(head -n 4 "$SRC_DIR/lib/$name.sh" | grep -m 1 "^# $name.sh - " || true)"
check
if [[ -z $line ]]; then
fail "src/lib/$name.sh must name its responsibility in its first lines ('# $name.sh - ...')"
fi
check
if ! grep -q '^# Provides: ' "$SRC_DIR/lib/$name.sh" && [[ $name != constants ]]; then
fail "src/lib/$name.sh does not list what it provides"
fi
done
}
test_no_function_is_defined_twice() {
local duplicates
duplicates="$(cat "$SCRIPT" "$SRC_DIR"/lib/*.sh | grep -oE '^[a-z_]+\(\) \{' | sort | uniq -d)"
assert_eq "each function is defined in exactly one file" "" "$duplicates"
}
test_the_listed_functions_exist_in_their_file() {
local name list fn
for name in $(lib_names); do
list="$(sed -n 's/^# Provides: //p' "$SRC_DIR/lib/$name.sh" | tr -d ',')"
for fn in $list; do
check
if ! grep -q "^$fn() {" "$SRC_DIR/lib/$name.sh"; then
fail "src/lib/$name.sh says it provides $fn but does not define it"
fi
done
done
}
test_library_files_have_no_side_effects_when_sourced_alone() {
# A library file only defines functions and constants: running it by itself
# (after the constants) prints nothing and makes no request.
local name
write_curl_stub
for name in $(lib_names); do
if [[ $name == constants ]]; then
continue
fi
STATUS=0
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" "$BASH" -c \
'SCRIPT_DIR="$1"; PROJECT_ROOT="$2"; source "$1/lib/constants.sh"; source "$1/lib/$3.sh"' \
_ "$SRC_DIR" "$REPO_ROOT" "$name" >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
assert_status "sourcing lib/$name.sh" 0 "$STATUS"
assert_eq "lib/$name.sh prints nothing" "" "$(cat "$WORK/out.txt" "$WORK/err.txt")"
done
assert_eq "no request made by sourcing" "" "$(calls)"
}
test_the_entry_point_is_small_and_holds_no_helpers() {
local helpers
helpers="$(grep -oE '^[a-z_]+\(\) \{' "$SCRIPT" | tr -d '(){ ' | sort | tr '\n' ' ')"
assert_eq "only finish and main live in the entry point" "finish main " "$helpers"
}
test_no_library_file_is_ignored_by_git() {
# The Python template in .gitignore ignores any folder named lib/; a file
# that git ignores would silently be left out of every commit.
local name
for name in $(lib_names); do
check
if git -C "$REPO_ROOT" check-ignore -q --no-index "src/lib/$name.sh"; then
fail "src/lib/$name.sh is ignored by git; check .gitignore (!src/lib)"
fi
done
}