#!/usr/bin/env bash# Publish directories to origin/master: pushed directly by the owner, otherwise as a pull request.set-e# Exit on errordeclaregh_user_emailgh_user_namerepo_owner_emailrepo_owner_nameinit_gh_git_identity(){# init_gh_git_identity — Populate GitHub/repo identity globals and sync the# local git config identity to the authenticated user.## Globals set (module-level, declared externally):# gh_user_name Login of the authenticated GitHub user# gh_user_email Email of the authenticated GitHub user# repo_owner_name Login of the current repo's owner# repo_owner_email Email of the current repo's owner## Side effects (all local to the current git repo):# git config user.name — set to match the authenticated GitHub user# git config user.email — set to match the authenticated GitHub user## Returns:# 0 All phases completed successfully# 1 gh CLI not authenticated; or one or more API fields could not be# retrieved; or a git config write failed## Phases (each is skipped if a prior phase fails):# 1. Fetch gh_user_name, gh_user_email, repo_owner_name, repo_owner_email# via the GitHub API# 2. Sync local git identity (user.name, user.email) to the authenticated user## Credential helper note:# git auth is delegated to the gh CLI via a global, per-host credential# helper configured once by `gh auth setup-git` (install-credentials# Makefile target) — not by this function.## Notes:# - Requires `gh` CLI authenticated via `gh auth login`# - Must be run inside a git repository# - GitHub may return a null email if the user has hidden it in their profile,# which will cause phase 1 to faillocalrc=0if!ghauthstatus>/dev/null2>&1;thenecho"GitHub CLI is not authenticated. Please run: gh auth login">&2return1# no point in continuing, exit earlyfigh_user_name=$(ghapiuser--jq'.login')if[[-z"${gh_user_name}"]];thenecho"Error: could not get GitHub authenticated username">&2rc=1figh_user_email=$(ghapiuser--jq'.email')if[[-z"${gh_user_email}"]];thenecho"Error: could not get GitHub authenticated user's email">&2rc=1firepo_owner_name=$(ghrepoview--jsonowner--jq'.owner.login')if[[-z"${repo_owner_name}"]];thenecho"Error: could not get repository owner">&2rc=1firepo_owner_email=$(ghapi"users/${repo_owner_name}"--jq'.email')if[[-z"${repo_owner_email}"]];thenecho"Error: could not get owner email">&2rc=1fi[[${rc}==0]]||return${rc}# must not continue to git configlocalgit_user_emailgit_user_namegit_user_email=$(gitconfig--local--getuser.email2>/dev/null||echo"")if[["${git_user_email}"!="${gh_user_email}"]];thenif!gitconfig--localuser.email"${gh_user_email}";thenecho"Error: could not set git user email">&2rc=1fifigit_user_name=$(gitconfig--local--getuser.name2>/dev/null||echo"")if[["${git_user_name}"!="${gh_user_name}"]];thenif!gitconfig--localuser.name"${gh_user_name}";thenecho"Error: could not set git user name">&2rc=1fifi# The git credential helper is configured globally via `gh auth setup-git`# (see the install-credentials Makefile target). Do not write a local# credential.helper here — it duplicates the global, per-host setup and# risks local/global drift.return${rc}}check_not_gitignored(){# check_not_gitignored — Verify that none of the input paths are excluded by .gitignore.## Usage: check_not_gitignored <path> [path ...]## Returns:# 0 No input paths are gitignored# 1 One or more input paths are gitignored (offending paths printed to stderr)localignoredignored=$(printf'%s\0'"$@"|gitcheck-ignore-z--stdin2>/dev/null|tr'\0''\n')||trueif[[-n"${ignored}"]];thenecho"Error: the following paths are excluded by .gitignore:">&2printf' %s\n'"${ignored}">&2return1fi}targets_are_valid(){# targets_are_valid — Verify that each target is an existing directory.## Usage: targets_are_valid <dir> [dir ...]## Returns:# 0 All targets are existing directories# 1 One or more targets are missing or not directories (offending paths printed to stderr)localrc=0targetfortargetin"${targets[@]}";doif[[!-d"${target}"]];thenecho"Error: target is not an existing directory: ${target}">&2rc=1fidonereturn${rc}}ensure_targets_are_tracked(){# ensure_targets_are_tracked — Update the local git index to reflect the current# state of all target directories (new, modified, deleted).## Usage: ensure_targets_are_tracked <dir> [dir ...]## Returns:# 0 Index updated successfully# 1 git add failedgitadd-A--"${targets[@]}"}publish(){# publish — Publish target directories to origin.## Usage: publish# Requires targets, gh_user_name, gh_user_email, repo_owner_email to be set# (populated by init_gh_git_identity).## Behaviour:# - Fetches origin/master and resets HEAD to it (soft, preserving working tree)# - Stages all target directories and creates a commit# - If gh_user_email == repo_owner_email: pushes HEAD to origin/master directly# (HEAD, not the local `master` branch — see the push itself)# - Otherwise: pushes to a named branch and opens a pull request# - If dry_run=1: skips push and PR; prints the commands that would run instead## Returns:# 0 Published (or PR created, or dry-run) successfully# 1 Commit, push, or PR creation failedgitfetchoriginmastergitreset--softorigin/mastergitadd-A--"${targets[@]}"ifgitdiff--cached--quiet;thenecho"Nothing to publish: no changes detected in targets">&2return0filocaltargets_listcommit_msgprintf-vtargets_list'%s, '"${targets[@]}"commit_msg="Publish ${targets_list%, }; updated by ${gh_user_name} on $(date+%y%m%d)"gitcommit-m"${commit_msg}"echo"Created commit: ${commit_msg}"if[["${gh_user_email}"=="${repo_owner_email}"]];then# HEAD:master, never `master`. `git push origin master` pushes the local BRANCH of# that name — but the commit above was made on whatever branch is checked out, and# in a per-user clone that is `user/<slug>`, whose local `master` ref has sat frozen# since the clone was created. So the new commit was never sent, and git was asked# to rewind a stale master instead: "[rejected] (non-fast-forward) … a pushed branch# tip is behind its remote counterpart". It only ever worked from the operator's own# checkout, where HEAD *is* master. The pull-request path below always got this# right (`HEAD:${branch_name}`); this one was the odd one out.eval_with_dry_rungitpushoriginHEAD:masterelselocalbranch_namebranch_name="${gh_user_name}_$(date+%y%m%d)"eval_with_dry_rungitpush-forigin"HEAD:${branch_name}"if!eval_with_dry_runghprcreate\--head"${branch_name}"\--basemaster\--title"Publish files updated by ${gh_user_name}"\--body"This PR publishes updated files from ${gh_user_name}.";thenecho"Error: could not create pull request">&2elseecho"After the PR is merged, run: solver sync"fifi}declaredry_run=0eval_with_dry_run(){# eval_with_dry_run — Execute a command, or print it if dry_run=1.## Usage: eval_with_dry_run <command> [arg ...]# Pass the command and its arguments as separate words, exactly as you# would invoke the command directly. In dry-run mode the expanded words# are printed to stdout with a '[dry-run]' prefix instead of being executed.## Returns:# In normal mode: the exit code of the evaluated command# In dry-run mode: 0 alwayslocalrcif[["${dry_run}"-eq0]];then"$@"rc=$?echo"$* -> ${rc}"return${rc}elseecho"[dry-run] $*"fi}usage(){cat>&2<<EOFUsage: $(basename "$0") [--dry-run] <dir> [dir ...] $(basename "$0") -h | --helpPublish local directories to origin/master.Arguments: dir Directory to publish. Must exist on disk.Options: --dry-run Run all steps except the final push and pull request. Prints the git push and gh pr create commands that would be executed. -h, --help Show this help message and exit.Behaviour: - Verifies no input paths are gitignored - Verifies each target is an existing directory - Syncs local git identity and credential helper to the authenticated gh user - Updates the local git index to track any new or deleted files in the targets - Fetches origin/master and resets HEAD to it (soft, preserving working tree) - Stages target directories and commits (message includes target list and yymmdd date) - If the authenticated user is the repo owner: pushes HEAD to origin/master directly - Otherwise: pushes to a named branch and opens a pull requestEOF}declare-atargetsmain(){set-e[[${#targets[@]}-eq0]]&&{echo"Error: no targets specified">&2;usage;exit1;}mapfile-ttargets<<(printf'%s\n'"${targets[@]}"|sort-u)check_not_gitignored"${targets[@]}"targets_are_validinit_gh_git_identityensure_targets_are_trackedpublish}if[["${BASH_SOURCE[0]}"=="${0}"]];then[[" $* "==*" -h "*||" $* "==*" --help "*]]&&{usage;exit0;}[[" $* "==*" --dry-run "*]]&&dry_run=1for_argin"$@";do[["${_arg}"!=-*]]&&targets+=("${_arg}");donemainfi