123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438 | #!/bin/bash
# Usage: git selfhost [--dry-run] [--yes] [-d DESCRIPTION] [--section TITLE | --no-section]
# [--tag TAG]... [--mirror NAME]... [--mirror-url URL]... [--no-mirrors]
# [--branch BRANCH] [--server SERVER] [--config FILE] [--git-host HOST]
# [--remote NAME] [NAME]
#
# Run in a working copy: puts it on SERVER, a server set up as in
# docs/openbsd.md, as the repository NAME (default: the directory's
# name), adds the server here as a remote, and pushes to it. Put it on
# your PATH as git-selfhost (make install-tools) to run it as git
# selfhost.
#
# It checks everything here first, then shows what it will do on SERVER,
# and asks. On SERVER, repo-render add does it, as root, in one doas:
# creates (or rewires) NAME.git, sets its section and tags in its git
# config, and gives it its mirrors, with a key of its own for pushing to
# them. Then it pushes BRANCH and the tags, which the hook
# renders and mirrors. You add the key to each mirror as a deploy key
# with write access, and it checks each mirror once you have (a second
# doas).
#
# - --section takes a section's name (as in the config) or its title
# - BRANCH is the default branch SERVER's or the mirrors' HEAD names, or
# with neither, init.defaultBranch (main), or the only branch here;
# it's pushed whichever is checked out
# - a mirror is used only if its repository is there already, as git
# ls-remote from here sees it; it makes none
# - a remote of that name (origin) that is a mirror is renamed after
# it; it stops at one that isn't
#
# It stops, before it changes anything, if:
# - there are no commits, or this is a shallow clone
# - SERVER's or a mirror's BRANCH has a commit BRANCH here doesn't (pull
# it first), or a tag here differs from one there
#
# Run it again after a failure, or to change the section, tags,
# description or mirrors: it picks up where it was. It asks for the
# description, section and tags unless they're given, or --yes, or it
# isn't run in a terminal. --dry-run shows what it would do.
#
# The defaults come from git config, usually your global one:
#
# selfhost.server SERVER: what ssh logs you in as, with doas
# selfhost.gitHost HOST, where to push as git (default: SERVER's
# host name)
# selfhost.config the renderer's config on SERVER, if it isn't
# where repo-render looks for it
# selfhost.mirror the mirrors a new repository gets, by NAME; one
# line each, so set them with git config --add
# selfhost.NAME.url the mirror's ssh URL, with {repo} for the name,
# e.g. git@github.com:me/{repo}.git
# selfhost.NAME.keys a link to the mirror's deploy key settings, with
# {repo}, shown when it asks you to add the key
#
# --mirror NAME and --mirror-url URL give the mirrors instead of
# selfhost.mirror, and --no-mirrors gives none (a repository on SERVER
# keeps those it has).
#
# Needs git and ssh here, and repo-render on SERVER.
# shellcheck disable=SC2029 # the commands are meant to expand here, then run there
set -euo pipefail
usage() {
sed -n '2,5s/^# //p' "$0" >&2
exit 2
}
die() { echo "git-selfhost: $*" >&2; exit 1; }
# A git config value, or the default
conf() { git config --get "selfhost.$1" || printf '%s' "${2-}"; }
dry="" yes="" description="" desc_set="" section="" section_set="" no_section=""
tags=() mirror_names=() mirror_urls=() mirrors_set="" no_mirrors=""
branch="" server="" config="" git_host="" remote=origin name=""
while (($#)); do
case $1 in
--dry-run) dry=1 ;;
--yes) yes=1 ;;
-d) description=${2?}; desc_set=1; shift ;;
--section) section=${2?}; section_set=1; shift ;;
--no-section) no_section=1; section_set=1 ;;
--tag) tags+=("${2?}"); shift ;;
--mirror) mirror_names+=("${2?}"); mirrors_set=1; shift ;;
--mirror-url) mirror_urls+=("${2?}"); mirrors_set=1; shift ;;
--no-mirrors) no_mirrors=1 ;;
--branch) branch=${2?}; shift ;;
--server) server=${2?}; shift ;;
--config) config=${2?}; shift ;;
--git-host) git_host=${2?}; shift ;;
--remote) remote=${2?}; shift ;;
-h) sed -n '2,/^# shellcheck/{/^# shellcheck/d;s/^# \{0,1\}//p}' "$0"; exit 0 ;;
-*) usage ;;
*) [[ $name ]] && usage; name=$1 ;;
esac
shift
done
[[ $section && $no_section ]] && die "--section and --no-section"
[[ $mirrors_set && $no_mirrors ]] && die "--no-mirrors and --mirror or --mirror-url"
[[ $server ]] || server=$(conf server)
[[ $server ]] || die "no server: give --server SERVER, or set it with git config --global selfhost.server SERVER"
[[ $config ]] || config=$(conf config)
[[ $git_host ]] || git_host=$(conf gitHost "${server#*@}")
interactive=""
[[ -t 0 && ! $yes ]] && interactive=1
# ---- here -------------------------------------------------------------------------
top=$(git rev-parse --show-toplevel 2>/dev/null) || die "not in a git working copy"
# In a worktree, the name is the main working copy's.
if [[ ! $name ]]; then
common=$(git rev-parse --path-format=absolute --git-common-dir)
if [[ $common == */.git ]]; then name=$(basename "${common%/.git}"); else name=$(basename "$top"); fi
fi
[[ $name =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $name != *.git ]] ||
die "$name: a name has only letters, digits and . _ - (and no .git): give one, as git selfhost NAME"
[[ $(git for-each-ref --count=1 refs/heads/) ]] || die "no commits yet: commit something first"
[[ $(git rev-parse --is-shallow-repository) == false ]] || die "this is a shallow clone, which can't be pushed: git fetch --unshallow first"
# The kind of a git URL: ssh, local, or other (which deploy keys can't use)
url_kind() {
case $1 in
ssh://* | git+ssh://* | ssh+git://*) echo ssh ;;
file://*) echo local ;;
*://*) echo other ;;
*) if [[ $1 =~ ^[^/]*: ]]; then echo ssh; else echo local; fi ;;
esac
}
# The host and the path of a git URL, scp-like or not
url_host() {
local u=$1
if [[ $u == *://* ]]; then u=${u#*://}; u=${u%%/*}; else u=${u%%:*}; fi
u=${u#*@}
printf '%s' "${u%%:*}"
}
url_path() {
local u=$1
if [[ $u == *://* ]]; then u=${u#*://}; u=${u#*/}; else u=${u#*:}; fi
u=${u%/}; u=${u%.git}
printf '%s' "${u#/}"
}
same_repo() { [[ $(url_host "$1") == "$(url_host "$2")" && $(url_path "$1") == "$(url_path "$2")" ]]; }
# A label for a mirror: github for git@github.com:..., say
host_label() {
local l
l=$(url_host "$1"); l=${l%.*}; l=${l##*.}
[[ $l =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || l=mirror
printf '%s' "$l"
}
# Runs a command, or with --dry-run prints it, quoted to paste into a shell
run() { if [[ $dry ]]; then printf '%q ' "$@"; echo; else "$@"; fi; }
# Quotes a string for sh, in single quotes if it needs them
sq() {
if [[ $1 =~ ^[A-Za-z0-9_./:=@%+,-]+$ ]]; then printf '%s' "$1"
else printf "'%s'" "${1//\'/\'\\\'\'}"
fi
}
# A command line for sh, each word quoted
sqline() { local out="" w; for w in "$@"; do out+="${out:+ }$(sq "$w")"; done; printf '%s' "$out"; }
# Asks y/N; without a terminal, or with --yes, the answer is $2 (or no)
confirm() {
if [[ ! $interactive ]]; then
echo "$1 ${2:-n}"
[[ ${2-} == y ]]
return
fi
read -rp "$1 [y/N] " answer
[[ $answer == [yY]* ]]
}
# One ssh connection to SERVER for all of it, so one login. The socket's
# path must be short.
tmp=$(mktemp -d /tmp/selfhost.XXXXXX 2>/dev/null || mktemp -d)
rssh() { ssh -o ControlMaster=auto -o ControlPath="$tmp/ssh" -o ControlPersist=60 "$@"; }
cleanup() {
ssh -o ControlPath="$tmp/ssh" -O exit "$server" >/dev/null 2>&1 || true
rm -rf "$tmp"
}
trap cleanup EXIT
# repo-render on SERVER, with the config if it was given
rr() { sqline repo-render ${config:+--config "$config"} "$@"; }
# ---- what SERVER has --------------------------------------------------------
rssh "$server" "$(rr info "$name")" >"$tmp/info" || die "can't run repo-render info on $server: is it installed, and its config where it looks, or selfhost.config?"
# The lines of info that start with KEY, without it
info() { sed -n "s/^$1 //p" "$tmp/info"; }
server_url=git@$git_host:$(info path)
exists=""
[[ $(info exists) == yes ]] && exists=1
server_head=$(info head)
server_refs=$(info ref)
server_desc=$(info description)
current=$(info current)
section_names=() titles=()
while read -r n t; do
[[ $n ]] && section_names+=("$n") titles+=("$t")
done < <(info section)
mapfile -t current_tags < <(info tag)
# ---- the mirrors -------------------------------------------------------------
# The mirrors, as parallel lists: a label, the URL, the key settings link
labels=() urls=() links=()
add_mirror() {
local u
for u in "${urls[@]}"; do
same_repo "$u" "$2" && return 0
done
labels+=("$1") urls+=("$2") links+=("$3")
}
if [[ ! $mirrors_set && ! $no_mirrors ]]; then
mapfile -t mirror_names < <(git config --get-all selfhost.mirror || true)
fi
for m in "${mirror_names[@]}"; do
[[ $m =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "mirror $m: a name has only letters, digits and . _ -"
u=$(git config --get "selfhost.$m.url") || die "mirror $m has no URL: set selfhost.$m.url"
[[ $(url_kind "$u") != other ]] || die "mirror $m's URL, $u, isn't an ssh URL, which deploy keys need: set selfhost.$m.url to one like git@HOST:me/{repo}.git"
l=$(git config --get "selfhost.$m.keys" || true)
add_mirror "$m" "${u//\{repo\}/$name}" "${l//\{repo\}/$name}"
done
for u in "${mirror_urls[@]}"; do
[[ $(url_kind "$u") != other ]] || die "mirror $u isn't an ssh URL, which deploy keys need: give one like git@HOST:me/$name.git"
add_mirror "$(host_label "$u")" "$u" ""
done
# A repository on SERVER keeps its mirrors.
while read -r u; do
[[ $u ]] && add_mirror "$(host_label "$u")" "$u" ""
done < <(info mirror)
# A remote of that name: the server already, or a mirror, which is
# renamed after it.
remote_ok="" old_url=""
if old_url=$(git remote get-url "$remote" 2>/dev/null); then
if same_repo "$old_url" "$server_url"; then
remote_ok=1
else
found=""
for u in "${urls[@]}"; do
same_repo "$old_url" "$u" && found=1
done
[[ $found ]] || die "your remote $remote is $old_url, which isn't one of the mirrors: give --remote NAME to add the server as another remote, or --mirror-url URL (its ssh URL) to mirror to it"
fi
fi
# Only mirrors that are there already are used. refs[i] is what the
# mirror has: its refs, and HEAD's branch.
refs=() keep_labels=() keep_urls=() keep_links=()
for i in "${!urls[@]}"; do
if out=$(GIT_TERMINAL_PROMPT=0 git ls-remote --symref -- "${urls[i]}" 2>"$tmp/err"); then
keep_labels+=("${labels[i]}") keep_urls+=("${urls[i]}") keep_links+=("${links[i]}") refs+=("$out")
else
sed 's/^/ /' "$tmp/err"
confirm "${labels[i]}: there's no repository at ${urls[i]}, or it can't be reached from here. Go on without mirroring to it?" y ||
die "make the repository at ${urls[i]}, or fix the URL, and run git selfhost again"
echo "${labels[i]}: not mirrored"
fi
done
labels=("${keep_labels[@]}") urls=("${keep_urls[@]}") links=("${keep_links[@]}")
# The old remote is renamed after the mirror it is.
rename=""
if [[ $old_url && ! $remote_ok ]]; then
for i in "${!urls[@]}"; do
same_repo "$old_url" "${urls[i]}" && rename=${labels[i]}
done
[[ $rename ]] || die "your remote $remote is $old_url, which can't be reached, so it can't be a mirror: give --remote NAME to add the server as another remote"
git remote get-url "$rename" >/dev/null 2>&1 && die "your remote $remote is mirror $rename, but there's a remote $rename already: remove one"
fi
# ---- the branch ----------------------------------------------------------------
# The commit a branch or tag is at, in what ls-remote or info says
ref_sha() { awk -v r="$2" '$2 == r && $1 != "ref:" { print $1 }' <<<"$1"; }
# The default branch is the one SERVER's or the mirrors' HEAD names. A
# HEAD naming a branch that isn't there (an empty repository) says
# nothing.
if [[ ! $branch ]]; then
from=""
consider() {
[[ $2 && $(ref_sha "$3" "refs/heads/$2") ]] || return 0
if [[ ! $branch ]]; then
branch=$2 from="$1's default branch"
elif [[ $2 != "$branch" ]]; then
die "the default branches differ ($from is $branch, $1's is $2): give --branch BRANCH"
fi
}
consider "$server" "$server_head" "$server_refs"
for i in "${!urls[@]}"; do
consider "${labels[i]}" "$(awk '$1 == "ref:" && $3 == "HEAD" { sub("^refs/heads/", "", $2); print $2 }' <<<"${refs[i]}")" "${refs[i]}"
done
if [[ ! $branch ]]; then
d=$(git config init.defaultBranch || echo main)
mapfile -t local_branches < <(git for-each-ref --format='%(refname:short)' refs/heads/)
if git show-ref --verify --quiet "refs/heads/$d"; then
branch=$d from="the default branch, init.defaultBranch"
elif ((${#local_branches[@]} == 1)); then
branch=${local_branches[0]} from="the only branch"
else
die "which branch is the default? Nothing says, and there's no $d here: give --branch BRANCH"
fi
fi
git show-ref --verify --quiet "refs/heads/$branch" || die "$from is $branch, and there's no $branch here: check it out, or give --branch BRANCH"
echo "default branch: $branch ($from)"
fi
[[ $branch =~ ^[A-Za-z0-9_][A-Za-z0-9._/-]*$ ]] || die "branch $branch: letters, digits and . _ - / only"
git show-ref --verify --quiet "refs/heads/$branch" || die "there's no branch $branch here"
# Nothing is pushed over a commit we don't have: the server's branch and
# each mirror's must be in ours. A tag must be the same everywhere.
check_refs() { # LABEL REFS PULL-URL
local sha t local_t
sha=$(ref_sha "$2" "refs/heads/$branch")
if [[ $sha ]] && ! git merge-base --is-ancestor "$sha" "refs/heads/$branch" 2>/dev/null; then
die "$1's $branch has commits that your $branch doesn't: pull them first (git pull $3 $branch), and run git selfhost again"
fi
while read -r sha t; do
[[ $t == refs/tags/* && $t != *'^{}' ]] || continue
local_t=$(git rev-parse -q --verify "$t" || true)
[[ ! $local_t || $local_t == "$sha" ]] ||
die "tag ${t#refs/tags/} is different here and on $1: make them the same (to take $1's: git fetch $3 +$t:$t), and run git selfhost again"
done <<<"$2"
}
if [[ $exists ]]; then
sha=$(ref_sha "$server_refs" "refs/heads/$branch")
if [[ $sha ]] && ! git merge-base --is-ancestor "$sha" "refs/heads/$branch" 2>/dev/null; then
die "$name is on $server already, with commits your $branch doesn't have. If it's this repository, pull them (git pull $server_url $branch) and run git selfhost again; if not, give this one another name: git selfhost NAME"
fi
check_refs "$server" "$server_refs" "$server_url"
echo "$name is on $server already: picking up where it was"
fi
for i in "${!urls[@]}"; do
check_refs "${labels[i]}" "${refs[i]}" "${urls[i]}"
done
# ---- description, section and tags ---------------------------------------------
[[ $desc_set ]] || description=$server_desc
if [[ $interactive && ! $desc_set ]]; then
read -rp "Description${description:+ [$description]}: " answer
[[ $answer ]] && description=$answer
fi
description=${description//[$'\n\r\t']/ }
[[ $section_set ]] || section=$current
if [[ $interactive && ! $section_set ]]; then
echo "Section:"
default=""
for i in "${!titles[@]}"; do
echo " $((i + 1))) ${titles[i]}"
[[ $current && ${section_names[i]} == "$current" ]] && default=$((i + 1))
done
echo " $((${#titles[@]} + 1))) none (private)"
[[ $exists && ! $current ]] && default=$((${#titles[@]} + 1))
while :; do
read -rp "> ${default:+[$default] }" answer
answer=${answer:-$default}
if [[ $answer =~ ^[0-9]+$ ]] && ((answer >= 1 && answer <= ${#titles[@]} + 1)); then
section=""
((answer <= ${#titles[@]})) && section=${section_names[answer - 1]}
section_set=1
break
fi
done
fi
# --section takes a section's name or its title.
if [[ $section ]]; then
found=""
for i in "${!section_names[@]}"; do
[[ $section == "${section_names[i]}" || $section == "${titles[i]}" ]] && found=${section_names[i]}
done
[[ $found ]] || die "there's no section $section on $server: its sections are ${section_names[*]:-(none)}"
section=$found
fi
if [[ $interactive && ${#tags[@]} -eq 0 ]]; then
read -rp "Tags, separated by spaces${current_tags[*]:+ [${current_tags[*]}]}: " -a tags
fi
# ---- on SERVER --------------------------------------------------------------------
add=(add "$name" --branch "$branch")
if [[ $section_set ]]; then
if [[ $section ]]; then add+=(--section "$section"); else add+=(--no-section); fi
fi
for t in "${tags[@]}"; do add+=(--tag "$t"); done
[[ $description ]] && add+=(-d "$description")
for u in "${urls[@]}"; do add+=(--mirror "$u"); done
echo
echo "On $server:"
rssh "$server" "$(rr "${add[@]}" --dry-run)" | sed 's/^would / /' || die "repo-render add refuses that"
if [[ $dry ]]; then
echo "(dry run: not run)"
elif [[ $yes ]] || confirm "Do it?"; then
rssh -t "$server" "doas $(rr "${add[@]}")" || die "that failed on $server; run git selfhost again to pick up from there"
else
echo "nothing changed on $server"
exit 1
fi
# ---- here, again ------------------------------------------------------------------
if [[ ! $remote_ok ]]; then
[[ $rename ]] && run git remote rename "$remote" "$rename"
run git remote add "$remote" "$server_url"
fi
run git push -u "$remote" "$branch" || die "the push failed; run git selfhost again to pick up from there"
run git push "$remote" --tags || die "pushing the tags failed; run git selfhost again to pick up from there"
# The mirrors need the key before the hook can push to them. Checking
# pushes to them now, as the hook does after a push.
if ((${#urls[@]})) && [[ ! $dry ]]; then
rssh "$server" "$(rr info "$name")" >"$tmp/info"
echo
echo "Add this key to each mirror, as a deploy key, with write access:"
echo
echo " $(info pubkey)"
echo
for i in "${!urls[@]}"; do
echo " ${labels[i]}: ${links[i]:-${urls[i]}}"
done
echo "(If a mirror has the key already, from an earlier run, there's nothing to do there.)"
[[ $interactive ]] && read -rp "Press Enter when they're added... "
until rssh -t "$server" "doas $(rr mirror "$name")"; do
if [[ ! $interactive ]]; then
echo "the next push retries the mirrors, and says if they fail; or run git selfhost again to check them"
break
fi
echo "Is the key added to each one that failed, with write access?"
read -rp "Press Enter to check again, or s to skip: " answer
if [[ $answer == [sS]* ]]; then
echo "the next push retries the mirrors, and says if they fail; or run git selfhost again to check them"
break
fi
done
fi
[[ $section ]] || echo "$name is in no section of the index, so it is private"
|