#!/bin/bash

## Copyright (C) 2012 - 2025 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

set -x
set -o errexit
set -o nounset
set -o pipefail
set -o errtrace
shopt -s inherit_errexit
shopt -s shift_verbose

true "INFO: Currently running script: ${BASH_SOURCE[0]} $*"

## DM_GITHUB_MIRROR is declared (readonly) in git-helpers.bsh, sourced
## a few lines below; pkg_git_remotes_add references it once that
## source has run.

MYDIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" && pwd )"

if [ "$MYDIR" = "/usr/bin" ]; then
   true "INFO: Run from: /usr/bin"
   ## XXX: hardcoded path
   derivative_maker_source_code_dir="$HOME/derivative-maker"
else
   true "INFO: Run from: source code folder"
   derivative_maker_source_code_dir="$(cd -- "$MYDIR" && cd -- "../../../../../" && pwd)"
fi

## required by: make-helper-one.bsh
cd -- "$derivative_maker_source_code_dir"

source "${derivative_maker_source_code_dir}/packages/kicksecure/helper-scripts/usr/libexec/helper-scripts/get_colors.sh"
source "${derivative_maker_source_code_dir}/packages/kicksecure/helper-scripts/usr/libexec/helper-scripts/has.sh"
source "${derivative_maker_source_code_dir}/packages/kicksecure/helper-scripts/usr/libexec/helper-scripts/log_run_die.sh"
source "${derivative_maker_source_code_dir}/packages/kicksecure/helper-scripts/usr/libexec/helper-scripts/git.sh"
source "${derivative_maker_source_code_dir}/packages/kicksecure/helper-scripts/usr/libexec/helper-scripts/parallel.bsh"
source "${derivative_maker_source_code_dir}/packages/kicksecure/genmkfile/usr/share/genmkfile/make-helper-one.bsh"

log_level=info

canonical_workflows_dir="${derivative_maker_source_code_dir}/packages/kicksecure/developer-meta-files/consumer-templates/.github/workflows"
canonical_dependabot_yml="${derivative_maker_source_code_dir}/packages/kicksecure/developer-meta-files/consumer-templates/.github/dependabot.yml"

[ -v DM_GITHUB_DEV ] || DM_GITHUB_DEV='github-adrelanos'
[ -v DM_GITLAB_DEV ] || DM_GITLAB_DEV='gitlab-adrelanos'
[ -v DM_GITHUB_MIRROR ] || DM_GITHUB_MIRROR='org-ai-assisted'

## ArrayBolt3 (fetch-only) is intentionally excluded.
[ -v DM_PUSH_REMOTES_UNIVERSE ] || DM_PUSH_REMOTES_UNIVERSE=( github-kicksecure github-whonix "${DM_GITHUB_MIRROR}" "${DM_GITHUB_DEV}" "${DM_GITLAB_DEV}")
[ -v DM_PUSH_BRANCHES_UNIVERSE ] || DM_PUSH_BRANCHES_UNIVERSE=( main master trixie )

## BEGIN Initialization functions {

check_prerequisites() {
   local empty_directories cache_dirs

   if ! test -d "${derivative_maker_source_code_dir}"; then
      printf '%s\n' "FATAL ERROR: derivative_maker_source_code_dir '${derivative_maker_source_code_dir}' does not exist or is not a directory."
      exit 1
   fi

   # Any arbitrary package under derivative-maker/packages/kicksecure will
   # work here, helper-scripts was chosen arbitrarily.
   if ! test -d "${derivative_maker_source_code_dir}/packages/kicksecure/helper-scripts"; then
      printf '%s\n' "FATAL ERROR: git submodules under derivative_maker_source_code_dir '${derivative_maker_source_code_dir}' not populated."
      exit 1
   fi

   test -x "${MYDIR}/dm-check-unicode"

   has safe-rm
   has sq-git
   has append
   has overwrite

   if ! "${MYDIR}/dm-check-unicode"; then
      printf '%s\n' "FATAL ERROR: derivative_maker_source_code_dir '${derivative_maker_source_code_dir}' contains Unicode or is mislocated."
      exit 1
   fi

   cache_dirs="$(
      find "${derivative_maker_source_code_dir}" \
      -not -ipath '*/.git/*' \
      -type d \
      \( \
      -name '.pytest_cache' -o \
      -name '.mypy_cache'   -o \
      -name '__pycache__' \
      \)
   )"

   if [ -n "${cache_dirs}" ]; then
      printf '%s\n' \
        "FATAL ERROR: derivative_maker_source_code_dir '${derivative_maker_source_code_dir}' contains cache directories. Found: '${cache_dirs}'"
      exit 1
   fi

   empty_directories="$(find "${derivative_maker_source_code_dir}" -not -ipath '*.git*' -type d -empty)"

   if ! [ "${empty_directories}" = "" ]; then
      printf '%s\n' "${red}${bold}$0: ERROR:${reset} Empty directory found! empty_directories:
'${empty_directories}'"
      exit 1
   fi

   [ "${derivative_maker_source_code_dir}" = "$HOME/derivative-maker" ] || {
      printf '%s\n' "WARNING: derivative_maker_source_code_dir '${derivative_maker_source_code_dir}' is not located at '$HOME/derivative-maker'. The script may break."
   }

   [ -d "${derivative_binary_dir}" ] || {
      printf '%s\n' "WARNING: derivative_binary_dir '${derivative_binary_dir}' does not exist or is not a directory. Some features will be disabled."
      ## Soft failure.
      #exit 1
   }

   return 0
}

## Ensures that the system is prepared to run the script (right now this just
## makes sure directories that need to exist actually exist).
prepare_system() {
   mkdir --parents -- "${announcements_drafts_dir}"
   mkdir --parents -- "${package_documentation_dir}"
}

## Shows debugging information for all non-volatile global variables.
## TODO Make sure this is up-to-date!
show_debug_variable_info() {
   local library_item

   true "Global tunables:"
   true "derivative_binary_dir: ${derivative_binary_dir}"
   true "derivative_maker_source_code_dir: ${derivative_maker_source_code_dir}"
   true "derivative_version_old_main: ${derivative_version_old_main}"
   true "derivative_version_new_main: ${derivative_version_new_main}"
   true "derivative_release_type: ${derivative_release_type}"
   true "makefile_generic_version: ${makefile_generic_version}"
   true "packaging_files_diff_template_package_relative_path: ${packaging_files_diff_template_package_relative_path}"
   true ""
   true "Global variables:"
   true "announcements_drafts_dir: ${announcements_drafts_dir}"
   true "derivative_maker_dir_name: ${derivative_maker_dir_name}"
   true "make_cowbuilder_dist_dir: ${make_cowbuilder_dist_dir}"
   true "package_documentation_dir: ${package_documentation_dir}"
   true "MYDIR: ${MYDIR}"
}

## . END Initialization functions }

## BEGIN Utility functions {

## Takes a short commit message as input. Returns 0 if the commit message is
## not one of several common ones that should be ignored. Returns 1 otherwise.
commit_filter() {
   set +x

   local commit_msg_short text_any_case

   commit_msg_short="$1"
   [ -z "${commit_msg_short}" ] && {
      printf '%s\n' "${red}${bold}FATAL ERROR: No argument provided to ${FUNCNAME[0]}!${reset}"
      set -x
      exit 1
   }

   commit_msg_short="${commit_msg_short,,}"

   if printf '%s\n' "${commit_msg_short}" | grep --ignore-case --quiet -- 'Merge remote-tracking branch' ; then
      set -x
      return 1
   fi
   if printf '%s\n' "${commit_msg_short}" | grep --ignore-case --quiet -- 'Merge pull request' ; then
      set -x
      return 1
   fi
   if printf '%s\n' "${commit_msg_short}" | grep --ignore-case --quiet -- 'Merge branch' ; then
      set -x
      return 1
   fi

   for text_any_case in \
      'add debian install file (generated using "genmkfile debinstfile")' \
      'use long option names' \
      'Insert empty new line' \
      "Merge branch 'master' into master" \
      'Remove whitespace' \
      'lintian' \
      'genmkfile manpages' \
      'LC_ALL=C' \
      'LANG=C' \
      'signed commit' \
      'rm_conffile' \
      'Typo' \
      'Typo fixes' \
      'end of options' \
      'end-of-options' \
      'todo' \
      'sponge' \
      'Clarify' \
      'aa-logprof' \
      'bookworm aa-logprof' \
      'improve' \
      'improved error handling' \
      'lower debugging' \
      'printf' \
      'stprint' \
      'stecho' \
      'release-upgrade' \
      'Kicksecure' \
      'kicksecure' \
      'rename' \
      'split' \
      'split project source code' \
      'split source code' \
      'split Kicksecure and Whonix packages' \
      're-generated man pages' \
      'update lintian tag name' \
      'genmkfile debinstfile' \
      'update-path' \
      'output, refactoring' \
      'man' \
      'manpage' \
      'lintian' \
      'fix comment' \
      'genmkfile manpages' \
      'update comment' \
      'fix linitian warning' \
      'PEP8' \
      'intentd' \
      'improve error handling' \
      'refactoring; output' \
      'refactoring; fix' \
      'fix; refactoring' \
      'refactoring; debugging' \
      'output; refactoring' \
      'autopep8' \
      'anondate' \
      'apparmor' \
      'update path' \
      'update link' \
      'package description' \
      'update copyright year' \
      'remove white spaces from file names' \
      'Added creation of upstream changelog to debian/rules' \
      'add debian install file' \
      'remove genmkfile' \
      '# On branch master nothing to commit (working directory clean)' \
      'copyright' \
      'fix lintian warnings' \
      'fix lintian warning' \
      'fixed debian/changelog' \
      'fix path' \
      'remove trailing spaces' \
      'typos' \
      'minor' \
      'typo' \
      'update Depends' \
      'update copyright' \
      'remove faketime from Build-Depends:' \
      'remove debian/gain-root-command workaround' \
      'packaging simplification config-package-dev (>= 5.1) -> config-package-dev' \
      'packaging, bumped Standards-Version from 3.9.6 to 3.9.8 for jessie support' \
      'bumped Standards-Version' \
      'bump version number' \
      'bumped version number' \
      'bumped changelog version' \
      'packaging' \
      'shellcheck' \
      'verbose' \
      'safe-rm' \
      'sanity test' \
      'fix' \
      'comment' \
      'comments' \
      'output' \
      'news' \
      'readme' \
      'updated generic makefile' \
      'bumped compat from 8 to 9' \
      'Updated debian/changelog.' \
      'Fixed changelog date.' \
      'updated makefile generic to version 1.3' \
      'updated makefile generic to version 1.4' \
      'updated makefile generic to version 1.5' \
      'added changelog.upstream' \
      'quotes' \
      'refactoring' \
      'debugging' \
      'lintian warning copyright fix' \
      'https://www.kicksecure.com/wiki/Dev/Licensing' \
      'port to debian buster' \
      'port to debian bullseye' \
      'surpress lintian warning' \
      'add error handler' \
      'use full path' \
      'colors' \
      'build' \
      'improve --dry-run' \
      'migration' \
      'add newline at the end' \
      'update' \
      'fix debian/watch lintian warning debian-watch-contains-dh_make-template' \
      'code simplification' \
      'simplification' \
      'update path to pre.bsh' \
      'sudo' \
      'gksudo' \
      'tbb_hardcoded_version update' \
      'work towards nounset' \
      'nounset' \
      'dev-adrelanos' \
      'improvements' \
      'stricter shell options' \
      'use long options' \
      'remove trailing whitespace' \
      'remove trailing whitespaces' \
      'pylint' \
      'use end of options' \
      'usrmerge' \
      'unicode' \
      'improve pkexec test' \
      'only one TM' \
      'debhelper' \
      'use long option name' \
      'Adjust persistent mode wording' \
      'identd style' \
      'ident style' \
      'ident' \
      'autopep' \
      'add more ignore patterns' \
      'exit non-zero in more cases of warnings and errors' \
      'check journal: exclude more patterns' \
      'description' \
      'buster' \
      'bullseye' \
      'bookworm' \
      'cleanup' \
      'license' \
      'update path to pre.bsh' \
      'anon-shared-helper-scripts -> helper-scripts' \
      'enable debugging' \
      'Update control' \
      'fix output' \
      'local' \
      'man page' \
      'coypright' \
      'formatting' \
      'improve error handler' \
      'cleanup' \
      'fix lintian warning' \
      'local' \
      'bump' \
      'trailing spaces' \
      'coypright' \
      'description' \
      'shuffle' \
      'use pre.bsh' \
      'set -e' \
      'update control' \
      'disable debugging' \
      'set -o pipefail' \
      'style' \
      'upgrade license from GPLv2+ to GPLv3+' \
      'CI fix' \
      'errtrace' \
      'add link' \
      'simplify boot menu names' \
      'better boot menu entry naming' \
      'bugfixes' \
      'long option names' \
      'newline at the end' \
      'use end-of-options' \
      'delete empty file' \
      'wayland' \
      'nftabels' \
      'chmod +x' \
      'CI' \
      'rename variable' \
      'ISO' \
      'wrap-and-sort' \
      'Depends: pkexec' \
      'sysmaint' \
      'Port to Trixie.' \
      "Merge branch 'master' into arraybolt3/trixie" \
      'Add Wayland compatibility' \
      'remove duplication' \
      '--no-install-recommends' \
      'test' \
      'duplicate' \
      'pipe' \
      'add' \
      'dev' \
      'indent' \
      'stcat' \
      'improvement' \
      'improve lockfile' \
      'trixie' \
      'python3 -su' \
      'bump breaks version' \
      'idempotent' \
      'bump version' \
      'more fixes' \
      'long option name' \
      'long option names' \
      'no longer depend on sudo' \
      'newline' \
      'Remove link' \
      'Remove duplicate comment' \
      'add root_check' \
      'add root check' \
      'ignore' \
      'ignore more journal messages' \
      'refactoring; comments' \
      'add `wc -l` test' \
      'check apparmor' \
      'Adjust for Wayland and LXQt compatibility' \
      'Adjust for metapackage restructure' \
      'update minimum_unixtime' \
      'forky' \
      'debian-tor' \
      'links' \
      'use TEMPDIR' \
      'regenerate' \
      'remove trailing space' \
      'remove trailing spaces' \
      'Depends: python3' \
      '/usr/bin/touch' \
      'newlines' \
      'line endings' \
      'Add Wayland support' \
      'warning' \
      'lockfile' \
      'consistency' \
      're-generate man pages (generated using "genmkfile manpages")' \
      'add todo' \
      'todo' \
      'genmkfile uch' \
      'stub' \
      'changelog' \
      'port to sequonia-pgp' \
      'port to sequoia-pgp' \
      'windows' \
      'docker' \
      'add CLAUDE.md' \
      'add AGENTS.md' \
      '-' \
      '.' \
      ; do
         ## Done above already.
         #commit_msg_short="${commit_msg_short,,}"
         if [ "${text_any_case,,}" = "${commit_msg_short}" ]; then
            set -x
            return 1
         fi
   done

   set -x
}

## Takes a package repository name as an argument. Returns 0 if the current
## package reponame being handled is NOT the same as the argument. Returns 1
## otherwise. Intended to be used in commands as
## `repo_skip 'package-name' || return 0`.
repo_skip() {
   if [ "${batch_current_package_reponame}" = "$1" ]; then
      true "Skipping ${batch_current_package_reponame}."
      return 1
   fi
}

repo_skip_non_package() {
   repo_skip 'derivative-maker' || return 1
   repo_skip 'qubes-template-kicksecure' || return 1
   repo_skip 'qubes-template-whonix' || return 1
   repo_skip 'Whonix-Installer' || return 1
   repo_skip 'Whonix-Starter' || return 1
}

## Returns 0 if the current repo is one of the "special" (non-package)
## repos. Counterpart to repo_skip_non_package.
repo_is_special() {
   case "${batch_current_package_reponame}" in
      qubes-template-kicksecure|qubes-template-whonix|Whonix-Installer|Whonix-Starter|live-build|grml-debootstrap|grml-debootstraptest)
         return 0
         ;;
   esac
   return 1
}

## Resets all batch_meta_* global state variables to default values.
reset_batch_meta_globals() {
   batch_meta_category_list=()
   batch_meta_debian_control_web_link=''
   batch_meta_file_name_without_reponame=''
   batch_meta_file_web_link=''
   batch_meta_gateway_only='n'
   batch_meta_installed_by_default='y'
   batch_meta_non_qubes_whonix_only='n'
   batch_meta_project_list=()
   batch_meta_qubes_whonix_only='n'
   batch_meta_relative_file_name=''
   batch_meta_repo_web_link=''
   batch_meta_workstation_only='n'
   unset batch_meta_file_header_done_list
   ## `=()` keeps the array marked "set" for set -o nounset; bare
   ## `declare -A -g name` would leave it unset and any `${name[@]}`
   ## access (count, iterate) would error.
   declare -A -g batch_meta_file_header_done_list=()
}

## Returns 0 if the current HEAD commit subject is the sentinel
## 'bumped changelog version' message produced by
## `genmkfile deb-uachl-commit-changelog`. Marks an in-progress
## bump-and-build that may have not yet completed reprepro-add.
is_changelog_bump_commit() {
   [ "$(git log --format=%s -1)" = 'bumped changelog version' ]
}

dry_run_or_run() {
   if [ "${batch_meta_dry_run}" = "y" ]; then
      ## WOULD goes to stderr so it survives `>/dev/null` on the
      ## caller (needed when wrapping commands like `tee` that echo
      ## their input on stdout in live mode).
      log notice "[DRY-RUN] ${batch_current_package_reponame:-}: WOULD: $*" >&2
      ## When called as the receiving end of a pipe (e.g.
      ## `printf '...' | dry_run_or_run tee -- file`), drain stdin so
      ## the upstream producer doesn't SIGPIPE under errexit+pipefail.
      ## `-p` is true only for actual pipes, so non-piped callers and
      ## heredoc inputs are unaffected.
      [ -p /dev/stdin ] && cat >/dev/null
      return 0
   fi
   "$@"
}

## Extracts a package description from a debian/control file, writing it to
## the specified file using a special template for the headline. The template
## can be an arbitrary string, but must contain the string XXX_REPLACE_ME_XXX
## to indicate where the first line of the description should be inserted.
extract_description_from_debian_control() {
   local source_file target_file headline_template line first_word \
     description_found headline_written

   source_file="${1:-}"
   target_file="${2:-}"
   headline_template="${3:-}"
   if [ -z "${source_file}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: Empty source_file parameter provided!${reset}"
      exit 1
   elif [ -z "${target_file}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: Empty target_file parameter provided!${reset}"
      exit 1
   elif [ -z "${headline_template}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: Empty headline_template parameter provided!${reset}"
      exit 1
   elif ! grep --quiet 'XXX_REPLACE_ME_XXX' <<< "${headline_template}" ; then
      printf '%s\n' "${red}${bold}FATAL ERROR: headline_template parameter lacks XXX_REPLACE_ME_XXX placeholder!${reset}"
      exit 1
   fi

   description_found='n'
   headline_written='n'

   while IFS= read -r line; do
      read -r first_word _ <<< "${line}"
      if [ "${first_word}" = 'Description:' ]; then
         description_found='y'
      elif [ "${line:0:1}" != ' ' ] && [ "${description_found}" = 'y' ]; then
         break
      fi

      if [ "${description_found}" = 'y' ]; then
         if [ -z "${line}" ]; then
            break;
         fi
         if [ "${headline_written}" = 'n' ]; then
            headline="$(awk -F':' '{ print $2 }' <<< "${line}")"
            ## Debian policy 5.6.13 mandates exactly one space between
            ## 'Description:' and the synopsis, so the strip below is
            ## effectively unconditional; the leading-space guard is
            ## purely defensive against a hand-edited control file that
            ## omits the space.
            if [ "${headline:0:1}" = ' ' ]; then
               headline="${headline:1}"
            fi
            headline_template="${headline_template//'XXX_REPLACE_ME_XXX'/"${headline}"}"
            dry_run_or_run append "${target_file}" "${headline_template}" >/dev/null
            dry_run_or_run append "${target_file}" "" >/dev/null
            headline_written='y'
            continue
         fi
         if [ "${line}" = ' .' ]; then
            dry_run_or_run append "${target_file}" "" >/dev/null
         else
            # Strip all leading whitespace
            read -r line <<< "${line}"
            dry_run_or_run append "${target_file}" "${line}" >/dev/null
         fi
      fi
   done < "${source_file}"
}

internal_git_known_push_remotes() {
   local -n remotes_out_ref
   local remote_item

   check_variable_name "$1" || return 1
   remotes_out_ref="$1"
   remotes_out_ref=()

   for remote_item in "${DM_PUSH_REMOTES_UNIVERSE[@]}"; do
      if [ "${remote_item}" = "github-adrelanos" ]; then
         ## github bug 404
         ## https://github.com/adrelanos/icon-pack-dist
         case "${batch_current_package_reponame}" in
            icon-pack-dist|initializer-dist|libvirt-dist)
               true "INFO: skip github bug: yes, skip."
               continue
               ;;
            *)
               true "INFO: proceed: yes"
               ;;
         esac
      fi

      git remote get-url -- "${remote_item}" &>/dev/null || continue
      remotes_out_ref+=( "${remote_item}" )
   done
}

internal_git_verify_ref() {
   local ref
   ref="$1"
   case "${ref}" in
      refs/heads/*)
         log_run_silent_if_success git verify-commit "${ref}^{commit}"
         ;;
      refs/tags/*)
         log_run_silent_if_success git verify-tag "${ref}"
         ;;
      *)
         die 1 "${FUNCNAME[0]}: unknown ref namespace: '${ref}'"
         ;;
   esac
}

internal_git_push_atomic_all_remotes() {
   local branch tag branch_tags_raw sorted_tags_raw
   local -a existing_branches remote_list tip_tags branch_tag_list

   remote_list=()
   internal_git_known_push_remotes remote_list

   if [ "${#remote_list[@]}" -eq 0 ]; then
      log notice "no known push remotes configured locally; nothing to push"
      return 0
   fi

   existing_branches=()
   for branch in "${DM_PUSH_BRANCHES_UNIVERSE[@]}"; do
      git show-ref --verify --quiet -- "refs/heads/${branch}" \
         && existing_branches+=( "${branch}" )
   done

   if [ "${#existing_branches[@]}" -eq 0 ]; then
      log notice "none of (${DM_PUSH_BRANCHES_UNIVERSE[*]}) exist locally; nothing to push"
      return 0
   fi

   ## Dedupe tip tags to avoid double-verifying a tag at multiple branch tips.
   tip_tags=()
   for branch in "${existing_branches[@]}"; do
      internal_git_verify_ref "refs/heads/${branch}"
      branch_tags_raw="$(git tag --points-at "refs/heads/${branch}")"
      if [ -n "${branch_tags_raw}" ]; then
         mapfile -t branch_tag_list <<< "${branch_tags_raw}"
         tip_tags+=( "${branch_tag_list[@]}" )
      fi
   done
   if [ "${#tip_tags[@]}" -gt 0 ]; then
      sorted_tags_raw="$(printf '%s\n' "${tip_tags[@]}" | sort --unique)"
      mapfile -t tip_tags <<< "${sorted_tags_raw}"
   fi
   for tag in "${tip_tags[@]}"; do
      [ -n "${tag}" ] || continue
      internal_git_verify_ref "refs/tags/${tag}"
   done

   make_git_push_branches="${existing_branches[*]}" \
   make_git_push_remotes="${remote_list[*]}" \
      genmkfile git-tag-push
}

## Creates and appends to package description files.
internal_descr_writer() {
   local project category description_file control_file line first_word \
     description_found headline_written

   for project in "${batch_meta_project_list[@]}"; do
      for category in "${batch_meta_category_list[@]}"; do
         description_file="${package_documentation_dir}/${batch_current_package_reponame}_${category}_${project}.mediawiki"

         ## Create the file if it does not yet exist
         if ! [ -e "${description_file}" ]; then
            control_file="${batch_current_package_path}/debian/control"
            if ! [ -f "${control_file}" ]; then
               printf '%s\n' "${red}${bold}FATAL ERROR: control_file '${control_file}' does not exist or is not a regular file!${reset}"
               exit 1
            fi

            touch -- "${description_file}"

            dry_run_or_run append "${description_file}" "== ${batch_current_package_reponame} =="
            dry_run_or_run append "${description_file}" "" >/dev/null
            dry_run_or_run append "${description_file}" "* ${batch_meta_repo_web_link}" >/dev/null
            dry_run_or_run append "${description_file}" "* [${batch_meta_debian_control_web_link} debian/control]" >/dev/null

            extract_description_from_debian_control "${control_file}" "${description_file}" '=== XXX_REPLACE_ME_XXX ==='

         ## If the file does exist, make sure it's a real file
         elif ! [ -f "${description_file}" ]; then
            printf '%s\n' "${red}${bold}FATAL ERROR: ${description_file} exists but is not a regular file!${reset}"
            exit 1
         fi

         if ! [ "${batch_meta_file_header_done_list["${batch_meta_relative_file_name}_${batch_current_package_reponame}_${category}_${project}"]:-}" = 'y' ]; then
            dry_run_or_run append "${description_file}" "=== ${batch_meta_file_name_without_reponame} ===" >/dev/null
            dry_run_or_run append "${description_file}" "" >/dev/null
            dry_run_or_run append "${description_file}" "* [${batch_meta_file_web_link} ${batch_meta_file_name_without_reponame}]" >/dev/null
            [ "${batch_meta_gateway_only}" = 'y' ] && dry_run_or_run append "${description_file}" '* gateway only<!--gateway-only-->' >/dev/null
            [ "${batch_meta_workstation_only}" = 'y' ] && dry_run_or_run append "${description_file}" '* workstation only<!--workstation-only-->' >/dev/null
            [ "${batch_meta_non_qubes_whonix_only}" = 'y' ] && dry_run_or_run append "${description_file}" '* Non-Qubes-Whonix only' >/dev/null
            [ "${batch_meta_qubes_whonix_only}" = 'y' ] && dry_run_or_run append "${description_file}" '* Qubes-Whonix only' >/dev/null
            [ "${batch_meta_installed_by_default}" = 'n' ] && dry_run_or_run append "${description_file}" '* Not installed by default.' >/dev/null
            dry_run_or_run append "${description_file}" "" >/dev/null

            batch_meta_file_header_done_list["${batch_meta_relative_file_name}_${batch_current_package_reponame}_${category}_${project}"]='y'
         fi

         dry_run_or_run append "${description_file}" "$*" >/dev/null
      done
   done
}

## Extracts a list of unique info fields from package documentation file
## names. The info fields that can be searched for are 'category' and
##'project'. This takes advantage of the fact that in package documentation
## filenames, the reponames, categories, and projects are separated by
## underscores and can thus be extracted easily that way.
get_package_documentation_info_list() {
   local info_type info_idx info_list file_name_full_path file_name_only \
      file_info info_from_list info_match_found

   info_type="${1:-}"
   if [ "${info_type}" = 'category' ]; then
      info_idx='2'
   elif [ "${info_type}" = 'project' ]; then
      info_idx='3'
   else
      printf '%s\n' "${red}${bold}FATAL ERROR: info_type is not 'category' or 'project'!${reset}"
      exit 1
   fi

   info_list=()

   for file_name_full_path in "${package_documentation_dir}/"* ; do
      file_name_only="${file_name_full_path##*/}"
      file_info="$(cut -d'_' -f"${info_idx}" <<< "${file_name_only}")"
      if [[ "${file_info}" =~ \.merged\.mediawiki ]] \
         || [[ "${file_info}" =~ \.allmerged\.mediawiki ]]; then
         continue
      fi
      file_info="$(str_replace '.mediawiki' '' <<< "${file_info}")"
      info_match_found='n'
      for info_from_list in "${info_list[@]}"; do
         if [ "${file_info}" = "${info_from_list}" ]; then
            info_match_found='y'
            break
         fi
      done
      if [ "${info_match_found}" = 'n' ]; then
         info_list+=( "${file_info}" )
         printf '%s\n' "${file_info}"
      fi
   done
}

## Resolves a repo NAME (--repo argument) to its filesystem path under
## derivative-maker. Echoes the resolved path on stdout, returns 1 if no
## candidate exists. Mirrors the iteration set run_batch() covers.
resolve_repo_path() {
   local name candidate
   name="$1"

   if [[ "${name}" == */* ]] || [[ "${name}" == .* ]] || [ -z "${name}" ]; then
      return 1
   fi

   if [ "${name}" = 'derivative-maker' ]; then
      printf '%s\n' "${derivative_maker_source_code_dir}"
      return 0
   fi

   for candidate in \
      "${derivative_maker_source_code_dir}/${name}" \
      "${derivative_maker_source_code_dir}/qubes/${name}" \
      "${derivative_maker_source_code_dir}/windows/${name}" \
      "${derivative_maker_source_code_dir}/packages/kicksecure/${name}" \
      "${derivative_maker_source_code_dir}/packages/whonix/${name}"
   do
      if [ -d "${candidate}" ]; then
         printf '%s\n' "${candidate}"
         return 0
      fi
   done

   return 1
}


## Derives all batch_current_* globals from PWD for single-repo --no-batch
## invocations. Mirrors the per-package assignments run_batch() makes for
## each iteration. Fails fast if the reponame is unrecognized so the user
## function doesn't run with empty/garbage context.
set_batch_context_from_pwd() {
   local pwd_abs reponame parent parent_basename
   pwd_abs="$(realpath -- "${PWD}")"
   reponame="${pwd_abs##*/}"
   parent="${pwd_abs%/*}"
   parent_basename="${parent##*/}"

   batch_current_package_path="${pwd_abs}"
   batch_current_package_reponame="${reponame}"

   ## derivative-maker is its own meta-repo category - kept out of
   ## repo_is_special to preserve batch-mode branching semantics.
   if [ "${reponame}" = 'derivative-maker' ]; then
      batch_current_project_name='Kicksecure'
      batch_current_project_website='kicksecure.com'
      batch_current_package_changelog="${announcements_drafts_dir}/kicksecure_giant_git_log.txt"
      return 0
   fi

   if repo_is_special; then
      case "${reponame}" in
         qubes-template-kicksecure|live-build|grml-debootstrap|grml-debootstraptest)
            batch_current_project_name='Kicksecure'
            batch_current_project_website='kicksecure.com'
            batch_current_package_changelog="${announcements_drafts_dir}/kicksecure_giant_git_log.txt"
            ;;
         qubes-template-whonix|Whonix-Installer|Whonix-Starter)
            batch_current_project_name='Whonix'
            batch_current_project_website='whonix.org'
            batch_current_package_changelog="${announcements_drafts_dir}/whonix_giant_git_log.txt"
            ;;
         *)
            die 1 "${FUNCNAME[0]}: '${reponame}' is in repo_is_special but no case arm here maps it to a project; add it to one of the arms above (or remove it from repo_is_special)."
            ;;
      esac
      return 0
   fi

   case "${parent_basename}" in
      kicksecure)
         batch_current_project_name='Kicksecure'
         batch_current_project_website='kicksecure.com'
         batch_current_package_changelog="${announcements_drafts_dir}/kicksecure_giant_git_log.txt"
         ;;
      whonix)
         batch_current_project_name='Whonix'
         batch_current_project_website='whonix.org'
         batch_current_package_changelog="${announcements_drafts_dir}/whonix_giant_git_log.txt"
         ;;
      *)
         printf '%s\n' "${red}${bold}FATAL ERROR:${reset} ${FUNCNAME[0]}: cannot determine project for reponame='${reponame}' (PWD parent: '${parent_basename}'). Run from a package directory under derivative-maker, or add '${reponame}' to repo_is_special."
         exit 1
         ;;
   esac
}

## Per-package fetch-remote list (consumed by pkg_git_fetch_remotes).
## Project-aware; intentionally narrower than the push universe in
## DM_PUSH_REMOTES_UNIVERSE (git-helpers.bsh). Remote URLs live in
## pkg_git_remotes_add.
set_batch_current_package_remote_list() {
   batch_current_package_remote_list=( "github-${batch_current_project_name,,}" "${DM_GITHUB_MIRROR}" )

   ## derivative-maker is mirrored at Whonix.
   if [ "${batch_current_package_reponame}" = 'derivative-maker' ]; then
      batch_current_package_remote_list+=( 'github-whonix' )
   fi
}

## Replaces the destination file with the source file only if the
## destination already exists. Skips silently when absent. Stages
## and commits with "update <basename>" as the default message; the
## optional third arg overrides it.
pkg_update_if_exists() {
   local source_path destination_path commit_msg destination_basename

   source_path="${1:-}"
   destination_path="${2:-}"
   commit_msg="${3:-}"

   if [ -z "${source_path}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: Empty source_path parameter provided!${reset}"
      exit 1
   elif [ -z "${destination_path}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: Empty destination_path parameter provided!${reset}"
      exit 1
   elif ! [ -f "${source_path}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: source_path '${source_path}' does not exist!${reset}"
      exit 1
   fi

   if ! [ -f "${destination_path}" ]; then
      true "INFO: '${destination_path}' does not exist; skipping update."
      return 0
   fi

   dry_run_or_run cp --verbose -- "${source_path}" "${destination_path}"

   dry_run_or_run git add -- "${destination_path}"

   if nothing_to_commit; then
      true "INFO: '${destination_path}' already up to date; nothing to commit."
      return 0
   fi

   #git diff --cached

   if [ -z "${commit_msg}" ]; then
      destination_basename="${destination_path##*/}"
      commit_msg="update ${destination_basename}"
   fi

   dry_run_or_run git commit -m "${commit_msg}"

   return 0
}

## . END Utility functions }

## BEGIN Commands {
#
# NOTE: Commands may assume that $PWD is the directory in which they should
# make changes, unless they are excluded from the batch processing mechanism.

pkg_upstream_changelog_regenerate() {
   if ! [ -f 'changelog.upstream' ]; then
      return 0
   fi
   genmkfile uch
   git add "changelog.upstream"
   git commit -m "genmkfile uch"
   git status
}

## Commits changes to all project README files.
pkg_readme_creator_commit() {
   local readme_file

   repo_skip 'derivative-maker' || return 0

   ## global readme file
   if [ -f 'README_generic.md' ]; then
      ## When there is a README_generic.md, this is a way to express
      ## "do not create a generic README.md for that package, because it
      ## already has a real readme."
      readme_file='README_generic.md'
   else
      readme_file='README.md'
   fi

   dry_run_or_run git add -- "${readme_file}" || true

   if nothing_to_commit; then
      return 0
   fi

   ## Reviewing the maintainer's own staged changes -> meld's GUI is preferred.
   ## git-diff-review (terminal-safe textual driver) is for untrusted diffs;
   ## kept commented for quick re-enable.
   #dry_run_or_run git-diff-review --cached
   dry_run_or_run git-meld --cached

   dry_run_or_run git commit -m 'readme' || true
}

## Regenerates the README files for all projects except derivative-maker and
## tirdad, using the description from 'debian/control' and a master README
## template. The files are placed at `README.md` or `README_generic.md`,
## depending on which kind of README the project uses.
pkg_readme_creator_do() {
   local control_file generic_readme_template_file readme_file line \
     first_word description_found headline_written headline search_str \
     replace_str

   repo_skip 'derivative-maker' || return 0

   control_file='debian/control'
   if ! [ -f "${control_file}" ]; then
      true "No file"
      return 0
   fi

   generic_readme_template_file="${derivative_maker_source_code_dir}/packages/kicksecure/developer-meta-files/README_generic_template_file.md"
   if ! [ -f "${generic_readme_template_file}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: generic_readme_template_file '${generic_readme_template_file}' does not exist!${reset}"
      exit 1
   fi

   ## global readme file
   if [ -f 'README_generic.md' ]; then
      ## When there is a README_generic.md, this is a way to express
      ## "do not create a generic README.md for that package, because it
      ## already has a real readme."
      readme_file='README_generic.md'
   else
      readme_file='README.md'
   fi

   description_found='n'
   headline_written='n'

   dry_run_or_run safe-rm --force -- "${readme_file}"

   extract_description_from_debian_control "${control_file}" "${readme_file}" '# XXX_REPLACE_ME_XXX #'

   dry_run_or_run append "${readme_file}" "" >/dev/null

   cat -- "${generic_readme_template_file}" | dry_run_or_run tee --append -- "${readme_file}" >/dev/null

   search_str='%%project_clearnet%%'
   replace_str="${batch_current_project_website}"
   str_replace "${search_str}" "${replace_str}" "${readme_file}"

   search_str='%%package-name%%'
   replace_str="${batch_current_package_reponame}"
   str_replace "${search_str}" "${replace_str}" "${readme_file}"

   true "Done: ${readme_file}"
}

## If the current package repo has an AGENTS.md, generate a minimal
## CLAUDE.md pointer alongside it. Claude Code reads CLAUDE.md by
## convention; the rest of the AI tool ecosystem reads AGENTS.md. One
## file (AGENTS.md) is the source of truth; CLAUDE.md just redirects
## to it so we don't have to maintain two parallel docs.
pkg_claude_md_pointer_create() {
   if ! [ -f 'AGENTS.md' ]; then
      return 0
   fi

   dry_run_or_run overwrite 'CLAUDE.md' '# CLAUDE.md

See [AGENTS.md](AGENTS.md).' >/dev/null

   dry_run_or_run git add "CLAUDE.md"
   dry_run_or_run git commit -m "add CLAUDE.md"
}

## Harvests metadata from individual files under all projects except
## derivative-maker, and converts it to package documentation in the form of
## MediaWiki files. Places documentation under package_documentation_dir.
## TODO: Only handles the first metadata section of each file. Some files have
## multiple metadata sections.
pkg_descr_creator() {
   local file_name file_list skip_file_list base_name do_skip_file \
     skip_file_name in_meta_section description_found line first_word \
     second_word third_word temp

   repo_skip 'derivative-maker' || return 0

   skip_file_list=(
      'changelog.upstream'
      'CONTRIBUTING.md'
      'COPYING'
      'GPLv3'
      'Makefile'
      'README.md'
   )

   if [ "${batch_func_init_done}" = 'n' ]; then
      dry_run_or_run safe-rm -f -- "${package_documentation_dir}"/*.mediawiki
      batch_func_init_done='y'
   fi

   readarray -t file_list < <(find "${batch_current_package_path}" -type f -not -iwholename '*.git*')
   for file_name in "${file_list[@]}"; do
      ## Skip binary files for better performance.
      if ! isutf8 -q "${file_name}" ; then
         continue
      fi

      true "file_name: ${file_name}"

      base_name="${file_name##*/}"
      do_skip_file='n'
      for skip_file_name in "${skip_file_list[@]}"; do
         if [ "${skip_file_name}" = "${base_name}" ]; then
            do_skip_file='y'
            break
         fi
      done
      if [ "${do_skip_file}" = 'y' ]; then
         continue
      fi

      if ! [ -f "${file_name}" ]; then
         printf '%s\n' "${red}${bold}FATAL ERROR: pkg_descr_creator: file_name '${file_name}' does not exist!${reset}"
         exit 1
      fi

      reset_batch_meta_globals

      batch_meta_relative_file_name="$(str_replace "${derivative_maker_source_code_dir}/packages/${batch_current_project_name,,}/" '' <<< "${file_name}")"
      batch_meta_file_name_without_reponame="/$(cut -d'/' -f2- <<< "${batch_meta_relative_file_name}")"
      batch_meta_repo_web_link="https://github.com/${batch_current_project_name}/${batch_current_package_reponame}"
      batch_meta_debian_control_web_link="https://github.com/${batch_current_project_name}/${batch_current_package_reponame}/blob/master/debian/control"
      batch_meta_file_web_link="https://github.com/${batch_current_project_name}/${batch_current_package_reponame}/blob/master${batch_meta_file_name_without_reponame}"

      in_meta_section='n'
      description_found='n'
      while read -r line; do
         true "line: ${line}"
         read -r first_word second_word third_word _ <<< "${line}" || true

         if [ "${first_word}" = '####' ]; then
            true "second_word: '${second_word}'"
            if [ "${second_word}" = 'meta' ]; then
               if [ "${third_word}" = 'start' ]; then
                  in_meta_section='y'
                  printf '%s\n' '##################################################'
                  printf '%s\n' "${file_name}"
                  continue
               elif [ "${third_word}" = 'end' ]; then
                  in_meta_section='n'
                  break
               else
                  printf '%s\n' "${red}${bold}FATAL ERROR: Unexpected meta subcommand third_word '${third_word}'!${reset}"
                  exit 1
               fi
            fi

            if [ "${in_meta_section}" = 'y' ]; then
               if [ "${second_word}" = 'project' ]; then
                  ## TODO: Consider rewriting this to handle a comma-separated
                  ## list of projects
                  temp="$(str_replace '#### project ' '' <<< "${line}") " # extra space needed for readarray
                  temp="$(str_replace ' and' '' <<< "${temp}")"
                  readarray -d' ' -t batch_meta_project_list <<< "${temp}"
                  # Remove a mangled entry at the end.
                  batch_meta_project_list=( "${batch_meta_project_list[@]:0:((${#batch_meta_project_list[@]} - 1))}" )
                  printf '%s\n' "batch_meta_project_list: '${batch_meta_project_list[*]}'"
                  continue
               elif [ "${second_word}" = 'non_qubes_whonix_only' ]; then
                  if [ "${third_word}" = 'yes' ]; then
                     batch_meta_non_qubes_whonix_only='y'
                     continue
                  fi
               elif [ "${second_word}" = 'qubes_whonix_only' ]; then
                  if [ "${third_word}" = 'yes' ]; then
                     batch_meta_qubes_whonix_only='y'
                     continue
                  fi
               elif [ "${second_word}" = 'gateway_only' ]; then
                  if [ "${third_word}" = 'yes' ]; then
                     batch_meta_gateway_only='y'
                     continue
                  fi
               elif [ "${second_word}" = 'workstation_only' ]; then
                  if [ "${third_word}" = 'yes' ]; then
                     batch_meta_workstation_only='y'
                     continue
                  fi
               elif [ "${second_word}" = 'installed_by_default' ]; then
                  if [ "${third_word}" = 'no' ]; then
                     batch_meta_installed_by_default='n'
                     continue
                  fi
               elif [ "${second_word}" = 'category' ]; then
                  ## TODO: Consider rewriting this to handle a comma-separated
                  ## list of categories
                  temp="$(str_replace '#### category ' '' <<< "${line}") " # extra space needed for readarray
                  temp="$(str_replace ' and' '' <<< "${temp}")"
                  readarray -d' ' -t batch_meta_category_list <<< "${temp}"
                  # Remove a mangled entry at the end.
                  batch_meta_category_list=( "${batch_meta_category_list[@]:0:((${#batch_meta_category_list[@]} - 1))}" )
                  printf '%s\n' "batch_meta_category_list: '${batch_meta_category_list[*]}'"
                  continue
               elif [ "${second_word}" = 'description' ]; then
                  description_found='y'
                  continue
               else
                  ## this looks like a metadata field line and we're in the
                  ## meta section, but the field keyword was not recognized.
                  continue
               fi
            else
               ## this looks like a metadata field line, but we're not in the
               ## meta section and a 'meta' keyword was not encountered
               continue
            fi
         fi

         if [ "${in_meta_section}" = 'y' ]; then
            ## At this point, we're in the meta section, but the current line
            ## doesn't look like a metadata field line. Usually this means
            ## that we're in a description zone.
            if [ "${description_found}" = 'y' ]; then
               if [ "${line}" = '' ]; then
                  internal_descr_writer ''
                  continue
               fi

               ## translate a line consisting entirely of '##' to a newline
               if [ "${line}" = '##' ]; then
                  internal_descr_writer ''
                  continue
               fi

               ## Lines prefixed by a single '#' and lines with no '#'s
               ## prefixing them at all are code, anything else should be
               ## treated as normal text.
               if [ "${line:0:1}" = '#' ] && [ "${line:1:1}" != '#' ]; then
                  line="* <code>${line}</code>"
                  internal_descr_writer "${line}"
                  continue
               elif [ "${line:0:1}" != '#' ]; then
                  line="* <code>${line}</code>"
                  internal_descr_writer "${line}"
                  continue
               else
                  line="$(str_replace '## ' '' <<< "${line}")"
                  internal_descr_writer "${line}"
               fi
            fi
         fi
      done < "${file_name}"
   done
}

## Merges together package documentation files for all packages sharing a
## project, category, and target machine type in common. pkg_descr_creator
## must be run first. Note that this function does NOT go through the batch
## processing mechanism.
pkg_descr_merger() {
   local project_list category_list project category file_name_full_path \
      file_name_only package_name machine merged_file

   readarray -t project_list < <(get_package_documentation_info_list 'project')
   readarray -t category_list < <(get_package_documentation_info_list 'category')

   for project in "${project_list[@]}"; do
      for category in "${category_list[@]}"; do
         for file_name_full_path in "${package_documentation_dir}/"*"_${category}_${project}.mediawiki" ; do
            ## If we hit on a combination of category and project that never
            ## occurs, file_name_full_path will contain a bogus filename that
            ## needs to be skipped.
            if [ ! -f "${file_name_full_path}" ]; then
               continue
            fi

            file_name_only="${file_name_full_path##*/}"
            package_name="$(str_replace "_${category}_${project}.mediawiki" '' <<< "${file_name_only}")"

            if [ "${project}" = 'Kicksecure' ]; then
               machine='all'
            elif [ "${project}" = 'Whonix' ]; then
               if grep --quiet '\-gw-' <<< "${package_name}" ; then
                  machine='gateway'
               elif grep --quiet '\-ws-' <<< "${package_name}" ; then
                  machine='workstation'
               else
                  machine='shared'
               fi

               ## TODO: don't hardcode here
               [ "${package_name}" = 'onion-grater' ] && machine='gateway'
               [ "${package_name}" = 'anon-apps-config' ] && machine='workstation'
               [ "${package_name}" = 'bindp' ] && machine='workstation'
            else
               printf '%s\n' "${red}${bold}FATAL ERROR: Unrecognized project '${project}'!${reset}"
               exit 1
            fi

            merged_file="${package_documentation_dir}/${machine}_${category}_${project}.merged.mediawiki"

            if ! [ "${merge_file_reset_list["${merged_file}"]:-}" = 'y' ] ; then
               dry_run_or_run safe-rm -f -- "${merged_file}"
               merge_file_reset_list["${merged_file}"]='y'
            fi

            cat -- "${file_name_full_path}" | dry_run_or_run tee --append -- "${merged_file}" >/dev/null
         done
      done
   done
}

## Merges together the merged files output by pkg_descr_merger, so that the
## final resulting files contain documentation for all packages sharing a
## project and category in common. Runs pkg_descr_merger, so you can run this
## without running pkg_descr_merger first. Note that this function does NOT go
## through the batch processing mechanism.
pkg_descr_merge_all() {
   local project_list category_list machine_list project category machine \
      all_merged_file file_name_full_path

   readarray -t project_list < <(get_package_documentation_info_list 'project')
   readarray -t category_list < <(get_package_documentation_info_list 'category')

   pkg_descr_merger

   machine_list=( 'all' 'gateway' 'workstation' 'shared' )

   for project in "${project_list[@]}"; do
      for category in "${category_list[@]}"; do
         all_merged_file="${package_documentation_dir}/${category}_${project}.allmerged.mediawiki"
         dry_run_or_run safe-rm -f -- "${all_merged_file}"

         for machine in "${machine_list[@]}"; do
            file_name_full_path="${package_documentation_dir}/${machine}_${category}_${project}.merged.mediawiki"
            if ! [ -f "${file_name_full_path}" ]; then
               continue
            fi

            true "file_name_full_path: ${file_name_full_path}"
            true "all_merged_file: ${all_merged_file}"

            if [ "${machine}" = 'all' ]; then
               dry_run_or_run append "${all_merged_file}" '= Kicksecure =' >/dev/null
            elif [ "${machine}" = 'gateway' ]; then
               dry_run_or_run append "${all_merged_file}" '= Whonix-Gateway =' >/dev/null
            elif [ "${machine}" = 'workstation' ]; then
               dry_run_or_run append "${all_merged_file}" '= Whonix-Workstation =' >/dev/null
            elif [ "${machine}" = 'shared' ]; then
               dry_run_or_run append "${all_merged_file}" '= Shared by Whonix-Gateway and Whonix-Workstation =' >/dev/null
            fi

            cat -- "${file_name_full_path}" | dry_run_or_run tee --append -- "${all_merged_file}" >/dev/null
         done
      done
   done
}

## Echos repository links for all repositories.
pkg_links_echo() {
   printf '%s\n' "https://github.com/${batch_current_project_name}/${batch_current_package_reponame}"
   #printf '%s\n' "https://github.com/adrelanos/${batch_current_package_reponame}"
}

## Echos Markdown-formatted links for all repositories.
pkg_links_markdown_echo() {
   printf '%s\n' "[${batch_current_package_reponame}](https://github.com/${batch_current_project_name}/${batch_current_package_reponame})"
}

## Echos links to all commits made by adrelanos for all repositories.
pkg_echo_commits_adrelanos() {
   printf '%s\n' "https://github.com/${batch_current_project_name}/${batch_current_package_reponame}/commits?author=adrelanos"
}

## Echos the names of all packages.
pkg_names_echo() {
   printf '%s\n' "${batch_current_package_reponame}"
}

## Echos the names of all packages, along with the project each package is
## under.
pkg_names_test() {
   printf '%s\n' "${batch_current_package_reponame} | ${batch_current_project_name}"
}

## Opens all package links in Tor Browser.
pkg_links_open() {
   torbrowser --new-tab "$(pkg_links_echo)"
}

## Sets up standard Git remotes for all repos.
pkg_git_remotes_add() {
   dry_run_or_run git remote rm origin || true

   ## Mirror org (DM_GITHUB_MIRROR) - holds forks of every package repo.
   dry_run_or_run git remote add     "${DM_GITHUB_MIRROR}" \
      "git@github.com:${DM_GITHUB_MIRROR}/${batch_current_package_reponame}.git" || true
   dry_run_or_run git remote set-url "${DM_GITHUB_MIRROR}" \
      "git@github.com:${DM_GITHUB_MIRROR}/${batch_current_package_reponame}.git" || true

   ## TODO: remove
   dry_run_or_run git remote rm     adrelanos || true

   dry_run_or_run git remote add     github-adrelanos         "git@github.com:adrelanos/${batch_current_package_reponame}.git" || true
   dry_run_or_run git remote set-url github-adrelanos         "git@github.com:adrelanos/${batch_current_package_reponame}.git" || true

   dry_run_or_run git remote add     gitlab-adrelanos         "git@gitlab.com:adrelanos-only-test/${batch_current_package_reponame}.git" || true
   dry_run_or_run git remote set-url gitlab-adrelanos         "git@gitlab.com:adrelanos-only-test/${batch_current_package_reponame}.git" || true

   dry_run_or_run git remote add     ArrayBolt3         "git@github.com:ArrayBolt3/${batch_current_package_reponame}.git" || true
   dry_run_or_run git remote set-url ArrayBolt3         "git@github.com:ArrayBolt3/${batch_current_package_reponame}.git" || true

   if [ "${batch_current_project_name,,}" = "whonix" ]; then
      dry_run_or_run git remote add     github-whonix     "git@github.com:Whonix/${batch_current_package_reponame}.git" || true
      dry_run_or_run git remote set-url github-whonix     "git@github.com:Whonix/${batch_current_package_reponame}.git" || true

      #git remote add     gitlab-whonix     "git@gitlab.com:whonix/${batch_current_package_reponame}.git" || true
      #git remote set-url gitlab-whonix     "git@gitlab.com:whonix/${batch_current_package_reponame}.git" || true
   elif [ "${batch_current_project_name,,}" = "kicksecure" ]; then
      dry_run_or_run git remote add     github-kicksecure "git@github.com:Kicksecure/${batch_current_package_reponame}.git" || true
      dry_run_or_run git remote set-url github-kicksecure "git@github.com:Kicksecure/${batch_current_package_reponame}.git" || true

      #git remote add     gitlab-kicksecure "git@gitlab.com:kicksecure/${batch_current_package_reponame}.git" || true
      #git remote set-url gitlab-kicksecure "git@gitlab.com:kicksecure/${batch_current_package_reponame}.git" || true
   elif [ "${batch_current_project_name,,}" = "derivative-maker" ]; then
      dry_run_or_run git remote add     github-kicksecure "git@github.com:Kicksecure/${batch_current_package_reponame}.git" || true
      dry_run_or_run git remote set-url github-kicksecure "git@github.com:Kicksecure/${batch_current_package_reponame}.git" || true

      dry_run_or_run git remote add     github-whonix "git@github.com:Whonix/${batch_current_package_reponame}.git" || true
      dry_run_or_run git remote set-url github-whonix "git@github.com:Whonix/${batch_current_package_reponame}.git" || true
   else
      printf '%s\n' "ERROR: batch_current_project_name wrong! (1)"
   fi

   return 0
}

pkg_git_check_current_branch() {
   local current_branch
   current_branch="$(git branch --show-current)"

   if [ "$current_branch" = "master" ]; then
      return 0
   fi

   printf '%s\n' "unexpected branch! batch_current_package_reponame: '$batch_current_package_reponame' | current_branch: '$current_branch' | expected: 'master'"
   return 1
}

## Branch-switching targets change which branch is checked out, so the
## on-master pre-flight guard (pkg_git_check_current_branch) must not run
## before them. Otherwise a repo left on a non-master branch (e.g. detached
## HEAD) makes the guard abort under errexit before the very command meant
## to put it back on master can run.
pkg_git_target_is_branch_switch() {
   case "${1}" in
      pkg_git_branch_trixie|\
      pkg_git_branch_checkout_master|\
      pkg_git_branch_checkout_bullseye|\
      pkg_git_branch_checkout_trixie)
         return 0
         ;;
   esac
   return 1
}

## Creates a 'trixie' branch for all repos.
pkg_git_branch_trixie() {
   dry_run_or_run git branch trixie || true
   return 0
}

## Checks out the 'master' branch of all repos.
pkg_git_branch_checkout_master() {
   dry_run_or_run git switch master
   return 0
}

## Checks out the 'bullseye' branch of all repos.
pkg_git_branch_checkout_bullseye() {
   dry_run_or_run git switch bullseye
   return 0
}

## Checks out the 'trixie' branch of all repos.
pkg_git_branch_checkout_trixie() {
   dry_run_or_run git switch trixie
   return 0
}

## Maps the current project to its upstream GitHub remote name: whonix ->
## 'github-whonix'; kicksecure and derivative-maker both -> 'github-kicksecure'
## (there is no 'github-derivative-maker' remote). Single source of truth so the
## upstream pull and diff-and-merge paths cannot diverge.
pkg_git_upstream_remote() {
   case "${batch_current_project_name,,}" in
      whonix)
         printf '%s\n' 'github-whonix'
         ;;
      kicksecure|derivative-maker)
         printf '%s\n' 'github-kicksecure'
         ;;
      *)
         die 1 "batch_current_project_name wrong: '${batch_current_project_name}'"
         ;;
   esac
}

### {{{ simple wrappers

pkg_git_branch_fetch_upstream() {
   pkg_git_branch_fetch_one "github-${batch_current_project_name,,}"
}

pkg_git_branch_fetch_arraybolt3() {
   pkg_git_branch_fetch_one "ArrayBolt3"
}

pkg_git_branch_fetch_bot() {
   pkg_git_branch_fetch_one "${DM_GITHUB_MIRROR}"
}

pkg_git_diff_and_merge_branch_upstream() {
   local upstream_repo
   upstream_repo="$(pkg_git_upstream_remote)"
   pkg_git_diff_and_merge_branch "${upstream_repo}/master"
}

pkg_git_diff_and_merge_branch_arraybolt3() {
   pkg_git_diff_and_merge_branch "ArrayBolt3/arraybolt3/trixie"
}

pkg_git_diff_and_merge_branch_bot() {
   pkg_git_diff_and_merge_branch "${DM_GITHUB_MIRROR}/master"
}

### }}}

pkg_git_branch_fetch_one() {
   local origin_name_list package_remote_item

   origin_name_list=( "$@" )

   for package_remote_item in "${origin_name_list[@]}"; do
      dry_run_or_run git fetch -- "${package_remote_item}" &
      git_pid_label[$!]="fetch ${package_remote_item} (${batch_current_package_reponame})"
   done

   return 0
}

pkg_git_diff_and_merge_branch() {
   #repo_skip 'derivative-maker' || return 0

   local origin_branch upstream_repo
   ## Called with exactly one argument (the '<remote>/<branch>' ref); use
   ## "${1}" not "$@" to avoid SC2124 (array-to-string assignment).
   origin_branch="${1}"
   ## Resolve the project's upstream remote the same way the wrapper and the
   ## pull path do, so the trusted-upstream match below cannot diverge (e.g.
   ## derivative-maker -> github-kicksecure, not github-derivative-maker).
   upstream_repo="$(pkg_git_upstream_remote)"

   ## Use the fully qualified remote-tracking ref to avoid name
   ## collision with same-named tags.
   local origin_ref="refs/remotes/${origin_branch}"

   ## Check if branch exists.
   if ! git --no-pager diff --stat "${origin_ref}" &>/dev/null ; then
      ## No such branch.
      return 0
   fi
   ## Branch exists.

   if [ "$(git --no-pager diff -C --stat ..."${origin_ref}")" = "" ]; then
      ## No diff. Branch already merged.
      return 0
   fi

   if [ "$(git --no-pager diff -C --stat "${origin_ref}")" = "" ]; then
      ## No diff. Branch already merged.
      return 0
   fi

   printf '%s\n' "${batch_current_package_reponame}"
   check-ref-commits-for-unicode "${origin_ref}"
   dry_run_or_run git --no-pager diff -C --stat ..."${origin_ref}"
   dry_run_or_run git --no-pager diff -C ..."${origin_ref}"
   dry_run_or_run git --no-pager kdiff -C ..."${origin_ref}"

   if [ "${origin_branch}" = "${upstream_repo}/master" ]; then
      ## Real upstream master (github-whonix / github-kicksecure): a
      ## trusted, signed source. Same policy as the ArrayBolt3 branch
      ## below: verify the tip's signature (aborts under errexit if it is
      ## missing/invalid, so nothing unsigned is ever merged), then merge
      ## '--no-ff' so the maintainer-signed merge commit authenticates the
      ## commits below it (sq-git policy).
      git verify-commit "${origin_ref}"
      dry_run_or_run git merge --no-ff "${origin_ref}"
   elif [ "${origin_branch}" = "ArrayBolt3/arraybolt3/trixie" ]; then
      ## 'git verify-commit' passes through debug output by 'sq-git-wrapper'.
      git verify-commit "${origin_ref}"
      ## 'git merge' does not pass through debug output by 'sq-git-wrapper'.
      ## '--no-ff' so the maintainer-signed merge commit authenticates
      ## the drive-by commits below it (sq-git policy).
      dry_run_or_run git merge --no-ff "${origin_ref}"
   elif [ "${origin_branch}" = "${DM_GITHUB_MIRROR}/master" ]; then
      ## TODO: Can't we verify against Claude's SSH key?
      ## TODO: Is it ever really safe to mass-merge from the AI branches?
      #git verify-commit "${origin_ref}"
      ## '--no-ff': same sq-git policy rationale as above; maintainer
      ## merge commit authenticates the AI-generated commits below it.
      dry_run_or_run git merge --no-ff --no-verify-signature "${origin_ref}"
   else
      die 1 "UNKNOWN origin_branch: '${origin_branch}'"
   fi

   printf '%s\n' "--------------------------------------------------"
   true

   ## Manually re-run script.
   exit
}

pkg_git_branch_pull_upstream_master() {
   local origin_name_list package_remote_item upstream_repo

   ## Branch(es) to pull from upstream. Without this the loop below
   ## iterated an unset array and pulled nothing (silent no-op).
   origin_name_list=( "master" )

   ## Shared with the diff-and-merge upstream path (see pkg_git_upstream_remote).
   upstream_repo="$(pkg_git_upstream_remote)"

   for package_remote_item in "${origin_name_list[@]}"; do
      dry_run_or_run git pull --ff-only --verify-signatures -- "${upstream_repo}" "${package_remote_item}" &
      git_pid_label[$!]="pull ${package_remote_item} (${batch_current_package_reponame})"
   done

   return 0
}

## Detects packages with Bash profile.d files, and adds symlink lines to zsh's
## zprofile.d directory to the Debian packaging if needed.
pkg_profile_d_zsh_creator() {
   local file_name_list file_name_without_slash profile_d_symlink \
      basename_symlink_with_file_extension \
      basename_symlink_without_file_extension

   if ! test -d 'etc/profile.d' ; then
      return 0
   fi

   readarray -t file_name_list < <(find etc/profile.d/*)

   for file_name_without_slash in "${file_name_list[@]}"; do
      profile_d_symlink="/${file_name_without_slash}"
      basename_symlink_with_file_extension="${profile_d_symlink##*/}"
      basename_symlink_without_file_extension="${basename_symlink_with_file_extension%.*}"
      package_links_file="debian/${batch_current_package_reponame}.links"
      z_profile_d_symlink="/etc/zprofile.d/${basename_symlink_without_file_extension}.zsh"
      symlink_config_entry="$profile_d_symlink $z_profile_d_symlink"
      if [ -f "${package_links_file}" ]; then
         if grep --quiet "${symlink_config_entry}" "${package_links_file}" ; then
            true 'already exists.'
            continue
         fi
      fi
      dry_run_or_run append "${package_links_file}" "${symlink_config_entry}" >/dev/null
   done

   return 0
}

## Updated CONTRIBUTING.md in all repos except derivative-maker and kloak.
pkg_copy_contributing_file() {
   repo_skip 'derivative-maker' || return 0
   repo_skip 'kloak' || return 0

   dry_run_or_run cp -- "${derivative_maker_source_code_dir}/CONTRIBUTING.md" "${batch_current_package_path}/CONTRIBUTING.md"

   dry_run_or_run git add -- "${batch_current_package_path}/CONTRIBUTING.md"
   dry_run_or_run git commit -m "update"

   return 0
}

## True if .github/dependabot.yml carries the
## "## propagation: manual" opt-out marker.
dependabot_yml_is_manual() {
   local target_path

   target_path="${batch_current_package_path}/.github/dependabot.yml"

   if ! [ -f "${target_path}" ]; then
      return 1
   fi

   if grep --quiet --extended-regexp '^## propagation: manual$' "${target_path}"; then
      return 0
   fi

   return 1
}

## Bulk variant of pkg_update_if_exists: iterates a fixed set of
## consumer-wrapper filenames against canonical_workflows_dir, and
## also refreshes .github/dependabot.yml from the canonical at
## developer-meta-files/consumer-templates/.github/dependabot.yml.
## dependabot.yml refresh honours "## propagation: manual".
pkg_update_consumer_workflows() {
   local workflow_source_path workflow_basename destination_path
   local dependabot_destination
   local -a workflow_source_list

   test -d "${canonical_workflows_dir}"

   ## nullglob so an empty consumer-templates/.github/workflows yields
   ## a 0-element array instead of the literal '*.yml' pattern.
   shopt -s nullglob
   workflow_source_list=( "${canonical_workflows_dir}"/*.yml )
   shopt -u nullglob

   for workflow_source_path in "${workflow_source_list[@]}"; do
      workflow_basename="${workflow_source_path##*/}"
      destination_path="${batch_current_package_path}/.github/workflows/${workflow_basename}"
      pkg_update_if_exists \
         "${workflow_source_path}" \
         "${destination_path}" \
         "update .github/workflows/${workflow_basename}"
   done

   dependabot_destination="${batch_current_package_path}/.github/dependabot.yml"

   test -f "${canonical_dependabot_yml}"

   ## Unconditionally install dependabot.yml if missing.
   pkg_install_dependabot_yml

   if dependabot_yml_is_manual; then
      true "INFO: ${batch_current_package_reponame}: .github/dependabot.yml carries '## propagation: manual'; skipping update."
   else
      pkg_update_if_exists \
         "${canonical_dependabot_yml}" \
         "${dependabot_destination}" \
         "update .github/dependabot.yml"
   fi

   return 0
}

## Installs the canonical .github/dependabot.yml into every repo
## that uses GitHub Actions (has .github/workflows/) and doesn't
## already carry the file. INSTALL semantic, parallel to
## pkg_install_codeql_actions_scan. Idempotent. Honours
## "## propagation: manual".
pkg_install_dependabot_yml() {
   local destination_dir destination_path

   destination_dir="${batch_current_package_path}/.github"
   destination_path="${destination_dir}/dependabot.yml"

   if ! [ -f "${canonical_dependabot_yml}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: canonical '${canonical_dependabot_yml}' absent!${reset}"
      exit 1
   fi

   if ! [ -d "${batch_current_package_path}/.github/workflows" ]; then
      true "INFO: ${batch_current_package_reponame}: no .github/workflows/ directory; skipping."
      return 0
   fi

   ## INSTALL semantic: only acts when the file is missing. Any existing
   ## file (including ones carrying '## propagation: manual') is left
   ## alone here; the manual-marker check matters only for the UPDATE
   ## path in pkg_update_consumer_workflows.
   if [ -f "${destination_path}" ]; then
      true "INFO: ${batch_current_package_reponame}: dependabot.yml already present; skipping."
      return 0
   fi

   dry_run_or_run cp --verbose -- "${canonical_dependabot_yml}" "${destination_path}"

   dry_run_or_run git add -- "${destination_path}"

   if nothing_to_commit; then
      true "INFO: ${FUNCNAME[0]}: nothing to commit"
      return 0
   fi

   dry_run_or_run git commit -m "add .github/dependabot.yml" || true

   return 0
}

## Installs the canonical consumer-codeql-actions.yml consumer wrapper into
## every repo that uses GitHub Actions (has .github/workflows/) and
## doesn't already carry the file. INSTALL semantic, in contrast to
## pkg_update_consumer_workflows's UPDATE-if-exists. Idempotent:
## a re-run after rollout no-ops everywhere.
pkg_install_codeql_actions_scan() {
   local source_path destination_dir destination_path

   source_path="${derivative_maker_source_code_dir}/packages/kicksecure/developer-meta-files/.github/workflows/consumer-codeql-actions.yml"
   destination_dir="${batch_current_package_path}/.github/workflows"
   destination_path="${destination_dir}/consumer-codeql-actions.yml"

   if ! [ -f "${source_path}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: canonical '${source_path}' absent!${reset}"
      exit 1
   fi

   if ! [ -d "${destination_dir}" ]; then
      true "INFO: ${batch_current_package_reponame}: no .github/workflows/ directory; skipping."
      return 0
   fi

   if [ -f "${destination_path}" ]; then
      true "INFO: ${batch_current_package_reponame}: consumer-codeql-actions.yml already present; skipping."
      return 0
   fi

   dry_run_or_run cp -- "${source_path}" "${destination_path}"
   dry_run_or_run git add -- "${destination_path}"

   if nothing_to_commit; then
      true "INFO: ${FUNCNAME[0]}: nothing to commit"
      return 0
   fi

   dry_run_or_run git commit -m "add .github/workflows/consumer-codeql-actions.yml"

   return 0
}

## Gets rid of all 'debian/compat' files in all repos.
pkg_compat_delete() {
   ## `|| true` so re-runs in repos where the file was already
   ## removed do not abort the whole batch with a fatal `pathspec
   ## did not match any files`.
   dry_run_or_run git rm -- "debian/compat" || true

   return 0
}

## Updates all packages that ship systemd services and Build-Depend on
## debhelper 13 or later to depend on debhelper 13.11.6 or later.
pkg_build_depends_systemd() {
   local search replace

   repo_skip 'derivative-maker' || return 0

   if ! find . -not -iwholename '*.git*' | grep --fixed-strings 'usr/lib/systemd/system' | grep --color '.service$' ; then
      return 0
   fi

   search='Build-Depends: debhelper (>= 13)'
   replace='Build-Depends: debhelper (>= 13.11.6)'
   str_replace "${search}" "${replace}" "${batch_current_package_path}/debian/control"

   return 0
}

## Previously was used to do mass package changes for porting packages from
## Bullseye to Bookworm. Retained for future reference.
# pkg_debhelper_bump() {
#    local one two
#    one='debhelper (>= 12)'
#    two='debhelper (>= 13), debhelper-compat (= 13)'
#    str_replace "$one" "$two" "${batch_current_package_path}/debian/control"
#
#    one='http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/'
#    two='https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/'
#    str_replace "$one" "$two" debian/copyright
#    str_replace "$one" "$two" COPYING
#
#    one="Standards-Version: 3.9.8"
#    two="Standards-Version: 4.6.2
#
#    str_replace "$one" "$two" "${batch_current_package_path}/debian/control"
#
#    one="debian-watch-may-check-gpg-signature"
#    two="debian-watch-does-not-check-openpgp-signature"
#    str_replace "$one" "$two" "${batch_current_package_path}/debian/source/lintian-overrides"
#
#    one="ruby-ronn"
#    two="ronn"
#    str_replace "$one" "$two" "${batch_current_package_path}/debian/control"
#
#    one='Priority: extra'
#    two='Priority: optional'
#    str_replace "$one" "$two" "${batch_current_package_path}/debian/control"
#
#    one='--with=config-package --with=systemd'
#    two='--with=config-package'
#    str_replace "$one" "$two" "${batch_current_package_path}/debian/rules"
#
#    git add "${batch_current_package_path}/debian/rules"
#    git commit -m "port to debian bookworm" || true
# }

## Generates diffs for specific files between each package and an arbitrary
## template package. Also checks for the existence of several files, and
## synchronizes files that should be identical.
pkg_packaging_files_diff() {
   local compare_with_full_path contributing_md_absent_package_list \
      package_lacks_contributing_md contributing_md_absent_package \
      check_file_list check_file

   repo_skip 'derivative-maker' || return 0

   compare_with_full_path="$(realpath "../../${packaging_files_diff_template_package_relative_path}")"

   if [ "${batch_func_init_done}" = 'n' ]; then
      if ! [ -f "${compare_with_full_path}/COPYING" ]; then
         printf '%s\n' "${red}${bold}FATAL ERROR: '${compare_with_full_path}/COPYING' does not exist!"
         exit 1
      fi
      batch_func_init_done='y'
   fi

   ## Don't bother looking for CONTRIBUTING.md for the following list of
   ## packages. For all others, make sure that CONTRIBUTING.md exists.
   contributing_md_absent_package_list=(
      'mediawiki-shell'
      'tirdad'
      'tor-ctrl'
      'kloak'
   )

   package_lacks_contributing_md='n'
   for contributing_md_absent_package in "${contributing_md_absent_package_list[@]}"; do
      if [ "${batch_current_package_reponame}" = "${contributing_md_absent_package}" ]; then
         package_lacks_contributing_md='y'
         break
      fi
   done
   if ! [ "${package_lacks_contributing_md}" = 'y' ]; then
      if ! [ -f "${batch_current_package_path}/CONTRIBUTING.md" ]; then
         printf '%s\n' "${red}${bold}FATAL ERROR: Package '${batch_current_package_reponame}' is missing a CONTRIBUTING.md file!${reset}"
         exit 1
      fi
   fi

   if ! [ -f "${batch_current_package_path}/README.md" ]; then
      if ! [ -f "${batch_current_package_path}/README.mediawiki" ]; then
         ## TODO: Should README_generic.d be taken into account here?
         printf '%s\n' "${red}${bold}FATAL ERROR: Neither '${batch_current_package_path}/README.md' nor '${batch_current_package_path}/README.mediawiki' exist!${reset}"
         exit 1
      fi
   fi

   diff "${compare_with_full_path}/debian/source/format" "${batch_current_package_path}/debian/source/format"

   if [ "${batch_current_package_reponame}" = 'corridor' ]; then
      ## Corridor has no COPYING but a LICENSE-ISC file.
      if ! [ -f "${batch_current_package_path}/LICENSE-ISC" ]; then
         printf '%s\n' "${red}${bold}FATAL ERROR: '${batch_current_package_path}/LICENSE-ISC' does not exist!${reset}"
         return 1
      fi
#  elif [ "${batch_current_package_reponame}" = 'hardened_malloc' ]; then
#     ## hardened_malloc has no COPYING but a LICENSE file.
#     if ! [ -f "${batch_current_package_path}/LICENSE" ]; then
#        printf '%s\n' "${red}${bold}FATAL ERROR: '${batch_current_package_path}/LICENSE' does not exist!${reset}"
#        return 1
#     fi
   else
      cp -- "${batch_current_package_path}/debian/copyright" "${batch_current_package_path}/COPYING"

      ## COPYING should always match debian/copyright.
      diff "${batch_current_package_path}/debian/copyright" "${batch_current_package_path}/COPYING"

      ## Would show license files that are different.
      ## This is useful to manually enable sometimes.
      #diff "${compare_with_full_path}/COPYING" "${batch_current_package_path}/COPYING"

   fi

   check_file_list=(
      "debian/changelog"
      "debian/control"
      "debian/copyright"
      "debian/rules"
      "debian/watch"
   )
   for check_file in "${check_file_list[@]}"; do
      if ! [ -f "${batch_current_package_path}/${check_file}" ]; then
         printf '%s\n' "${red}${bold}FATAL ERROR: check_file '${batch_current_package_path}/${check_file}' does not exist!"
         exit 1
      fi
   done

   return 0
}

## Fetches remote changes from all Git remotes for all repos.
pkg_git_fetch_remotes() {
   local package_remote_item

   for package_remote_item in "${batch_current_package_remote_list[@]}"; do
      dry_run_or_run git fetch -- "${package_remote_item}" &
      git_pid_label[$!]="fetch ${package_remote_item} (${batch_current_package_reponame})"
   done
   return 0
}

pkg_git_push_atomic() {
   ## repo_skip could technically be used here, but it's confusing in
   ## code and prints a confusing message.
   if [ "${batch_current_package_reponame:-}" = 'derivative-maker' ]; then
      ## Tags created manually when deemed ready; verify only.
      true "Detected package 'derivative-maker', verifying Git commit signature and returning."
      dry_run_or_run genmkfile git-commit-verify >/dev/null
      return 0
   fi

   dry_run_or_run genmkfile git-verify >/dev/null

   internal_git_push_atomic_all_remotes
}

## Like pkg_git_push_atomic but for the AI model, which has no signing key
## yet: pushes the package's branches WITHOUT Git signature verification and
## WITHOUT requiring a tag at the branch tip, so work-in-progress dev commits
## can be mirrored.
## TODO: remove once the AI model has a signing key and can use
## pkg_git_push_atomic.
pkg_git_push_atomic_unsigned() {
   local branch mirror_url
   local -a existing_branches refspecs

   ## The skip path never pushes, so it must not depend on the mirror config.
   if [ "${batch_current_package_reponame:-}" = 'derivative-maker' ]; then
      true "Detected package 'derivative-maker', skipping (tags created manually)."
      return 0
   fi

   ## The bot mirror org must be defined; never fall back to an empty or
   ## implicit push target on the unsigned path.
   if [ -z "${DM_GITHUB_MIRROR:-}" ]; then
      die 1 "${FUNCNAME[0]}: DM_GITHUB_MIRROR (bot mirror org) is not defined; refusing unsigned push."
   fi

   ## Whitelist: the bot mirror org is the only allowed unsigned-push target.
   ## Validate by URL, not just by remote name -- a remote that carries the
   ## mirror name but was re-pointed at a dev or production repo must not
   ## receive unsigned commits.
   mirror_url="$(git remote get-url -- "${DM_GITHUB_MIRROR}" 2>/dev/null)" || mirror_url=''
   if [ -z "${mirror_url}" ]; then
      die 1 "mirror remote '${DM_GITHUB_MIRROR}' not configured locally; nothing to push (run pkg_git_remotes_add)"
   fi
   case "${mirror_url}" in
      *"github.com:${DM_GITHUB_MIRROR}/"*|*"github.com/${DM_GITHUB_MIRROR}/"*)
         true "mirror remote URL verified: '${mirror_url}'"
         ;;
      *)
         die 1 "${FUNCNAME[0]}: remote '${DM_GITHUB_MIRROR}' points at '${mirror_url}', not the bot mirror org on github.com; refusing unsigned push."
         ;;
   esac

   existing_branches=()
   for branch in "${DM_PUSH_BRANCHES_UNIVERSE[@]}"; do
      git show-ref --verify --quiet -- "refs/heads/${branch}" \
         && existing_branches+=( "${branch}" )
   done
   if [ "${#existing_branches[@]}" -eq 0 ]; then
      log notice "none of (${DM_PUSH_BRANCHES_UNIVERSE[*]}) exist locally; nothing to push"
      return 0
   fi

   refspecs=()
   for branch in "${existing_branches[@]}"; do
      refspecs+=( "refs/heads/${branch}:refs/heads/${branch}" )
   done

   dry_run_or_run git push --atomic --porcelain -- "${DM_GITHUB_MIRROR}" "${refspecs[@]}"
}

## Checks out the changelog.upstream file of all repos.
git_reset_changelog_upstream() {
   dry_run_or_run git checkout -- changelog.upstream
}

## Commits changes to the readme files of all repos.
pkg_git_commit_readme() {
   local msg

   if [ -f 'README_generic.md' ]; then
      dry_run_or_run git add -- 'README_generic.md'
   elif [ -f 'README.md' ]; then
      dry_run_or_run git add -- 'README.md'
   elif [ -f 'README.mediawiki' ]; then
      dry_run_or_run git add -- 'README.mediawiki'
   fi

   #git diff --cached
   msg='readme'
   dry_run_or_run git commit -m "${msg}" || true
   dry_run_or_run git status
}

## Commits changes to the changelog files of all repos.
pkg_git_commit_changelog() {
   local msg

   dry_run_or_run git add -- 'debian/changelog'
   if [ -f 'changelog.upstream' ]; then
      dry_run_or_run git add -- 'changelog.upstream'
   fi
   msg='bumped changelog version'
   dry_run_or_run git commit -m "${msg}" || true
   dry_run_or_run git status
   return 0
}

## Creates a new commit called 'copyright' for all repos.
pkg_git_commit_copyright() {
   local msg

   dry_run_or_run git add -- 'debian/copyright'
   if [ -f 'COPYING' ]; then
      dry_run_or_run git add -- 'COPYING'
   fi
   msg='copyright'
   dry_run_or_run git commit -m "${msg}" || true
   dry_run_or_run git status
   return 0
}

pkg_git_commit_debian_control() {
   local msg

   dry_run_or_run git add -- 'debian/control'
   msg='bumped Standards-Version'
   dry_run_or_run git commit -m "${msg}" || true
   dry_run_or_run git status
   return 0
}

## Commits all changes to all repos with a tunable-set message.
## Code path below `exit 1` is intentionally a draft.
# shellcheck disable=SC2317
pkg_git_commit_all() {
   printf '%s\n' TODO
   exit 1
   dry_run_or_run git add -A
   dry_run_or_run git commit -m "${git_commit_all_msg}" || true
   dry_run_or_run git status
   return 0
}

## Regenerate and commit manpages for all repos.
pkg_git_manpages() {
   local msg

   if ! [ -d 'man' ]; then
      return 0
   fi

   dry_run_or_run genmkfile manpages

   msg='re-generate man pages (generated using "genmkfile manpages")'
   dry_run_or_run git add -- 'man'/* || true
   dry_run_or_run git add -- 'auto-generated-man-pages'/* || true
   dry_run_or_run git commit -m "${msg}" || true
   dry_run_or_run git status
   return 0
}

## Generate and commit debian install files for all repos.
pkg_git_debinstfile() {
   local msg

   repo_skip 'derivative-maker' || return 0

   ## Skipped because the install file for corridor is maintained manually.
   repo_skip 'corridor' || return 0

   dry_run_or_run genmkfile debinstfile

   msg='add debian install file (generated using "genmkfile debinstfile")'
   printf '%s\n' "PWD: '$PWD'"
   dry_run_or_run git add -- "debian/${batch_current_package_reponame}.install" || true
   dry_run_or_run git commit -m "${msg}" || true
   dry_run_or_run git status
   return 0
}

pkg_git_commit_packaging() {
   local msg

   repo_skip 'derivative-maker' || return 0

   if nothing_to_commit; then
      return 0
   fi

   #git add 'Makefile'
   #git add 'make-helper.bsh'
   #git add 'debian/changelog'
   #git add 'changelog.upstream'
   #git add 'debian'/*'.install'
   dry_run_or_run git add -- 'debian/control'
   #git add -A
   msg='update debian/control'
   dry_run_or_run git commit -m "${msg}"
   dry_run_or_run git status
   return 0
}

## Add a dh_installchangelogs entry to all packages that don't already have
## one. Note that this was modified from the original to avoid writing
## duplicates.
pkg_add_dh_changelogs_override_to_debian_rules() {
   repo_skip 'derivative-maker' || return 0

   if grep --quiet 'override_dh_installchangelogs' 'debian/rules'; then
      return 0
    fi

   dry_run_or_run append "debian/rules" "
override_dh_installchangelogs:
  dh_installchangelogs changelog.upstream upstream" >/dev/null
   return 0
}

## Hard-reset all repos except for derivative-maker and developer-meta-files.
pkg_git_reset() {
   repo_skip 'derivative-maker' || return 0
   repo_skip 'developer-meta-files' || return 0
   dry_run_or_run git reset --hard
   dry_run_or_run git clean -dff
}

## Does a git diff followed by a git commit on all repos with changes, except
## for derivative-maker.
pkg_git_diff_and_commit() {
   repo_skip 'derivative-maker' || return 0

   ## Remove extra new lines.
   ## Thanks to llua http://unix.stackexchange.com/a/81689
   #a=$(<debian/rules); printf '%s\n' "$a" > debian/rules
   #continue

   if nothing_to_commit; then
      true "press enter to continue_not"
      #read -r temp
      return 0
   fi

   dry_run_or_run git add -A
   ## Reviewing the maintainer's own staged changes -> meld's GUI is preferred.
   ## git-diff-review (terminal-safe textual driver) is for untrusted diffs;
   ## kept commented for quick re-enable.
   #dry_run_or_run git-diff-review --cached
   dry_run_or_run git-meld --cached

   true "press enter to continue"
   #read -r temp

   dry_run_or_run git add -A
   ## TODO
   dry_run_or_run git commit -m "typo"

   if nothing_to_commit; then
      true "press enter to continue_not"
      #read -r temp
      return 0
   fi

   return 0
}

## Opens the debian/control file of all repos in Mousepad.
pkg_debian_control_open() {
   mousepad "${batch_current_package_path}/debian/control" &

   return 0
}

## Finds all Bash scripts in all repos and syntax-checks them.
pkg_bash_sanity_test() {
   local grep_opts

   grep_opts=(
      '--exclude-dir=.git'
      '--exclude-dir=auto-generated-man-pages'
      '--exclude-dir=man'
      '--recursive'
      '--fixed-strings'
      '--max-count=1'
      '--files-with-matches'
      '--binary-files=without-match'
      '--exclude=*.md'
      '--exclude=*.pas'
      '--exclude=*.upstream'
      '--exclude=*.yml'
      '--exclude=*.yaml'
   )

   ## Not using --quiet, because `man 1 grep` documents:
   ##
   ##   ...if the -q or --quiet or --silent is used and a line is selected,
   ##   the exit status is 0 even if an error occurred.
   if ! grep "${grep_opts[@]}" -- '#!/bin/bash' >/dev/null 2>&1 ; then
      return 0
   fi

   grep "${grep_opts[@]}" -- '#!/bin/bash' | xargs -n 1 bash -n --

   return 0
}

## Rewrites the Git submodules file for the derivative-maker repo.
pkg_git_submodule_file_writer() {
   local url

   repo_skip 'derivative-maker' || return 0

   if [ "${batch_func_init_done}" = 'n' ]; then
      dry_run_or_run safe-rm -- "${derivative_maker_source_code_dir}/.gitmodules"
      batch_func_init_done='y'
      dry_run_or_run append "${derivative_maker_source_code_dir}/.gitmodules" '## This file is autogenerated by:
## autogenerated by dm-packaging-helper-script function pkg_git_submodule_file_writer

## BEGIN hardcoded part BEGIN ##

[submodule "live-build"]
        path = live-build
        url  = https://github.com/Kicksecure/live-build.git
        #url = https://gitlab.com/Kicksecure/live-build.git

[submodule "grml-debootstrap"]
        path = grml-debootstrap
        url  = https://github.com/Kicksecure/grml-debootstrap.git
        #url = https://gitlab.com/Kicksecure/grml-debootstrap.git

[submodule "grml-debootstraptest"]
        path = grml-debootstraptest
        url  = https://github.com/Kicksecure/grml-debootstraptest.git
        #url = https://gitlab.com/Kicksecure/grml-debootstraptest.git

[submodule "whonix-installer"]
        path = windows/Whonix-Installer
        url  = https://github.com/Whonix/Whonix-Installer.git
        #url = https://gitlab.com/whonix/Whonix-Installer.git

[submodule "whonix-starter"]
        path = windows/Whonix-Starter
        url  = https://github.com/Whonix/Whonix-Starter.git
        #url = https://gitlab.com/whonix/Whonix-Starter.git

[submodule "qubes-template-kicksecure"]
        path = qubes/qubes-template-kicksecure
        url  = https://github.com/Kicksecure/qubes-template-kicksecure.git
        #url = https://gitlab.com/Kicksecure/qubes-template-kicksecure.git

[submodule "qubes-template-whonix"]
        path = qubes/qubes-template-whonix
        url  = https://github.com/Whonix/qubes-template-whonix.git
        #url = https://gitlab.com/Whonix/qubes-template-whonix.git

## END hardcoded part END ##' >/dev/null
   fi

   url="\
        url = https://github.com/${batch_current_project_name}/${batch_current_package_reponame}.git
        #url = https://gitlab.com/${batch_current_project_name,,}/${batch_current_package_reponame}.git"

   dry_run_or_run append "${derivative_maker_source_code_dir}/.gitmodules" "\
[submodule \"${batch_current_package_reponame}\"]
        path = packages/${batch_current_project_name,,}/${batch_current_package_reponame}
$url" >/dev/null

   dry_run_or_run append "${derivative_maker_source_code_dir}/.gitmodules" "" >/dev/null
}

## Generates a release announcement.
pkg_git_packages_git_log_writer() {
   local package_header_written package_version_old package_version_new \
      new_package commit_msg_short_list commit_hash commit_msg_short \
      committer_person commit_msg_full credit_msg commit_msg_line \
      commit_first_line temp_folder_path

   if [ "${batch_func_init_done}" = 'n' ]; then
      post_run_hook_list+=( 'generate_announcement' )
      dry_run_or_run safe-rm -f -- "${announcements_drafts_dir}/derivative-maker_giant_git_log.txt"
      dry_run_or_run safe-rm -f -- "${announcements_drafts_dir}/kicksecure_giant_git_log.txt"
      dry_run_or_run safe-rm -f -- "${announcements_drafts_dir}/whonix_giant_git_log.txt"
      batch_func_init_done='y'
   fi

   ## Strip the source-code directory prefix from $PWD so we get a
   ## repo-relative path; using ${derivative_maker_source_code_dir}
   ## rather than a hardcoded /home/user/... so this also works when
   ## $HOME is not /home/user.
   temp_folder_path="${PWD#"${derivative_maker_source_code_dir}/"}"

   if [ "${batch_current_package_reponame}" != 'derivative-maker' ]; then
      pushd -- .. >/dev/null
      ## Use refs/tags/ to avoid name collision with same-named branches.
      if package_version_old="$(git rev-parse "refs/tags/${derivative_version_old_main}:${temp_folder_path}")" ; then
         new_package='n'
      else
         new_package='y'
      fi
      package_version_new="$(git rev-parse "refs/tags/${derivative_version_new_main}:${temp_folder_path}")"
      popd >/dev/null
   else
      new_package='n'
      package_version_old="${derivative_version_old_main}"
      package_version_new="${derivative_version_new_main}"
   fi

   if [ "${new_package}" = 'n' ]; then
      if [ "${package_version_old}" = "${package_version_new}" ]; then
         true "skipping because package_version_old = package_version_new"
         return 0
      else
         commit_msg_short_list="$(git --no-pager log --pretty='%H %s' "${package_version_old}..${package_version_new}")"
      fi
   else
      commit_msg_short_list="$(git --no-pager log --pretty='%H %s')"
   fi

   if [ "$commit_msg_short_list" = "" ]; then
      true "${batch_current_package_reponame}: commit_msg_short_list empty?"
      exit 1
   fi

   package_header_written='n'

   while read -r commit_hash commit_msg_short ; do
      if [ "$commit_msg_short" = "" ]; then
         true "${batch_current_package_reponame}: commit_hash: $commit_hash | commit_msg_short empty"
         continue
      fi
      if ! commit_filter "${commit_msg_short}" ; then
         true "SKIP: ${commit_msg_short}"
         continue
      fi
      true "OK: ${commit_msg_short}"

      committer_person="$(git log --format='%an' -n 1 "${commit_hash}")"
      commit_msg_full="$(git log --format='%B' -n 1 "${commit_hash}")"
      ## Strip trailing whitespace from every line, then delete the lines that
      ## are left blank. Also strip AI-assistant commit trailers
      ## ("Co-Authored-By:", "Claude-Session:" and the "Generated with
      ## [Claude Code]" footer) so they do not leak into the generated
      ## changelog. The 'I' address flag is case-insensitive to also catch
      ## git's canonical "Co-authored-by:" casing. Deleting the whole footer
      ## line also drops its leading emoji, keeping the changelog ASCII-only.
      ## Interior newlines are intentionally kept (see the emit loop below) so
      ## a multi line commit message keeps its structure in the changelog
      ## instead of being collapsed onto a single run-on line.
      commit_msg_full="$(printf '%s\n' "${commit_msg_full}" \
         | sed 's/[[:space:]]*$//; /^$/d; /^[[:space:]]*Co-Authored-By:/Id; /^[[:space:]]*Claude-Session:/Id; /Generated with \[Claude Code\]/Id')"

      if [ "${committer_person}" = 'madaidan' ]; then
         committer_person="@${committer_person}"
      elif [ "${committer_person}" = 'Gavin Pacini' ]; then
         committer_person='@GavinPacini'
      elif [ "${committer_person}" = 'JeremyRand' ]; then
         committer_person='@JeremyRand'
      elif [ "${committer_person}" = 'HulaHoop0' ]; then
         committer_person='@HulaHoop'
      elif [ "${committer_person}" = 'Raja Grewal' ]; then
         committer_person='@raja'
      elif [ "${committer_person}" = 'raja-grewal' ]; then
         committer_person='@raja'
      elif [ "${committer_person}" = 'Aaron Rainbolt' ]; then
         committer_person='@ArrayBolt3'
      elif [ "${committer_person}" = 'TNT BOM BOM' ]; then
         committer_person='@nurmagoz'
      elif [ "${committer_person}" = 'assisted-by-ai (Bot Account)' ]; then
         committer_person='ai'
      elif printf '%s\n' "${committer_person}" | grep -i --fixed-strings -e "claude" -e "anthropic" &>/dev/null; then
         committer_person='ai'
      fi

      if [ "${committer_person}" = 'Patrick Schleizer' ]; then
         credit_msg=""
      elif [ "${committer_person}" = 'ai' ]; then
         credit_msg=" (AI assisted)"
      else
         credit_msg=" (Thanks to ${committer_person}!)"
      fi

      if [ "${package_header_written}" = 'n' ]; then
         dry_run_or_run append "${batch_current_package_changelog}" "* ${batch_current_package_reponame}:" >/dev/null
         package_header_written='y'
      fi

      ## Emit the commit subject (first line) as the bullet, carrying the
      ## contributor credit, then each remaining body line indented beneath it
      ## so the message keeps its original line breaks. IFS= keeps leading
      ## indentation of body lines (e.g. code blocks) intact.
      commit_first_line='y'
      while IFS= read -r commit_msg_line ; do
         if [ "${commit_first_line}" = 'y' ]; then
            dry_run_or_run append "${batch_current_package_changelog}" "  * ${commit_msg_line}${credit_msg}" >/dev/null
            commit_first_line='n'
         else
            dry_run_or_run append "${batch_current_package_changelog}" "    ${commit_msg_line}" >/dev/null
         fi
      done <<< "${commit_msg_full}"
   done <<< "${commit_msg_short_list}"

   if [ "${package_header_written}" = 'y' ]; then
      ## Add newline but only if there were actually changelog lines.
      dry_run_or_run append "${batch_current_package_changelog}" "" >/dev/null
   fi

   return 0
}

## Signs git tags for all repos except derivative-maker. The
## special-vs-regular dispatch lives in genmkfile make_git_tag_sign.
pkg_git_sign_tags() {
   local prefix
   prefix="${cyan}${batch_current_package_reponame}${reset}: pkg_git_sign_tags"

   repo_skip 'derivative-maker' || return 0

   ## Step 1/3: HEAD commit must be signed.
   true "${prefix} 1/3 verify HEAD commit signed"
   genmkfile git-commit-verify  >/dev/null

   ## Step 2/3: HEAD must have a signed tag pointing at it; sign one if not.
   true "${prefix} 2/3 verify HEAD tag signed, sign if absent"
   if ! genmkfile git-tag-verify 2>/dev/null; then
      dry_run_or_run genmkfile git-tag-sign >/dev/null
   fi

   ## Step 3/3: full sanity-check (commit + tag).
   ## Wrapped in dry-run because step 2's sign was a no-op in dry-run.
   true "${prefix} 3/3 sanity check"
   dry_run_or_run genmkfile git-verify >/dev/null

   pkg_git_qubes_template_sync_trixie
}

## derivative-maker submodules for these two repos are pinned at master
## tip locally, but trixie is still published and must carry a tag at
## its tip so a shallow submodule clone doesn't flag it as untagged.
## Idempotent; no-op for any other repo.
pkg_git_qubes_template_sync_trixie() {
   local master_head current_branch
   local prefix

   prefix="${cyan}${batch_current_package_reponame}${reset}: ${FUNCNAME[0]}"

   case "${batch_current_package_reponame}" in
      qubes-template-kicksecure|qubes-template-whonix)
         true "INFO: pkg_git_qubes_template_sync_trixie: yes"
         ;;
      *)
         true "INFO: pkg_git_qubes_template_sync_trixie: no"
         return 0
         ;;
   esac

   current_branch="$(git branch --show-current)"
   if [ "${current_branch}" != "master" ]; then
      die 1 "${prefix} expected HEAD on master, got '${current_branch}'"
   fi

   master_head="$(git rev-parse --verify 'HEAD^{commit}')"

   if ! git show-ref --verify --quiet -- "refs/heads/trixie"; then
      true "${prefix} 'trixie' branch missing locally, nothing to sync"
      return 0
   fi

   ## Ancestry check runs in both live and dry-run so divergence
   ## surfaces before any destructive op.
   if ! git merge-base --is-ancestor "refs/heads/trixie" "${master_head}"; then
      die 1 "${prefix} 'trixie' commit(s) not on master."
   fi

   ## Dry-run short-circuit: a per-step dry_run_or_run wrap leaves the
   ## live 'genmkfile git-tag-verify' below running against master HEAD
   ## (because the switch above was dry-runned) and reporting a
   ## misleading "trixie" state. Print one WOULD line instead.
   if [ "${batch_meta_dry_run}" = "y" ]; then
      log notice "${prefix} [DRY-RUN] WOULD: switch to trixie, ff-merge ${master_head}, sign tag if absent, switch back"
      return 0
   fi

   git switch trixie
   git merge --ff-only -- "${master_head}"
   if ! genmkfile git-tag-verify 2>/dev/null; then
      genmkfile git-tag-sign >/dev/null
   fi
   genmkfile git-verify >/dev/null
   git switch master

   return 0
}

## Finds packages that have modifications made to them since the last version
## bump, and displays their repo names. Also communicates that the package in
## question needs a version bump via a global variable, which is consumed by
## other functions in this script.
## NOTE: When calling this directly, redirect STDERR to /dev/null with
## 2>/dev/null.
pkg_need_version_bump_show() {
   local changelog_version source_pkg reprepro_version reprepro_version_source repo_query_output

   batch_current_package_needs_version_bump='n'

   repo_skip 'derivative-maker' || return 0

   ## Bash syntax check for every special and regular repo every
   ## batch iteration, independent of whether a bump is needed -
   ## catches script breakage even when no commits are pending.
   pkg_bash_sanity_test

   if repo_is_special; then
      ## Special (non-package) repos have no debian/changelog to bump.
      ## "needs version bump" here means "HEAD commit is signed but no
      ## signed tag points at HEAD". The downstream pkg_git_sign_tags
      ## will create one via dm-quickgittagsign.
      if ! nothing_to_commit; then
         true "uncommitted changes in special repo"
         return 1
      fi
      genmkfile git-commit-verify >/dev/null
      if genmkfile git-verify &>/dev/null; then
         return 0
      fi
      batch_current_package_needs_version_bump='y'
      printf '%s\n' "${bold}${batch_current_package_reponame}${reset}"
      return 0
   fi

   ## Forced bump: skip the reprepro query entirely and fall straight
   ## through to the bump+build pipeline. The bump-already-done guard
   ## in pkg_upstream_and_debian_changelog_bump is also force-aware,
   ## so a forced run with last commit == "bumped changelog version"
   ## bumps to the next version as expected.
   if [ "${batch_meta_force_version_bump:-n}" = "y" ]; then
      true "${batch_current_package_reponame}: forced version bump - skipping reprepro version check."
   else
      changelog_version="$(dpkg-parsechangelog --show-field Version)"
      source_pkg="$(dpkg-parsechangelog --show-field Source)"

      ## dm-reprepro-wrapper reads derivative_repository_name from env.
      export derivative_repository_name="${batch_current_project_name,,}"

      repo_query_output="$(dm-reprepro-wrapper --architecture source list "${dist_build_apt_codename}" "${source_pkg}" 2>/dev/null || true)"
      ## Example repo_query_output:
      ## local|main|source: sdwdate 1.2.3-1

      if [ -z "${repo_query_output}" ]; then
         true "${red}${bold}ERROR:${reset} changelog ${changelog_version} vs repo_query_output $repo_query_output is empty"
         return 1
      fi

      reprepro_output_cleaned="$(printf '%s\n' "${repo_query_output}" | grep --fixed-strings --invert-match -- "#grep-ignore-marker#")" || true
      reprepro_version_source="$(printf '%s\n' "${reprepro_output_cleaned}" | grep -- "source")" || true
      reprepro_version="$(printf '%s\n' "${reprepro_version_source}" | awk -- 'NF{print $NF}')" || true
      ## Example reprepro_version:
      ## 1.2.3-1

      if [ -z "${reprepro_version}" ]; then
         true "INFO: needs build: yes - ${source_pkg}: reprepro absent"
      elif dpkg --compare-versions "${changelog_version}" eq "${reprepro_version}"; then
         if is_changelog_bump_commit; then
            true "INFO: needs build: no - ${source_pkg}: changelog matches reprepro and the last commit contains the bump message."
            return 0
         else
            true "INFO: needs build: yes - ${source_pkg}: matches reprepro but new commits since last bump."
         fi
      elif dpkg --compare-versions "${changelog_version}" lt "${reprepro_version}"; then
         true "${red}${bold}ERROR:${reset} '${source_pkg}': changelog < reprepro. Changelog is BEHIND reprepro?!?"
         return 1
      elif dpkg --compare-versions "${changelog_version}" gt "${reprepro_version}"; then
         true "INFO: needs build: yes - '${source_pkg}': changelog > reprepro."
      else
         die 1 "${FUNCNAME[0]}: unknown else case."
      fi
   fi

   if ! nothing_to_commit; then
      true "uncommitted changes #1"
      return 1
   fi

   ## Verify that the commit prior the next changelog commit is signed.
   genmkfile git-commit-verify >/dev/null

   batch_current_package_needs_version_bump='y'

   printf '%s\n' "${batch_current_package_reponame}"
   return 0
}

## Bumps the debian/changelog version and rewrites the changelog.upstream file
## for all repos except derivative-maker. Generally called by other batch
## functions when necessary, calling this directly is probably a bad idea.
pkg_upstream_and_debian_changelog_bump() {
   repo_skip 'derivative-maker' || return 0

   ## No debian/changelog in special repos - nothing to bump. The
   ## tag-creation step happens in pkg_git_sign_tags via
   ## dm-quickgittagsign, downstream of this no-op.
   if repo_is_special; then
      return 0
   fi

   ## Forced re-bump bypasses the idempotency guard below: a forced
   ## run with last commit == "bumped changelog version" should bump
   ## to the next version, not skip.
   if [ "${batch_meta_force_version_bump:-n}" = "y" ]; then
      true "${batch_current_package_reponame}: forced version bump - bumping regardless of last commit."
   elif is_changelog_bump_commit; then
      ## Idempotency for resumed interrupted builds: if the last
      ## commit is already the bump, pkg_need_version_bump_show let
      ## us through because reprepro is behind (changelog >
      ## reprepro). Re-bumping here would produce a useless second
      ## bump with no intervening changes - skip and let the
      ## downstream build+reprepro-add steps resume.
      true "${batch_current_package_reponame}: changelog already bumped; skipping re-bump (resuming interrupted build)."
      return 0
   fi

   ## version_numbers_by_upstream is set by make-helper-overrides.bsh by
   ## individual packages.
   printf '%s\n' "version_numbers_by_upstream: '${version_numbers_by_upstream:-}'"

   ## TODO:
   ## Not the cleanest way. Other variables might be unwanted.
   local version_numbers_by_upstream

   ## Complex, slow.
   #make_init ## includes make_get_variables
   ## sets maybe: version_numbers_by_upstream
   make_source_overrides_file
   make_source_overrides_folder

   ## If version_numbers_by_upstream=true then 'genmkfile deb-uachl-bumpup-major'
   ## would be bumping only the package revision number. Not the version number.
   ## In that case reprepro ('genmkfile reprepro-add') would then detect the same
   ## source tarball version but with different content (checksum) and therefore
   ## refuse adding it.
   if [ "${version_numbers_by_upstream:-}" = "true" ]; then
      ## XXX: manual
      dry_run_or_run genmkfile deb-uachl-bumpup-manual
   else
      dry_run_or_run genmkfile deb-uachl-bumpup-major
   fi

   dry_run_or_run genmkfile deb-uachl-commit-changelog

   return 0
}

## Does changelog bumps on all repos that need it except derivative-maker,
## then pushes the modified repos.
## NOTE: When calling this directly, redirect STDERR to /dev/null with
## 2>/dev/null.
pkg_need_version_bump_do() {
   repo_skip 'derivative-maker' || return 0

   ## sets: batch_current_package_needs_version_bump
   pkg_need_version_bump_show

   if [ ! "${batch_current_package_needs_version_bump}" = 'y' ]; then
      return 0
   fi

   true "INFO: ${FUNCNAME[0]}: needs version bump: ${batch_current_package_reponame}"

   pkg_upstream_and_debian_changelog_bump
   ## Verify that the changelog bump commit is signed.
   genmkfile git-commit-verify

   ## Includes "genmkfile git-commit-verify".
   pkg_git_sign_tags

   pkg_git_push_atomic

   return 0
}

## For all packages that have modifications since the last changelog bump:
##
## * Regenerates manpages
## * Rewrites the Debian install file
## * Does version number bumps
## * Pushes the changes to Git
## * Builds the package
## * Adds the built package to a reprepro repository
## * Cleans up after the build
##
## NOTE: When calling this directly, redirect STDERR to /dev/null with
## 2>/dev/null.
pkg_need_version_bump_and_pkg_build_and_reprepro_add() {
   repo_skip 'derivative-maker' || return 0

   ## Special (non-package) repos: tag-and-push only. No manpages,
   ## no debinstfile, no Debian build, no reprepro - none of those
   ## apply to repos without debian/* files. pkg_need_version_bump_do
   ## dispatches internally on repo_is_special; see
   ## pkg_need_version_bump_show, pkg_upstream_and_debian_changelog_bump,
   ## and pkg_git_sign_tags.
   if repo_is_special; then
      pkg_need_version_bump_do
      return 0
   fi

   #if [ "${batch_current_package_reponame}" = "kloak" ]; then
   #   continue_yes=true
   #fi
   #if [ ! "$continue_yes" = "true" ]; then
   #   return 0
   #fi

   ## sets: batch_current_package_needs_version_bump
   pkg_need_version_bump_show

   if [ ! "${batch_current_package_needs_version_bump}" = 'y' ]; then
      return 0
   fi

   true "INFO: ${FUNCNAME[0]}: needs version bump: ${batch_current_package_reponame}"

   pkg_git_manpages
   pkg_git_debinstfile
   ## TODO
   #pkg_readme_creator_do
   #pkg_readme_creator_commit

   ## sets: needs_version_bump
   pkg_need_version_bump_do

   if [ ! "${batch_current_package_needs_version_bump}" = 'y' ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR: batch_current_package_needs_version_bump before true but now not!?${reset}"
      exit 1
   fi

   ## At this stage:
   ## - the commit prior the changelog bump is verified
   ## - the changelog bump commit is signed and verified
   ## - the new git tag is signed and verified
   ## - the new git tag has already been pushed
   ## - the git branch has already been pushed

   export make_lintian=true
   export make_use_cowbuilder=true
   dry_run_or_run genmkfile deb-pkg
   dry_run_or_run genmkfile reprepro-remove
   dry_run_or_run genmkfile reprepro-add
   dry_run_or_run genmkfile deb-cleanup

   return 0
}

## Builds all packages.
pkg_only_build_all() {
   repo_skip_non_package || return 0

   export make_lintian=true
   export make_use_cowbuilder=true
   dry_run_or_run genmkfile deb-pkg

   return 0
}

## Adds all packages to a reprepro repository.
pkg_only_reprepro_add() {
   repo_skip_non_package || return 0

   export make_use_cowbuilder=true
   dry_run_or_run genmkfile reprepro-add

   return 0
}

## Creates a lintian-overrides file for all repositories, ignoring
## debian-watch-does-not-check-openpgp-signature warnings.
pkg_add_lintian_watch_gpg_override() {
   repo_skip_non_package || return 0

   mkdir --parents -- './debian/source'
   if [ -f './debian/source/lintian-overrides' ] && ! grep -e 'debian-watch-does-not-check-openpgp-signature' -- './debian/source/lintian-overrides' ; then
      printf '%s\n' "\
## https://forums.whonix.org/t/genmkfile-lintian-debian-watch-may-check-gpg-signature-build-issue/19124
debian-watch-does-not-check-openpgp-signature" >> "./debian/source/lintian-overrides"
   fi
}

## Commits the lintian-overrides file generated by
## pkg_add_lintian_watch_gpg_override.
pkg_git_commit_lintian_watch_gpg_override() {
   local msg

   repo_skip_non_package || return 0

   dry_run_or_run git add -- './debian/source/lintian-overrides' || true
   msg="\
added debian/source/lintian-overrides with debian-watch-does-not-check-openpgp-signature to fix lintian warning
https://forums.whonix.org/t/genmkfile-lintian-debian-watch-may-check-gpg-signature-build-issue/19124"
   dry_run_or_run git commit -m "${msg}" || true
   return 0
}

## Creates debian/watch files for all repos.
debian_watch_file_create() {
   local msg

   dry_run_or_run overwrite "${batch_current_package_path}/debian/watch" "\
## Copyright (C) 2012 - 2025 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

version=4
opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/${batch_current_package_reponame}-\$1\.tar\.gz/ \\
  https://github.com/${batch_current_project_name}/${batch_current_package_reponame}/tags .*/v?(\d\S+)\.tar\.gz" >/dev/null

   dry_run_or_run git add -- "${batch_current_package_path}/debian/watch" || true
   msg="\
fix debian/watch lintian warning debian-watch-contains-dh_make-template"
   dry_run_or_run git commit -m "${msg}" || true
   return 0
}

## . END Commands }

## BEGIN Post-run hooks {

wait_for_git_processes() {
   if ! parallel_wait git_pid_label ; then
      ## parallel_wait now log-error's each failure individually
      ## with pid+exit (helper-scripts parallel.bsh). Consolidate
      ## the labels into one trailing summary line for grep /
      ## at-a-glance after many parallel log lines have scrolled.
      log error "${#parallel_failures[@]} git background job(s) failed: ${parallel_failures[*]}"
      exit 1
   fi
}

generate_announcement() {
   local changelog_file derivative_name project_name project_website temp \
      derivative_version_new_main_short release_type_long end_text \
      header_text

   ## TODO: Should this dynamically change depending on the project? Also,
   ## should it be inserted into the announcement automatically?
   changelog_file="${announcements_drafts_dir}/kicksecure_giant_git_log.txt"

   for derivative_name in "${derivative_name_list[@]}"; do
      if [ "${derivative_name}" = 'kicksecure' ]; then
         project_name='Kicksecure'
         project_website='kicksecure.com'
      elif [ "${derivative_name}" = 'whonix' ]; then
         project_name='Whonix'
         project_website='whonix.org'
      fi

      temp="${derivative_version_new_main}"
      temp="$(str_replace "-developers-only" "" <<< "${temp}")"
      temp="$(str_replace "-testers-only" "" <<< "${temp}")"
      temp="$(str_replace "-stable-only" "" <<< "${temp}")"

      derivative_version_new_main_short="$temp"

      if [ "${derivative_release_type}" = 'testers' ]; then
         release_type_long='Testers Wanted'
         header_text="# Testers Wanted!

Download the Testers-Only version of ${project_name}:

https://www.${project_website}/wiki/VirtualBox_Testers_Only_Version"
         end_text="(This testers wanted announcement might in future be [transformed](https://forums.whonix.org/t/transform-whonix-testers-wanted-forum-news-post-into-whonix-release-forum-news-post-ok/11405) into a stable release announcement if no major issues are found during the testing period.)"
      elif [ "${derivative_release_type}" = 'point' ]; then
         release_type_long="Point Release"
         header_text="# Download

https://www.${project_website}/wiki/Download

([What is a point release?](https://www.kicksecure.com/wiki/Point_Release))"
         end_text="(This forum post was previously a call for testers. No release critical bugs where found during the testing period. This forum post was therefore [transformed](https://forums.whonix.org/t/transform-whonix-testers-wanted-forum-news-post-into-whonix-release-forum-news-post-ok/11405) into a stable release announcement. See edit history.)"
      else
         error "invalid derivative_release_type!"
      fi

      dry_run_or_run overwrite "${announcements_drafts_dir}/${project_name}.txt" "\
${project_name} ${derivative_version_new_main_short} - ${release_type_long}!

${header_text}

----

# Upgrade

Alternatively, an in-place release upgrade is possible using the [${project_name} repository](https://www.${project_website}/wiki/Project-APT-Repository).

----

This release would not have been possible without the numerous supporters of ${project_name}!

----

Please Donate!

https://www.${project_website}/wiki/Donate

----

Please Contribute!

https://www.${project_website}/wiki/Contribute

----

# Major Changes

TODO

${changelog_file}

----

# Full difference of all changes

[https://github.com/${project_name}/derivative-maker/compare/${derivative_version_old_main}...${derivative_version_new_main}](https://github.com/${project_name}/derivative-maker/compare/${derivative_version_old_main}...${derivative_version_new_main})

----

${end_text}
" >/dev/null
   done
}

## . END Post-run hooks }

## BEGIN Main logic {

## The caller pushd's into the target repo and runs
## set_batch_context_from_pwd before invoking.
run_batch_command() {
   local batch_command
   batch_command="${1:-}"
   shift || true

   [ -z "${batch_command}" ] && {
      printf '%s\n' "${red}${bold}FATAL ERROR: No command provided to ${FUNCNAME[0]}!${reset}"
      exit 1
   }

   (( batch_run_counter++ )) || true
   true "${FUNCNAME[0]}: batch_current_package_path: ${batch_current_package_path} | batch_run_counter: ${batch_run_counter}"
   true "${cyan}INFO: ${FUNCNAME[0]}: batch_current_project_name: ${batch_current_project_name}${reset}"
   true "${cyan}INFO: ${FUNCNAME[0]}: batch_current_package_reponame: ${bold}${batch_current_package_reponame}${nobold}${reset}"
   true "${cyan}INFO: ${FUNCNAME[0]}: batch_current_package_path: ${batch_current_package_path}${reset}"
   true "INFO: ${FUNCNAME[0]}: batch_current_package_changelog: ${batch_current_package_changelog}"

   set_batch_current_package_remote_list

   "${batch_command}" "$@"
}

run_batch() {
   local repo_path post_run_hook

   ## Regular packages.
   for repo_path in "${derivative_maker_source_code_dir}/packages"/*/*; do
      [ -d "${repo_path}" ] || continue
      pushd -- "${repo_path}" >/dev/null
      set_batch_context_from_pwd
      run_batch_command "$@"
      popd >/dev/null
   done

   ## Top-level repos outside packages/ (matches repo_is_special plus
   ## the derivative-maker meta-repo). Order is significant: meta-repo
   ## last so per-package writes (e.g. pkg_git_submodule_file_writer)
   ## have run first.
   for repo_path in \
      "${derivative_maker_source_code_dir}/live-build" \
      "${derivative_maker_source_code_dir}/grml-debootstrap" \
      "${derivative_maker_source_code_dir}/grml-debootstraptest" \
      "${derivative_maker_source_code_dir}/qubes/qubes-template-kicksecure" \
      "${derivative_maker_source_code_dir}/qubes/qubes-template-whonix" \
      "${derivative_maker_source_code_dir}/windows/Whonix-Installer" \
      "${derivative_maker_source_code_dir}/windows/Whonix-Starter" \
      "${derivative_maker_source_code_dir}"
   do
      [ -d "${repo_path}" ] || continue
      pushd -- "${repo_path}" >/dev/null
      set_batch_context_from_pwd
      run_batch_command "$@"
      popd >/dev/null
   done

   for post_run_hook in "${post_run_hook_list[@]}"; do
      "${post_run_hook}"
   done
}

## Pure command-line parser: initializes and writes the parsed_*
## globals listed below. Kept separate from main() so the flag-shape
## knowledge lives in one place.
parse_args() {
   parsed_mode=''
   parsed_repo=''
   parsed_target=''
   parsed_args=()
   parsed_dry_run='n'

   while [ $# -gt 0 ]; do
      case "${1}" in
         --dry-run)
            parsed_dry_run='y'
            shift
            ;;
         --batch|--no-batch)
            [ -n "${parsed_mode}" ] && {
               printf '%s\n' "${red}${bold}FATAL ERROR:${reset} --batch and --no-batch are mutually exclusive"
               exit 1
            }
            parsed_mode="${1#--}"
            shift
            ;;
         --repo)
            if [ -z "${2:-}" ]; then
               printf '%s\n' "${red}${bold}FATAL ERROR:${reset} --repo requires an argument"
               exit 1
            fi
            parsed_repo="${2}"
            shift 2
            ;;
         --*)
            printf '%s\n' "${red}${bold}FATAL ERROR:${reset} unknown flag '${1}'"
            exit 1
            ;;
         *)
            break
            ;;
      esac
   done

   if [ "$#" -gt 0 ]; then
      parsed_target="${1}"
      shift
   fi
   parsed_args=( "$@" )

   ## --repo implies --no-batch (and rejects --batch downstream).
   if [ -n "${parsed_repo}" ] && [ -z "${parsed_mode}" ]; then
      parsed_mode='no-batch'
   fi
}

main() {
   local post_run_hook repo_path

   parse_args "$@"
   batch_meta_dry_run="${parsed_dry_run}"
   if [ "${batch_meta_dry_run}" = "y" ]; then
      ## function 'log_run' honors variable '$dry_run'
      dry_run=1
      export dry_run
   fi

   if [ -z "${parsed_mode}" ] && [ -n "${parsed_target}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR:${reset} one of --batch or --no-batch is required"
      exit 1
   fi

   if [ -n "${parsed_repo}" ] && [ "${parsed_mode}" = 'batch' ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR:${reset} --repo and --batch are mutually exclusive"
      exit 1
   fi

   if [ -z "${parsed_target}" ] && [ -n "${parsed_repo}" ]; then
      printf '%s\n' "${red}${bold}FATAL ERROR:${reset} --repo ${parsed_repo}: missing function name. Syntax: ./$0 [--dry-run] --repo NAME function-name [arguments]"
      exit 1
   fi

   if [ "${parsed_target}" = '' ]; then
      true "INFO: Available functions..."
      typeset -f | awk '/ \(\) $/ && !/^main / {print $1}' | grep -E "^pkg_*"
      true "INFO: syntax: ./$0 [--dry-run] (--batch|--no-batch [--repo NAME]) function-name arguments"
      exit 0
   fi

   if [ -n "${parsed_repo}" ]; then
      repo_path="$(resolve_repo_path "${parsed_repo}")" || {
         printf '%s\n' "${red}${bold}FATAL ERROR:${reset} --repo: unknown repo '${parsed_repo}' (searched derivative-maker root, qubes/, windows/, packages/kicksecure/, packages/whonix/)"
         exit 1
      }
      cd -- "${repo_path}"
   fi

   if [ "${parsed_mode}" = 'no-batch' ]; then
      set_batch_context_from_pwd
      if ! pkg_git_target_is_branch_switch "${parsed_target}"; then
         run_batch_command pkg_git_check_current_branch
      fi
      run_batch_command "${parsed_target}" "${parsed_args[@]}"
      for post_run_hook in "${post_run_hook_list[@]}"; do
         "${post_run_hook}"
      done
   elif [ "${parsed_mode}" = 'batch' ]; then
      if ! pkg_git_target_is_branch_switch "${parsed_target}"; then
         run_batch pkg_git_check_current_branch
      fi
      case "${parsed_target}" in
         'commit_filter')
            [ -z "${parsed_args[0]:-}" ] && {
               printf '%s\n' "${red}${bold}FATAL ERROR: commit_filter requires an argument${reset}"
               exit 1
            }
            commit_filter "${parsed_args[@]}"
            ;;
         'pkg_descr_merger')
            pkg_descr_merger
            ;;
         'pkg_descr_merge_all')
            pkg_descr_merge_all
            ;;
         *)
            run_batch "${parsed_target}"
            ;;
      esac
   else
      printf '%s\n' "${red}${bold}FATAL ERROR:${reset} ${FUNCNAME[0]}: unexpected parsed_mode='${parsed_mode}'"
      exit 1
   fi
}

## . END Main logic }

## Global tunables, adjust these as necessary
derivative_binary_dir="$HOME/derivative-binary"
export make_git_tag_push_cache_dir="${derivative_binary_dir}/git-tag-push-cache"

derivative_name_list=( 'kicksecure' 'whonix' )
derivative_version_old_main='18.2.1.0-developers-only'
derivative_version_new_main='18.2.1.7-developers-only'
derivative_release_type='point'
#derivative_release_type='testers'
makefile_generic_version='1.5'
packaging_files_diff_template_package_relative_path='kicksecure/anon-apt-sources-list'

## Global static variables, do not adjust
announcements_drafts_dir="${derivative_binary_dir}/announcements-drafts"
derivative_maker_dir_name="$(basename -- "${derivative_maker_source_code_dir}")"
make_cowbuilder_dist_dir="${derivative_maker_source_code_dir}/genmkfile-packages-result"
package_documentation_dir="${derivative_binary_dir}/package_documentation"

## Global state variables, do not adjust
## FIXME: All of these 'y' and 'n' booleans should be replaced with
## standard 'true' and 'false' string booleans.
batch_current_package_changelog=''
batch_current_package_needs_version_bump='n'
batch_current_package_path=''
batch_current_package_reponame=''
batch_current_project_name=''
batch_current_project_website=''
batch_current_package_remote_list=()
batch_func_init_done='n'
batch_meta_category_list=()
batch_meta_debian_control_web_link=''
batch_meta_file_name_without_reponame=''
batch_meta_file_web_link=''
batch_meta_gateway_only='n'
batch_meta_installed_by_default='y' # yes, this defaults to 'y'
batch_meta_non_qubes_whonix_only='n'
batch_meta_project_list=()
batch_meta_qubes_whonix_only='n'
batch_meta_relative_file_name=''
batch_meta_repo_web_link=''
batch_meta_workstation_only='n'
batch_meta_force_version_bump='n'
batch_meta_dry_run='n'

declare -A -g batch_meta_file_header_done_list=()
batch_run_counter=0
declare -A -g git_pid_label=()
declare -A -g merge_file_reset_list=()
post_run_hook_list=( 'wait_for_git_processes' )

## Initialization and launch
check_prerequisites
prepare_system
show_debug_variable_info
main "$@"

true "END: $0"
