#!/usr/bin/env bash
# Copyright 2001-2026, Paul Johnson (paul@pjcj.net)
# This software is free. It is licensed under the same terms as Perl itself.
# The latest version of this software should be available from my homepage:
# https://pjcj.net
if ((BASH_VERSINFO[0] < 5)); then
echo "❌ bash version $BASH_VERSION is too old. Please install v5 or higher."
exit 1
fi
set -eEuo pipefail
shopt -s inherit_errexit
_p() {
__l="$(hostname): $1"
shift
echo "$__l $script: $*" | tee -a "$LOG_FILE" >&2
}
pt() { _p "[TRACE] " "$*"; }
pd() { _p "[DEBUG] " "$*"; }
pi() { _p "[INFO] " "$*"; }
pw() { _p "[WARNING]" "$*"; }
pe() { _p "[ERROR] " "$*"; }
pf() {
_p "[FATAL] " "$*"
exit 1
}
usage() {
cat <<EOT
$script --help
$script --trace --verbose
$script --env=dev cpancover-controller-run-once
$script --results_dir=/cover/dev --image=pjcj/cpancover_dev cpancover-run
EOT
exit 0
}
cleanup() {
declare -r res=$?
# ((verbose)) && pi "Cleaning up"
exit "$res"
}
parse_options() {
while [[ $# -gt 0 ]]; do
case "$1" in
-d | --dryrun)
dryrun=1
shift
;;
-e | --env)
shift
env="$1"
case "$env" in
prod)
results_dir=~/cover/staging
docker_image=pjcj/cpancover
;;
dev)
results_dir=~/cover/staging_dev
docker_image=pjcj/cpancover_dev
;;
*)
pf "Unrecognised environment: $1"
;;
esac
shift
if grep -q "/docker/" /proc/self/mountinfo 2>/dev/null; then
# we're inside a docker container so use the remote staging directory
results_dir=/remote_staging
fi
;;
-f | --force)
force=1
shift
;;
-h | --help)
usage
;;
-i | --image)
docker_image="$2"
shift 2
;;
-r | --results_dir)
results_dir="$2"
shift 2
;;
-t | --trace)
set -x
shift
;;
-v | --verbose)
verbose=1
shift
;;
*)
recipe="$1"
shift
args=("$@")
break
;;
esac
done
}
recipe_options() {
echo "-d --dryrun"
echo "-e --env"
echo "-f --force"
echo "-h --help"
echo "-i --image"
echo "-r --results_dir"
echo "-t --trace"
echo "-v --verbose"
declare -F | perl -nE 'say $1 if /recipe_(.+)/'
}
setup() {
script=$(basename "$0")
readl=readlink
if command -v greadlink >&/dev/null; then readl=greadlink; fi
srcdir=$("$readl" -f "$(dirname "$0")")
readonly LOG_FILE="/tmp/$script.log"
export AUTOMATED_TESTING=1
export NONINTERACTIVE_TESTING=1
export EXTENDED_TESTING=1
PATH="$srcdir:$PATH"
docker=docker
docker_image=pjcj/cpancover
dryrun=0
env=prod
force=0
results_dir=~/cover/staging
verbose=0
parse_options "$@"
}
nice_cpus() {
perl -Iutils -MDevel::Cover::BuildUtils=nice_cpus -e "print nice_cpus"
}
recipe_nice-cpus() {
nice_cpus
}
recipe_update-copyright() {
local from="${1:-$(date +'%Y' --date='last year')}"
local to="${2:-$(date +'%Y')}"
pi "Updating copyright from $from to $to"
local me="Paul Johnson"
local files
files=$(git ls-files)
# shellcheck disable=SC2086
perl -pi -e "s/Copyright \\d+-\\K$from(, $me)/$to\$1/i" $files
# shellcheck disable=SC2086
perl -pi -e "s/Copyright $from\\K(, $me)/-$to\$1/i" $files
}
get_cpm() {
cpanm --notest App::cpm
plenv rehash
cpm=$(plenv which cpm)
}
install_dependencies() {
get_cpm
pi "Installing dependencies with $cpm"
$cpm install --workers="$(nice_cpus)" --global \
Sereal Digest::MD5 Template Pod::Coverage::CountParents \
Capture::Tiny Parallel::Iterator Template Class::XSAccessor
}
install_development_dependencies() {
get_cpm
$cpm install --workers="$(nice_cpus)" --global \
Dist::Zilla Perl::Critic Perl::Tidy App::perlimports \
Archive::Tar::Wrapper Perl::Critic::PJCJ App::Yath UUID
plenv rehash
dzil authordeps --missing |
xargs "$cpm" install --workers="$(nice_cpus)" --global
dzil listdeps --missing |
xargs "$cpm" install --workers="$(nice_cpus)" --global
}
install_test_dependencies() {
get_cpm
$cpm install --workers="$(nice_cpus)" --global \
DBM::Deep
}
recipe_install-dependencies() {
install_dependencies
}
recipe_install-development-dependencies() {
install_development_dependencies
}
recipe_install-test-dependencies() {
install_test_dependencies
}
install_perl() {
local name="${1:?No name specified}"
local version="${2:?No version specified}"
yes | plenv uninstall "$name" || true
plenv install --as "$name" -j 32 -D usedevel --noman "$version"
export PLENV_VERSION="$name"
plenv install-cpanm
install_dependencies
}
recipe_install-perl() {
local name="${1:?No name specified}"
local version="${2:?No version specified}"
install_perl "$name" "$version"
}
recipe_install-cpancover-perl() {
local version="${1:?No version specified}"
install_perl cpancover "$version"
}
recipe_install-dc-dev-perl() {
local version="${1:?No version specified}"
install_perl dc-dev "$version"
install_development_dependencies
}
recipe_all-versions() {
./utils/all_versions "$@"
}
recipe_build-and-test-all() {
perl Makefile.PL
make clean
perl Makefile.PL
make at
}
run_cpancover() {
mkdir -p "$results_dir"
local cpancover=cpancover
if [[ $(pwd) != /dc ]]; then
local root=
[[ -d /dc ]] && root=/dc/
PATH="./utils:./bin:$PATH"
perl Makefile.PL && make
cpancover="perl -Mblib=$root ${root}bin/cpancover --local"
fi
((verbose)) && cpancover="$cpancover --verbose"
((force)) && cpancover="$cpancover --force"
((dryrun)) && cpancover="$cpancover --dryrun"
local cmd
cmd="$cpancover --env $env --results_dir $results_dir"
cmd="$cmd --workers $(nice_cpus) $*"
((verbose)) && pi "$cmd"
$cmd || true
}
recipe_cpancover() {
run_cpancover "${args[@]:-}"
}
cpancover_docker_ps() {
local name="${docker_image//[^a-zA-Z0-9_.]/-}"
$docker ps -a | tail -n +2 | grep "$name-" | grep -vw "$(hostname)"
}
recipe_cpancover-docker-ps() {
cpancover_docker_ps
}
cpancover_docker_ps_ids() {
cpancover_docker_ps | awk '{ print $1 }' || true
}
recipe_cpancover-docker-kill() {
cpancover_docker_ps_ids | xargs -r "$docker" kill
}
cpancover_docker_rm() {
cpancover_docker_ps_ids | xargs -r "$docker" rm -f
$docker system prune --force
}
recipe_cpancover-docker-rm() {
cpancover_docker_rm
}
recipe_cpancover-docker-rm-image() {
$docker ps -q --filter ancestor="$docker_image" | xargs -r "$docker" stop
$docker ps -aq --filter ancestor="$docker_image" | xargs -r "$docker" rm
$docker rmi "$docker_image"
}
recipe_docker-build() {
local build="$srcdir/../docker/BUILD"
"$build" -e "$env" "${args[@]:+${args[@]}}"
}
# docker push authenticates via `docker login` credentials stored in
# ~/.docker/config.json. The Docker Hub *web API* (hub.docker.com/v2),
# used for listing and deleting tags, has its own auth — POST username +
# PAT to /v2/users/login to obtain a JWT. The CLI credentials cannot be
# reused for this.
#
# HUB_USERNAME: your Docker Hub username
# HUB_TOKEN: a Personal Access Token created at
# https://hub.docker.com/settings/security
# (needs Read & Write & Delete permissions)
Docker_hub_repo="pjcj/cpancover"
# Authenticate with Docker Hub and print a JWT.
docker_hub_auth() {
if [[ -z "${HUB_USERNAME:-}" || -z "${HUB_TOKEN:-}" ]]; then
pf "HUB_USERNAME and HUB_TOKEN environment variables are required"
fi
local token
token=$(curl -sf "https://hub.docker.com/v2/users/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$HUB_USERNAME\",\"password\":\"$HUB_TOKEN\"}" |
perl -MJSON::PP -e 'print decode_json(do { local $/; <STDIN> })->{token}')
if [[ -z "$token" ]]; then
pf "Failed to authenticate with Docker Hub"
fi
echo "$token"
}
# Fetch versioned tags from Docker Hub into the Hub_tags array (sorted
# newest-first). Sets Hub_token for callers that need it afterwards.
docker_hub_fetch_tags() {
Hub_token=$(docker_hub_auth)
Hub_tags=()
local base="https://hub.docker.com/v2/repositories"
local url="$base/$Docker_hub_repo/tags?page_size=100"
while [[ -n "$url" && "$url" != "null" ]]; do
local response
response=$(curl -sf "$url" -H "Authorization: Bearer $Hub_token")
local parsed
parsed=$(echo "$response" | perl -MJSON::PP -e '
my $d = decode_json(do { local $/; <STDIN> });
for (@{$d->{results}}) {
my $n = $_->{name};
print "$n\n" if $n =~ /^\d{4}-\d{2}-\d{2}-\d{4}-/;
}
print "---NEXT---\n";
print $d->{next} // "null", "\n";
')
while IFS= read -r t; do
[[ "$t" == "---NEXT---" ]] && break
[[ -n "$t" ]] && Hub_tags+=("$t")
done <<<"$parsed"
url=$(tail -1 <<<"$parsed")
done
# Sort newest-first (the tag format is naturally sortable)
mapfile -t Hub_tags < <(printf '%s\n' "${Hub_tags[@]}" | sort -r)
}
docker_show_tags() {
docker_hub_fetch_tags
pi "Found ${#Hub_tags[@]} versioned tags for $Docker_hub_repo:"
for t in "${Hub_tags[@]}"; do
pi " $t"
done
}
recipe_docker-show-tags() {
docker_show_tags
}
docker_prune_tags() {
local keep="${1:-20}"
docker_show_tags
if ((${#Hub_tags[@]} <= keep)); then
pi "Nothing to prune (${#Hub_tags[@]} tags <= $keep)"
return
fi
local to_delete=("${Hub_tags[@]:$keep}")
pi "Deleting ${#to_delete[@]} old tags (keeping $keep)"
for tag in "${to_delete[@]}"; do
if ((dryrun)); then
pi "[dry run] would delete $Docker_hub_repo:$tag"
else
if curl -sf -X DELETE \
"https://hub.docker.com/v2/repositories/$Docker_hub_repo/tags/$tag/" \
-H "Authorization: Bearer $Hub_token" >/dev/null; then
pi "Deleted $Docker_hub_repo:$tag"
else
pe "Failed to delete $Docker_hub_repo:$tag"
fi
fi
done
pi "Done"
}
recipe_docker-prune-tags() {
docker_prune_tags "${args[@]:-}"
}
cpancover_latest() {
if [[ -n "${CPANCOVER_TEST_MODULES:-}" ]]; then
echo "$CPANCOVER_TEST_MODULES"
elif [[ -n "${CPANCOVER_TEST_REGEX:-}" ]]; then
run_cpancover --latest | grep -E "$CPANCOVER_TEST_REGEX"
else
run_cpancover --latest
fi
}
recipe_cpancover-latest() {
cpancover_latest
}
recipe_cpancover-build-module() {
local module="${1:?No module specified}"
local v=
((verbose)) && v=--verbose
run_cpancover "$v" --local_build --docker "$docker" --workers 1 "$module"
}
docker_name() {
local name="${1:?No name specified}"
name="$docker_image-$name-$(date +%s+%N)"
echo "${name//[^a-zA-Z0-9_.]/-}"
}
recipe_docker-name() {
docker_name "${args[@]:-}"
}
cpancover_controller_command() {
local name="${1:?No name specified}"
shift
local cmd=("$@")
mkdir -p "$results_dir"
local sock=/var/run/docker.sock
local tty_flag=""
[[ -t 0 ]] && tty_flag="-t"
local env_flags=()
while IFS='=' read -r var value; do
env_flags+=(--env "$var=$value")
done < <(env | grep -E '^(CPANCOVER_|DEVEL_COVER_)' || true)
"$docker" run -i ${tty_flag:+"$tty_flag"} \
--mount "type=bind,source=$sock,target=$sock" \
--mount "type=bind,source=$results_dir,target=/remote_staging" \
${env_flags[@]:+"${env_flags[@]}"} \
--rm=false --memory=1g --name="$(docker_name "$name")" \
"$docker_image" "${cmd[@]}"
}
recipe_cpancover-controller-shell() {
cpancover_controller_command controller_bash "/bin/bash"
}
recipe_cpancover-docker-shell() {
local staging="${1:-$results_dir}"
mkdir -p "$staging"
$docker run -it \
--mount type=bind,source="$staging",target=/remote_staging \
--rm=false --memory=1g --name="$(docker_name docker)" \
"$docker_image" /bin/bash
}
# Called from Collection.pm
recipe_cpancover-docker-module() {
local module="${1:?No module specified}"
local name="${2:?No name specified}"
local staging="${3:-$results_dir}"
local module_timeout="${CPANCOVER_TIMEOUT:-1800}" # half an hour
local log_name="$name"
name=$(docker_name "$name")
mkdir -p "$staging"
((verbose)) && pi "module: $module"
local v=
((verbose)) && v=--verbose
container=$($docker run -d \
--rm=false --memory=1g --name="$name" \
"$docker_image" \
dc $v cpancover-build-module "$module")
((verbose)) && pi "container is $container"
if timeout "$module_timeout" "$docker" wait "$name" >/dev/null 2>&1; then
$docker logs "$name" | tee "$staging/$log_name.out"
local_staging="$staging/$name"
mkdir -p "$local_staging"
$docker cp "$name:/root/cover/staging" "$local_staging"
if [[ -d $local_staging ]]; then
chmod -R 755 "$local_staging"
find "$local_staging" -type f -exec chmod 644 {} \;
chown -R "$(id -un):$(id -gn)" "$local_staging"
cd "$local_staging"/* || exit
for f in *; do
if [[ -d $f && ! -d "$staging/$f" ]]; then
echo "$log_name.out.gz" >"$f/.log_ref"
mv "$f" "$staging"
fi
done
rm -r "$local_staging"
fi
else
pi "module timed out after ${module_timeout}s, killing container: $module"
$docker logs "$name" | tee "$staging/$log_name.out" 2>/dev/null || true
$docker kill "$name" >/dev/null 2>&1 || true
fi
$docker rm "$name" >/dev/null 2>&1 || true
}
cpancover_compress_dir() {
local dir="$1" do_sidecars="${2:-}" prefix="${3:-}"
shift 3 2>/dev/null || shift $#
local exclude=("$@")
rm -rf "$dir/runs" "$dir/structure"
rm -f "$dir"/digests "$dir"/digests.gz "$dir"/cover.[0-9]* \
"$dir"/cover.[0-9]*.gz "$dir"/*.lock "$dir"/*.lock.gz
local find_args=("$dir" -maxdepth 1
-type f -not -name '*.gz' -not -name '*.xz' -not -name '*.json'
-not -name 'virtual_unzipped' -not -name '.log_ref')
local name
for name in "${exclude[@]}"; do
find_args+=(-not -name "$name")
done
if find "${find_args[@]}" -print -quit | read -r; then
pi "${prefix}compressing"
find "${find_args[@]}" -print0 | xargs -0 pigz -f9
fi
[[ -n "$do_sidecars" ]] || return 0
local marker="$dir/virtual_unzipped" created=false gzfile basefile
for gzfile in "$dir"/*.gz; do
[[ -f "$gzfile" ]] || continue
[[ -f "$marker" ]] || touch "$marker"
basefile="${gzfile%.gz}"
if [[ ! -e "$basefile" ]]; then
ln "$marker" "$basefile"
created=true
fi
done
if [[ "$created" == true ]]; then
pi "${prefix}created sidecars"
fi
}
cpancover_compress_dirs() {
local only_new="$1"
local max_jobs="${CPANCOVER_COMPRESS_JOBS:-$(nice_cpus)}"
local jobs=0 dir name prefix
rm -f "$results_dir"/about.html.gz "$results_dir"/index.html.gz \
"$results_dir"/collection.css.gz "$results_dir"/cpancover.json.gz
cpancover_compress_dir "$results_dir" "" "top-level: " \
about.html index.html collection.css
# dist/ contains generated HTML that must be served uncompressed;
# delete stale .gz files so Caddy serves the fresh versions.
rm -f "$results_dir"/dist/*.gz
for dir in "$results_dir"/*/; do
[[ -d "$dir" ]] || continue
name="$(basename "$dir")"
[[ "$name" == __failed__ || "$name" == dist ]] && continue
[[ "$only_new" == true && -f "$dir/virtual_unzipped" ]] && continue
printf -v prefix "%-40.40s " "$name"
cpancover_compress_dir "$dir" sidecars "$prefix" &
((++jobs >= max_jobs)) && {
wait -n
((jobs--))
}
done
wait
}
cpancover_compress() {
pi "compressing $results_dir"
cpancover_compress_dirs false
}
cpancover_compress_new() {
pi "compressing new directories in $results_dir"
cpancover_compress_dirs true
}
recipe_cpancover-compress() {
cpancover_compress
}
recipe_cpancover-compress-new() {
cpancover_compress_new
}
cpancover_serve() {
local www_dir="$results_dir/../www_${env}"
local latest="$www_dir/latest"
mkdir -p "$www_dir"
if [[ ! -L "$latest" ]]; then
ln -s "$results_dir" "$latest"
fi
local port="${1:-8080}"
local caddyfile
caddyfile="$(mktemp)"
cat >"$caddyfile" <<EOF
:${port} {
root * $www_dir
redir / /latest/ 301
file_server {
precompressed gzip
}
}
EOF
trap 'rm -f "$caddyfile"; cleanup' EXIT
pi "serving $results_dir at http://localhost:$port/latest/"
caddy run --adapter caddyfile --config "$caddyfile"
}
recipe_cpancover-serve() {
cpancover_serve "${args[@]:+${args[@]}}"
}
cpancover_caddyfile() {
local www_dir="$results_dir/../www"
cat <<EOF
cpancover.com, www.cpancover.com {
root * $www_dir
redir / /latest/ 301
encode zstd gzip
file_server {
precompressed gzip
}
tls paul@pjcj.net
log {
output file /var/log/caddy/cpancover.com.log
format console
}
}
EOF
}
cpancover_validated_caddyfile() {
local tmpfile
tmpfile="$(mktemp)"
cpancover_caddyfile >"$tmpfile"
if ! caddy validate --adapter caddyfile --config "$tmpfile"; then
rm -f "$tmpfile"
pf "Caddyfile validation failed"
fi
echo "$tmpfile"
}
cpancover_configure_caddy() {
local caddyfile="/etc/caddy/Caddyfile"
local tmpfile
tmpfile="$(cpancover_validated_caddyfile)"
trap 'rm -f "$tmpfile"' RETURN
if diff -q "$caddyfile" "$tmpfile" >/dev/null 2>&1; then
pi "Caddyfile is already up to date"
return
fi
sudo install -m 644 -o root -g root "$tmpfile" "$caddyfile"
sudo systemctl reload caddy
pi "Caddyfile updated and Caddy reloaded"
}
recipe_cpancover-configure-caddy() {
cpancover_configure_caddy
}
cpancover_check_caddy() {
local caddyfile="/etc/caddy/Caddyfile"
local tmpfile
tmpfile="$(cpancover_validated_caddyfile)"
trap 'rm -f "$tmpfile"' RETURN
pi "Generated config validates OK"
local differ="diff -u"
command -v delta >/dev/null && differ="delta"
if diff -q "$caddyfile" "$tmpfile" >/dev/null 2>&1; then
pi "Caddyfile is up to date"
else
pw "Caddyfile differs from generated config:"
$differ "$caddyfile" "$tmpfile"
fi
}
recipe_cpancover-check-caddy() {
cpancover_check_caddy
}
recipe_cpancover-uncompress-dir() {
subdir="${1:?No subdir specified}"
find "$results_dir/$subdir/" -name __failed__ -prune -o \
-type f -name '*.gz' \
-exec gzip -df {} \;
}
cpancover_compress_old_versions() {
keep="${1:-3}"
run_cpancover --nobuild --compress_old_versions "$keep"
}
recipe_cpancover-compress-old-versions() {
cpancover_compress_old_versions "${args[@]:-}"
}
cpancover_generate_html() {
pi "Generating HTML at $(date)"
cpancover_compress_old_versions
run_cpancover --generate_html
cpancover_compress_new
local json=$results_dir/cpancover.json
local tmp=$json-tmp-$$.gz
pi "Compressing $json"
pigz <"$json" >"$tmp" && mv "$tmp" "$json.gz"
pi "Done"
}
recipe_cpancover-generate-html() {
cpancover_generate_html
}
cpancover_build() {
pi "Starting cpancover build at $(date) on $(nice_cpus) cpus"
cpancover_docker_rm
run_cpancover --build
cpancover_generate_html
pi "Finished cpancover build at $(date)"
}
cpancover_run_once() {
cpancover_latest | cpancover_build
}
recipe_cpancover-build-stdin() {
cpancover_build
}
cpancover_run_loop() {
while true; do
cpancover_run_once
sleep 600 # 10 minutes
done
}
recipe_cpancover-run-once() {
cpancover_run_once
}
recipe_cpancover-run-loop() {
cpancover_run_loop
}
recipe_cpancover-controller-run() {
local o=(--env "$env")
((verbose)) && o+=("--verbose")
cpancover_controller_command controller dc "${o[@]}" cpancover-run-loop
}
recipe_cpancover-controller-run-once() {
local o=(--env "$env")
((verbose)) && o+=("--verbose")
cpancover_controller_command controller dc "${o[@]}" cpancover-run-once
}
# Default test module - small, stable, predictable
cpancover_default_test_module="P/PJ/PJCJ/Perl-Critic-PJCJ-v0.2.4.tar.gz"
recipe_cpancover-test() {
: "${CPANCOVER_TEST_MODULES:=$cpancover_default_test_module}"
export CPANCOVER_TEST_MODULES
pi "Testing with modules: $CPANCOVER_TEST_MODULES"
cpancover_run_once
}
recipe_cpancover-controller-test() {
: "${CPANCOVER_TEST_MODULES:=$cpancover_default_test_module}"
pi "Testing with modules: $CPANCOVER_TEST_MODULES"
local o=(--env "$env")
((verbose)) && o+=("--verbose")
echo "$CPANCOVER_TEST_MODULES" |
cpancover_controller_command controller dc "${o[@]}" cpancover-build-stdin
}
recipe_cpancover-start-queue() {
COVER_DEBUG=1 perl bin/queue minion worker -j 4
}
recipe_cpancover-start-minion() {
COVER_DEBUG=1 perl bin/queue daemon -l http://\*:30000 -m production
}
recipe_cpancover-add() {
module="${1:?No module specified}"
COVER_DEBUG=1 perl bin/queue add "$module"
}
generate_gcov_fixtures() {
if ! command -v gcc >/dev/null; then
pf "gcc is required to generate gcov fixtures"
fi
if ! command -v gcov >/dev/null; then
pf "gcov is required to generate gcov fixtures"
fi
local fixtures_dir="t/fixtures/gcov2perl"
pi "Generating gcov fixtures in $fixtures_dir"
pushd "$fixtures_dir" >/dev/null
for fixture in *.c.fixture; do
[[ -f "$fixture" ]] || continue
local src="${fixture%.fixture}"
local base="${src%.c}"
pi "Processing $fixture"
# gcc needs a .c file
cp "$fixture" "$src"
# Compile with gcov instrumentation
gcc -fprofile-arcs -ftest-coverage -o "$base" "$src"
# Run to generate .gcda coverage data
./"$base" || true # Allow non-zero exit for branch coverage
# Generate .gcov file
# On macOS/clang, gcno/gcda files are named <output>-<source>.gc*
# so we call gcov with the object prefix, not the source file
if [[ -f "$base-$base.gcno" ]]; then
gcov "$base-$base" >/dev/null
else
gcov "$src" >/dev/null
fi
# Rename to .fixture so cover's *.gcov scan won't find it
mv "$src.gcov" "$src.gcov.fixture"
# Clean up intermediates, keep only .c.fixture and .gcov.fixture
rm -f "$src" "$base" "$base.gcno" "$base.gcda" \
"$base-$base.gcno" "$base-$base.gcda"
pi "Generated $src.gcov.fixture"
done
popd >/dev/null
}
recipe_generate-gcov-fixtures() {
generate_gcov_fixtures
}
run_recipe() {
recipe="recipe_$recipe"
shift
if declare -F "$recipe" >/dev/null 2>&1; then
"$recipe" "${args[@]:-}"
else
pf "Unknown recipe: $recipe"
fi
}
main() {
setup "$@"
((verbose)) && pi "Running $recipe ${args[*]:-}"
[[ ${recipe:-} == "" ]] && pf "Missing recipe"
run_recipe "${args[@]:-}"
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
trap cleanup EXIT INT
main "$@"
fi