#!/bin/bash

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

## AI-Assisted

## For each clone under <source-dir>, push HEAD (and optionally tags)
## to a same-named repo in <target-owner>. Run `github-org-push --help`
## for full usage.

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

# shellcheck source=../libexec/developer-meta-files/github-org-lib.bsh
source /usr/libexec/developer-meta-files/github-org-lib.bsh

# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/strings.bsh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/strings.bsh

branches='HEAD'
push_tags=0
remote_name=''
include_re=''
exclude_re=''
url_scheme='ssh'
jobs=4
# G-030: --apply or --dry-run required.
dry_run=0
mode_set=0
verbose=0

show_help() {
  cat <<'EOF'
For each git checkout under <source-dir>, push HEAD (and optionally
tags) to a same-named repo in <target-owner> on GitHub. Useful to keep
a mirror in sync with a cloned upstream.

Repos in the current working directory will be pushed if no source dir
is specified. Subdirs that aren't git checkouts are ignored.

Usage:
  github-org-push --apply   [OPTIONS] <target-owner> [<source-dir>]
  github-org-push --dry-run [OPTIONS] <target-owner> [<source-dir>]

Options:
  --branches "a b c"   branches to push per repo (default: HEAD;
                       HEAD = whichever branch is checked out)
  --tags               also push tags (default: off)
  --remote-name NAME   name of the git remote pointing at the
                       target (default: target_owner value)
  --include REGEX      only push repos whose name matches REGEX
  --exclude REGEX      skip pushing repos whose name matches REGEX
  --ssh                use git@github.com:... URLs (default)
  --https              use https://github.com/... URLs
  --jobs N             parallel pushes (default: 4)
  --apply              perform pushes
  --dry-run            report planned actions, do nothing
  -v, --verbose        print each git command
  -h, --help           show this help and exit

Refusal modes:
  - Target repo does not exist: warn and skip (run github-org-fork
    first to create it). There is intentionally no --create-missing
    feature; github-org-fork is the supported way to fork repos in an
    automated fashion.
  - Lookup itself fails for a non-404 reason (auth/rate-limit/network):
    refuse to push to that repo and report; continue with other repos.

Auth: ${GITHUB_TOKEN} env var, or ~/.config/github-token with
permissions 0600. The token must have 'repo' scope to push.
If pushing over SSH, the local system is also expected to have a
suitable SSH key present and configured in GitHub.
EOF
}

while [ "$#" -gt 0 ]; do
  case "$1" in
    --branches)
      [ "$#" -ge 2 ] || die 64 "missing value for --branches"
      branches="$2"
      shift 2
      ;;
    --tags)
      push_tags=1
      shift
      ;;
    --remote-name)
      [ "$#" -ge 2 ] || die 64 "missing value for --remote-name"
      remote_name="$2"
      shift 2
      ;;
    ## --include / --exclude can each be passed multiple times. All
    ## includes are stacked to produce a whitelist, then all excludes
    ## are stacked to blacklist previously whitelisted values.
    --include)
      [ "$#" -ge 2 ] || die 64 "missing value for --include"
      if [ -z "$2" ]; then
        shift 2
        continue
      fi
      if [ -z "${include_re}" ]; then
        include_re="(${2})"
      else
        include_re+="|(${2})"
      fi
      shift 2
      ;;
    --exclude)
      [ "$#" -ge 2 ] || die 64 "missing value for --exclude"
      if [ -z "$2" ]; then
        shift 2
        continue
      fi
      if [ -z "${exclude_re}" ]; then
        exclude_re="(${2})"
      else
        exclude_re+="|(${2})"
      fi
      shift 2
      ;;
    --ssh)
      url_scheme='ssh'
      shift
      ;;
    --https)
      url_scheme='https'
      shift
      ;;
    --jobs)
      [ "$#" -ge 2 ] || die 64 "missing value for --jobs"
      jobs="$2"
      shift 2
      ;;
    --apply)
      [ "${mode_set}" -eq 0 ] || die 64 'conflicting mode flags; specify exactly one of --apply / --dry-run'
      mode_set=1
      shift
      ;;
    --dry-run)
      [ "${mode_set}" -eq 0 ] || die 64 'conflicting mode flags; specify exactly one of --apply / --dry-run'
      dry_run=1
      mode_set=1
      shift
      ;;
    -v|--verbose)
      verbose=1
      shift
      ;;
    -h|--help)
      show_help
      exit 0
      ;;
    --)
      shift
      break
      ;;
    -*)
      die 64 "unknown option: '$1'"
      ;;
    *)
      break
      ;;
  esac
done

if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
  show_help >&2
  exit 64
fi

target_owner="$1"
source_dir="${2:-.}"

[ -z "${remote_name}" ] && remote_name="${target_owner}"

[ "${mode_set}" -eq 1 ] \
   || { show_help >&2; die 64 'specify exactly one of --apply / --dry-run'; }

ghorg_require_deps
ghorg_validate_name "${target_owner}" user
ghorg_validate_name "${remote_name}" user
is_whole_number "${jobs}" \
  && [ "${jobs}" -ge 1 ] \
  && [ "${jobs}" -le 200 ] \
  || die 64 "invalid --jobs value: '${jobs}' (expected integer 1..200)"

remote_url() {
  local repo

  repo="$1"

  case "${url_scheme}" in
    ssh)   printf '%s' "git@github.com:${target_owner}/${repo}.git" ;;
    https) printf '%s' "https://github.com/${target_owner}/${repo}.git" ;;
  esac
}

push_one() {
  local repo_dir repo url branch resolved_head repo_lookup_ret

  repo_dir="$1"
  repo="$(basename -- "${repo_dir}")"

  ## Validate the directory-derived name before using it as a URL or
  ## API parameter. Same allowlist rules as for API-supplied names so
  ## a hostile filesystem can't smuggle bad names either.
  if ! ghorg_validate_name "${repo}" repo; then
    log warn "skipping '${repo_dir}' (invalid name)"
    return 0
  fi

  ## Distinguish "missing" (404) from "lookup failed for some other
  ## reason". A skip is only safe when the repo definitely isn't
  ## there; otherwise a bad token or transient outage would silently
  ## no-op an entire run.
  repo_lookup_ret=0
  ghorg_repo_lookup "${target_owner}" "${repo}" || repo_lookup_ret="$?"
  case "${repo_lookup_ret}" in
    0) ;;
    1)
      log warn "'${target_owner}/${repo}' does not exist on target, skipping"
      return 0
      ;;
    *)
      log error "'${target_owner}/${repo}' lookup failed (auth or transient); not pushing"
      return 1
      ;;
  esac

  url="$(remote_url "${repo}")"

  if [ "${dry_run}" = '1' ]; then
    log notice "DRY-RUN: push ${repo_dir} -> ${url} (branches: ${branches}, tags: ${push_tags})"
    return 0
  fi

  ## Add or refresh the named remote unconditionally so URL drift is
  ## corrected on every run.
  if git -C "${repo_dir}" remote get-url "${remote_name}" >/dev/null 2>&1; then
    git -C "${repo_dir}" remote set-url "${remote_name}" "${url}"
  else
    git -C "${repo_dir}" remote add "${remote_name}" "${url}"
  fi

  for branch in ${branches}; do
    if [ "${branch}" = 'HEAD' ]; then
      ## Resolve HEAD to a fully qualified ref locally to detect
      ## detached HEAD. Repos with a detached HEAD are skipped with a
      ## warning.
      if ! resolved_head="$(git -C "${repo_dir}" symbolic-ref HEAD 2>/dev/null)"; then
        log warn "'${repo_dir}': HEAD detached, skipping HEAD push"
        continue
      fi
      [ "${verbose}" = '1' ] \
        && log notice "push ${resolved_head} ${repo_dir} -> ${remote_name}"
      git -C "${repo_dir}" push -- "${remote_name}" \
        "${resolved_head}:${resolved_head}" \
        || log warn "'${repo_dir}': '${resolved_head}' push to '${remote_name}' failed"
    else
      if ! ghorg_validate_name "${branch}" ref; then
        log warn "'${repo_dir}': skipping invalid branch name"
        continue
      fi
      ## Confirm the branch exists locally before constructing the
      ## refspec; otherwise 'git push' would emit a confusing 'src
      ## refspec ... does not match any' that is hard to distinguish
      ## from a network failure in the per-repo warn line.
      if ! git -C "${repo_dir}" rev-parse --verify --quiet \
        "refs/heads/${branch}^{commit}" >/dev/null; then
        log warn "'${repo_dir}': branch '${branch}' does not exist locally, skipping"
        continue
      fi
      [ "${verbose}" = '1' ] \
        && log notice "push ${branch} ${repo_dir} -> ${remote_name}"
      git -C "${repo_dir}" push -- "${remote_name}" \
        "refs/heads/${branch}:refs/heads/${branch}" \
        || log warn "'${repo_dir}': branch '${branch}' push to '${remote_name}' failed"
    fi
  done

  if [ "${push_tags}" = '1' ]; then
    [ "${verbose}" = '1' ] \
      && log notice "push --tags ${repo_dir} -> ${remote_name}"
    ## XXX: bulk 'push --tags' intentional for mass-mirror-to-fork; not migrated to genmkfile git-tag-push.
    git -C "${repo_dir}" push --tags -- "${remote_name}" \
      || log warn "'${repo_dir}': tag push to '${remote_name}' failed"
  fi
}

main() {
  local candidate_count active repo_dir base_name candidate_dir
  local -a candidate_dirs

  [ -d "${source_dir}" ] || die 1 "source dir does not exist: '${source_dir}'"

  ## NUL-delimited so directory names containing newlines or other
  ## odd bytes survive intact (find -print0 + read -d '').
  candidate_dirs=()
  while IFS= read -r -d '' candidate_dir; do
    if [ ! -e "${candidate_dir}/.git" ]; then
      [ "${verbose}" = '1' ] \
        && log info "skipping '${candidate_dir}': not a git checkout"
      continue
    fi
    candidate_dirs+=( "${candidate_dir}" )
  done < <(find "${source_dir}" -mindepth 1 -maxdepth 1 -type d -print0 \
    | sort --zero-terminated --unique)

  if [ -n "${include_re}" ] || [ -n "${exclude_re}" ]; then
    local kept_names cand_basename
    local names_in=''
    for candidate_dir in "${candidate_dirs[@]}"; do
      cand_basename="$(basename -- "${candidate_dir}")"
      names_in+="${cand_basename}"$'\n'
    done
    kept_names="$(printf '%s' "${names_in}" \
      | ghorg_filter_names "${include_re}" "${exclude_re}")"
    candidate_dirs=()
    while IFS= read -r base_name; do
      [ -z "${base_name}" ] && continue
      candidate_dirs+=( "${source_dir}/${base_name}" )
    done <<< "${kept_names}"
  fi

  candidate_count="${#candidate_dirs[@]}"
  if [ "${candidate_count}" -eq 0 ]; then
    log notice "no git checkouts found under '${source_dir}'"
    return 0
  fi

  log notice "'${candidate_count}' repos to process"

  active=0
  for repo_dir in "${candidate_dirs[@]}"; do
    [ -z "${repo_dir}" ] && continue
    push_one "${repo_dir}" &
    active=$(( active + 1 ))
    if [ "${active}" -ge "${jobs}" ]; then
      wait -n || true
      active=$(( active - 1 ))
    fi
  done
  wait
}

main
