@@ -0,0 +1,848 @@
2+# Keep MultiGram in step with Forkgram.
4+# scripts/sync-forkgram.sh [sync] [--remote NAME [--push]] [--force | --use-next [--allow-drop]]
5+# [--url URL] [--branch NAME] [--history-depth N]
6+# scripts/sync-forkgram.sh promote --remote NAME --candidate SHA --previous SHA
7+# --version VERSION [--shallow]
10+# 1. Reads the tip of Forkgram's dev branch. If it is the commit the latest "forkgram"
11+# snapshot was made from, there is nothing to do (exit 0), unless --force.
12+# 2. Otherwise it makes a snapshot commit on "forkgram": the tip's tree without the
13+# .github folder (.gitmodules and the submodule links stay), parented on the previous
14+# snapshot, with the Forkgram-Commit, Forkgram-Version and Telegram-Base lines.
15+# 3. It replays the MultiGram stack (previous-snapshot..multigram) onto the snapshot with
16+# "git rebase --onto", in a temporary worktree, as branch "multigram-next". rerere is on,
17+# so conflicts already resolved in this clone are resolved the same way (a fresh CI clone
18+# has no recorded resolutions). On a conflict it aborts and reports the stack commit and
19+# the files (exit 2). The stack must be linear: a merge commit that carries changes of
20+# its own stops the sync, because the replay flattens merges.
21+# It never replaces a multigram-next that holds commits it did not make (a fix by hand):
22+# it then stops with status next-edited and changes nothing. refs/multigram-sync/next
23+# (pushed with multigram-next, and kept locally) records the last multigram-next it built.
24+# --remote NAME read forkgram and multigram from that remote (default: the local branches)
25+# --push push forkgram and multigram-next to NAME (one atomic push, with leases)
26+# --force no new Forkgram release: rebuild multigram-next on the current snapshot
27+# (tests the pipeline, or retries after a failed compile)
28+# --use-next take the multigram-next branch as it is (a stack fixed by hand) as the
29+# candidate; nothing is fetched from Forkgram and nothing is rebuilt. It
30+# must still carry every commit of multigram's stack (matched by author and
31+# subject, or by patch id); --allow-drop accepts one that leaves some out.
32+# The results are left in the local branches "forkgram" and "multigram-next". The
33+# checked-out worktree is never touched, a branch checked out anywhere is not moved, and
34+# a local multigram-next whose commits are on no other branch or tag is not moved either.
37+# Moves "multigram" to a candidate that passed the compile check: tags the old tip
38+# multigram-before-VERSION, moves multigram with --force-with-lease and deletes
39+# multigram-next, in one atomic push. --shallow fetches only the two tips.
41+# Environment: FORKGRAM_URL (default https://github.com/forkgram/TelegramAndroid),
42+# FORKGRAM_BRANCH (default dev), FORKGRAM_HISTORY_DEPTH (default 600: how many Forkgram
43+# commits to search for Telegram's "update to X" commit; 0 = do not search).
44+# When GITHUB_OUTPUT / GITHUB_STEP_SUMMARY are set, the outputs and a summary go there.
46+# Outputs (key=value, printed at the end and appended to $GITHUB_OUTPUT):
47+# status up-to-date | behind | next-edited | candidate | conflict | error (sync)
48+# promoted | unchanged | error (promote)
49+# message one line for people
50+# new_snapshot yes | no
51+# forkgram_commit, forkgram_version, telegram_base, snapshot
52+# the Forkgram commit, its version, its Telegram base and the forkgram
53+# snapshot the candidate is built on (new or current)
54+# previous_snapshot, previous_forkgram_commit, previous_version
55+# the forkgram tip before this run
56+# multigram the multigram tip the candidate was built from
57+# multigram_base the snapshot multigram is based on (differs from previous_snapshot
58+# when multigram is behind)
59+# stack_count MultiGram commits on multigram; candidate_count: on multigram-next
60+# candidate multigram-next's commit, empty when there is none
61+# existing_next the multigram-next this run found (next-edited: the one it left alone)
62+# tag_version the version, safe for a tag name
63+# dropped MultiGram commits the candidate leaves out ("sha subject" lines with
64+# --allow-drop, else git's lines about commits Forkgram already has)
65+# missing --use-next without --allow-drop: the commits multigram-next lacks
66+# rerere_resolved conflicts resolved from recorded resolutions ("commit path" lines)
67+# conflict_commit, conflict_subject, conflict_position,
68+# conflict_files (paths), conflict_details ("what happened: path" lines)
69+# forkgram_tip the tip of Forkgram's branch this run saw
70+# pushed the branches pushed; tag (promote): the tag made for the old multigram
71+# error what went wrong
72+# Exit codes: 0 done (see status), 1 error, 2 conflict.
75+FORKGRAM_URL=${FORKGRAM_URL:-https://github.com/forkgram/TelegramAndroid}
76+FORKGRAM_BRANCH=${FORKGRAM_BRANCH:-dev}
77+FORKGRAM_HISTORY_DEPTH=${FORKGRAM_HISTORY_DEPTH:-600}
82+TIP_REF=refs/multigram-sync/forkgram-tip
83+REC_REF=refs/multigram-sync/next # the last multigram-next this script built by replaying
84+export GIT_TERMINAL_PROMPT=0
93+# ---------------------------------------------------------------- output and logging
95+log() { printf 'sync: %s\n' "$*" >&2; }
97+annotate() { # level message
98+ if [ -n "${GITHUB_ACTIONS:-}" ]; then
100+ m=${m//'%'/'%25'}; m=${m//$'\r'/'%0D'}; m=${m//$'\n'/'%0A'}
101+ printf '::%s::%s\n' "$1" "$m"
103+ printf 'sync: %s: %s\n' "$1" "$2" >&2
107+warn() { annotate warning "$*"; }
112+ out error "$ERROR_MSG"
113+ out message "$ERROR_MSG"
117+out() { # key value...
120+ printf -v "o_$k" '%s' "$*"
121+ have=" ${OUT_KEYS[*]-} "
122+ case $have in *" $k "*) ;; *) OUT_KEYS+=("$k") ;; esac
125+get() { local n="o_$1"; printf '%s' "${!n-}"; }
127+random_hex() { od -An -N16 -tx1 /dev/urandom | tr -d ' \n'; }
129+# Prints text that may hold other people's words (commit subjects, file names) so that the
130+# Actions runner does not read it as workflow commands.
132+ if [ -n "${GITHUB_ACTIONS:-}" ]; then
135+ printf '::stop-commands::%s\n%s\n::%s::\n' "$token" "$1" "$token"
142+ [ ${#OUT_KEYS[@]} -gt 0 ] || return 0
143+ local k v delim text=
144+ delim=ghadelimiter_$(random_hex)
145+ for k in "${OUT_KEYS[@]}"; do
147+ if [ -n "${GITHUB_OUTPUT:-}" ]; then
148+ printf '%s<<%s\n%s\n%s\n' "$k" "$delim" "$v" "$delim" >>"$GITHUB_OUTPUT"
151+ *$'\n'*) text+="$k:"$'\n'"$(printf '%s\n' "$v" | sed 's/^/ /')"$'\n' ;;
152+ *) text+="$k=$v"$'\n' ;;
155+ print_untrusted "---- outputs ----"$'\n'"${text%$'\n'}"
158+md() { printf '%s' "$1" | tr '`' "'"; } # untrusted text for a Markdown code span or block
161+ [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ ${#OUT_KEYS[@]} -gt 0 ] || return 0
164+ printf '### Forkgram sync: %s\n\n' "$(get status)"
165+ printf '```text\n%s\n```\n\n' "$(md "$(get message)")"
166+ printf '| | |\n|---|---|\n'
167+ for k in forkgram_version forkgram_commit telegram_base new_snapshot snapshot previous_version \
168+ previous_snapshot forkgram_tip multigram multigram_base stack_count existing_next candidate \
169+ candidate_count pushed tag; do
171+ [ -z "$v" ] || printf '| %s | `%s` |\n' "$k" "$(md "$v" | tr '|' '/')"
173+ for k in conflict_commit conflict_subject conflict_position conflict_details dropped missing rerere_resolved error; do
175+ [ -z "$v" ] || printf '\n**%s**\n```text\n%s\n```\n' "$k" "$(md "$v")"
177+ } >>"$GITHUB_STEP_SUMMARY"
183+ if [ -n "$WT" ] && [ -e "$WT" ]; then
184+ git -C "$WT" rebase --abort >/dev/null 2>&1
185+ git worktree remove --force "$WT" >/dev/null 2>&1 || { rm -rf "$WT"; git worktree prune; }
187+ git update-ref -d "$TIP_REF" >/dev/null 2>&1
188+ [ -z "$WORK" ] || rm -rf "$WORK"
189+ if [ $rc -ne 0 ] && [ -z "$(get status)" ]; then
191+ out error "${ERROR_MSG:-the script stopped unexpectedly (exit $rc); see the log above}"
193+ if [ "$(get status)" = error ]; then annotate error "$(get error)"; fi
200+# ---------------------------------------------------------------- git helpers
202+short() { git rev-parse --short "$1" 2>/dev/null || printf '%s' "${1:0:10}"; }
206+ top=$(git rev-parse --show-toplevel 2>/dev/null) || die "Run this inside a clone of the MultiGram repository."
208+ WORK=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/multigram-sync.XXXXXX")
209+ # Commits made here (the snapshot, the replayed stack) need a committer identity.
210+ if ! git config user.email >/dev/null 2>&1; then
211+ GITID=(-c "user.name=MultiGram sync" -c "user.email=multigram-sync@users.noreply.github.com")
215+# git for the commits this script makes: the fallback identity, and no signing (a local
216+# signing setup could prompt, or fail, in the middle of the replay).
217+g() { git -c commit.gpgSign=false ${GITID[@]+"${GITID[@]}"} "$@"; }
219+remote_ref() { # "ls-remote output" refname -> sha or empty
220+ printf '%s\n' "$1" | awk -v r="$2" '$2 == r { print $1; exit }'
223+check_free_branches() { # branch... : this script moves these branches
225+ list=$(git worktree list --porcelain)
227+ if grep -qxF "branch refs/heads/$b" <<<"$list"; then
228+ die "The branch $b is checked out in a worktree. Switch that worktree to another branch first; this script moves $b."
233+set_branch() { # branch sha why
235+ old=$(git rev-parse -q --verify "refs/heads/$1" || true)
236+ [ "$old" != "$2" ] || return 0
237+ git update-ref -m "sync-forkgram: $3" "refs/heads/$1" "$2"
238+ if [ -n "$old" ]; then
239+ log "local branch $1: $(short "$old") -> $(short "$2")"
241+ log "local branch $1: created at $(short "$2")"
245+# True when replacing multigram-next loses nothing a person made: there is none, it is the
246+# one this script built last (the record), or multigram already has all of it.
247+next_is_ours() { # next record multigram
248+ [ -z "$1" ] || [ "$1" = "$2" ] || git merge-base --is-ancestor "$1" "$3" 2>/dev/null
251+# Stops before the local multigram-next moves away from commits that are on no other
252+# branch, tag or record (say a fix by hand that was not pushed yet).
253+check_local_next() { # [the sha it would move to]
255+ cur=$(git rev-parse -q --verify "refs/heads/$NEXT" || true)
256+ [ -n "$cur" ] && [ "$cur" != "${1:-}" ] || return 0
257+ others=$(git for-each-ref --format='%(refname)' --contains "$cur" | grep -vxF "refs/heads/$NEXT" || true)
259+ die "Your local $NEXT ($(short "$cur")) has commits that are on no other branch or tag, so this script does not move it. Push it, or keep it under another name (git branch -m $NEXT <name>), then run again."
262+# Prints "sha subject" for each commit of multigram's stack (base..mg) that the hand-made
263+# multigram-next (old..next) does not carry. A commit counts as carried when next has one
264+# with the same author (e-mail and time) and subject, which a rebase, a conflict fix, a
265+# reword-free fixup or git am all keep, or one with the same patch id.
266+missing_commits() { # base mg old next
267+ local f=$WORK/missing
269+ git log --no-merges --no-color --no-ext-diff --format='commit %H' -p "$3..$4" | git patch-id --stable | awk '{ print "P\t" $1 }'
270+ git log --no-merges --format='N%x09%ae %at%x09%s' "$3..$4"
271+ git log --no-merges --no-color --no-ext-diff --format='commit %H' -p "$1..$2" | git patch-id --stable | awk '{ print "Q\t" $2 "\t" $1 }'
272+ git log --no-merges --reverse --format='M%x09%H%x09%ae %at%x09%s' "$1..$2"
275+ $1 == "P" { pid[$2] = 1; next }
276+ $1 == "N" { k = $0; sub(/^[^\t]*\t/, "", k); have[k] = 1; next }
277+ $1 == "Q" { mine[$2] = $3; next }
279+ k = $0; sub(/^[^\t]*\t[^\t]*\t/, "", k)
280+ if ((k in have) || (($2 in mine) && (mine[$2] in pid))) next
281+ s = k; sub(/^[^\t]*\t/, "", s)
286+# The replay flattens merge commits (git rebase without --rebase-merges). That loses nothing
287+# when a merge only joins its parents, so those just get a warning; a merge with changes of
288+# its own (a conflict fix, glue code) would be lost, so it stops the sync.
289+check_merges() { # old mg
290+ local m parents tree bad= clean=0
291+ for m in $(git rev-list --merges "$1..$2"); do
292+ parents=$(git rev-list --parents -n 1 "$m" | cut -d' ' -f2-)
293+ if [ "$(printf '%s\n' $parents | grep -c .)" -eq 2 ] &&
294+ tree=$(git merge-tree --write-tree --no-messages $parents 2>/dev/null) &&
295+ [ "${tree%%"$NL"*}" = "$(git rev-parse "$m^{tree}")" ]; then
296+ clean=$((clean + 1))
299+ bad+="$(short "$m") $(git log -1 --format=%s "$m")$NL"
301+ if [ -n "$bad" ]; then
302+ die "$MG has merge commits with changes of their own (the check needs git 2.38 or newer):$NL$bad""The sync replays the stack without merges, so those changes would be lost. Make $MG linear first and keep what the merges added as a commit of its own: git switch -C $MG origin/$MG && git rebase origin/$FG (fix any conflicts), then git restore --source=origin/$MG --staged --worktree :/ && git commit -m 'Keep the changes made in merges', and push it with git push --force-with-lease origin $MG."
304+ [ "$clean" -eq 0 ] || warn "$MG has $clean merge commit(s) on top of $FG that only join their parents; the replay flattens them into a linear stack."
307+safe_version() { # prints the version, or "unknown" when it holds odd characters
308+ if [[ $1 =~ ^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$ ]]; then printf '%s' "$1"; else printf unknown; fi
311+tag_version() { # version fallback -> a string that is safe in a tag name
313+ v=$(printf '%s' "$1" | tr -c '0-9A-Za-z._-' '-')
314+ if [ "$v" = unknown ] || ! git check-ref-format "refs/tags/multigram-before-$v"; then v=$2; fi
318+has_github() { [ -n "$(git ls-tree --name-only "$1" -- .github)" ]; }
320+check_no_github() { # from to what : every commit in from..to
322+ for c in $(git rev-list "$1..$2"); do
323+ if has_github "$c"; then
324+ die "Refusing to go on: $3 would contain a .github folder (commit $(short "$c")). The forkgram snapshots and the MultiGram stack must never carry .github (GITHUB_TOKEN cannot push workflow files, and the workflows live on main)."
329+# ---------------------------------------------------------------- snapshot helpers
331+S_COMMIT= S_VERSION= S_BASE=
332+read_snapshot() { # commit : reads the Forkgram-* lines of a snapshot commit
334+ body=$(git log -1 --format=%B "$1")
335+ S_COMMIT=$(printf '%s\n' "$body" | sed -n 's/^Forkgram-Commit:[[:space:]]*\([0-9a-f]\{40\}\)[[:space:]]*$/\1/p' | tail -n 1)
336+ S_VERSION=$(printf '%s\n' "$body" | sed -n 's/^Forkgram-Version:[[:space:]]*//p' | tail -n 1 | tr -d '\r')
337+ S_BASE=$(printf '%s\n' "$body" | sed -n 's/^Telegram-Base:[[:space:]]*//p' | tail -n 1 | tr -d '\r')
338+ [ -n "$S_COMMIT" ] || return 1
339+ S_VERSION=$(safe_version "$S_VERSION")
342+forkgram_version() { # tree subject -> APP_VERSION_NAME from gradle.properties, else the "[Release] X." subject
344+ v=$(git cat-file -p "$1:gradle.properties" 2>/dev/null |
345+ sed -n 's/^[[:space:]]*APP_VERSION_NAME[[:space:]]*=[[:space:]]*//p' | tail -n 1 | tr -d '\r' || true)
346+ v=${v%%[[:space:]]*}
347+ if [ -z "$v" ]; then
348+ v=$(printf '%s\n' "$2" | sed -n 's/^\[Release\][[:space:]]*\([0-9][0-9A-Za-z._-]*[0-9A-Za-z]\)\.\{0,1\}[[:space:]]*$/\1/p')
354+find_telegram_base() { # tip : sets TBASE to "sha (update to X)" when Forkgram's history shows it
355+ local tip=$1 hist=$WORK/history.git lines line sha ver
357+ [ "$FORKGRAM_HISTORY_DEPTH" -gt 0 ] || return 0
358+ # Commits only (no trees or files): cheap even a few hundred commits deep.
359+ if ! git clone --quiet --bare --filter=tree:0 --depth "$FORKGRAM_HISTORY_DEPTH" --single-branch \
360+ --branch "$FORKGRAM_BRANCH" --no-tags "$FORKGRAM_URL" "$hist" >"$WORK/history.log" 2>&1; then
361+ warn "Could not read Forkgram's history, so the snapshot has no Telegram-Base line."
364+ if ! git -C "$hist" cat-file -e "$tip^{commit}" 2>/dev/null; then
365+ warn "Forkgram's $FORKGRAM_BRANCH moved during the sync, so the snapshot has no Telegram-Base line."
368+ lines=$(git -C "$hist" log --first-parent --format='%H %s' "$tip")
369+ line=$(printf '%s\n' "$lines" | grep -m 1 -E '^[0-9a-f]{40} update to [0-9]' || true)
370+ if [ -z "$line" ]; then
371+ warn "No Telegram \"update to X\" commit in the last $FORKGRAM_HISTORY_DEPTH Forkgram commits, so the snapshot has no Telegram-Base line."
375+ ver=$(printf '%s\n' "${line#* }" | sed -n 's/^update to \([0-9][0-9A-Za-z._-]*\).*/\1/p')
376+ TBASE="$sha (update to $(safe_version "$ver"))"
379+source_link() { # commit
380+ local u=${FORKGRAM_URL%/}
383+ https://github.com/*/*) printf '%s/commit/%s' "$u" "$1" ;;
384+ *) printf '%s (%s) at %s' "$FORKGRAM_URL" "$FORKGRAM_BRANCH" "$1" ;;
388+snapshot_tree() { # tree -> the same tree without the top-level .github
390+ while IFS= read -r -d '' entry; do
391+ [ "${entry#*$'\t'}" = .github ] && continue
392+ printf '%s\0' "$entry"
393+ done < <(git ls-tree -z "$1") | git mktree -z
397+make_snapshot() { # tip tip_tree parent version telegram_base : sets NEW_SNAPSHOT
398+ local tip=$1 tree msg=$WORK/snapshot-message
399+ tree=$(snapshot_tree "$2") || die "Could not build the snapshot tree of $tip."
401+ printf 'Forkgram %s snapshot\n\n' "$4"
402+ printf 'Source: %s\n' "$(source_link "$tip")"
403+ printf 'Forkgram-Commit: %s\n' "$tip"
404+ printf 'Forkgram-Version: %s\n' "$4"
405+ if [ -n "$5" ]; then printf 'Telegram-Base: %s\n' "$5"; fi
406+ printf "\\nTree is Forkgram's %s branch at that commit with the .github folder removed.\\n" "$FORKGRAM_BRANCH"
408+ NEW_SNAPSHOT=$(g commit-tree "$tree" -p "$3" -F "$msg") || die "Could not create the snapshot commit."
411+# Gets Forkgram's tip commit's files into this repository. Sets TIP (the commit, re-read
412+# from the fetch), TIP_TREE and TIP_SUBJECT.
413+TIP= TIP_TREE= TIP_SUBJECT=
414+fetch_tip() { # sha from ls-remote
417+ if [ -z "$(git config extensions.partialClone || true)" ] && git cat-file -e "$TIP^{commit}" 2>/dev/null; then
418+ log "Forkgram ${TIP:0:10} is already here; not fetching it"
419+ elif [ "$(git rev-parse --is-shallow-repository)" = true ]; then
420+ # A shallow clone (as in CI) takes one more shallow commit in its stride.
421+ log "Fetching Forkgram ${TIP:0:10}"
422+ git fetch --no-tags --quiet --depth=1 "$FORKGRAM_URL" "+refs/heads/$FORKGRAM_BRANCH:$TIP_REF" ||
423+ die "Could not fetch Forkgram's $FORKGRAM_BRANCH from $FORKGRAM_URL."
424+ TIP=$(git rev-parse --verify "$TIP_REF^{commit}")
426+ # A full clone: a shallow fetch would turn it into a shallow repository. Fetch into a
427+ # scratch repository instead and copy only the tip's tree (its files) into this one.
428+ log "Fetching Forkgram ${TIP:0:10} (into a scratch repository)"
429+ tipgit=(git -C "$WORK/tip.git")
430+ git init --quiet --bare "$WORK/tip.git"
431+ "${tipgit[@]}" fetch --no-tags --quiet --depth=1 "$FORKGRAM_URL" "+refs/heads/$FORKGRAM_BRANCH:refs/tip" ||
432+ die "Could not fetch Forkgram's $FORKGRAM_BRANCH from $FORKGRAM_URL."
433+ TIP=$("${tipgit[@]}" rev-parse --verify "refs/tip^{commit}")
434+ "${tipgit[@]}" rev-list --objects "$TIP^{tree}" | "${tipgit[@]}" pack-objects --quiet --stdout |
435+ git index-pack --stdin >/dev/null || die "Could not copy Forkgram's files into this repository."
437+ TIP_TREE=$("${tipgit[@]}" rev-parse --verify "$TIP^{tree}")
438+ TIP_SUBJECT=$("${tipgit[@]}" log -1 --format=%s "$TIP")
441+set_snapshot_outputs() { # snapshot new_snapshot(yes|no)
442+ read_snapshot "$1" || die "The snapshot $(short "$1") has no Forkgram-Commit line."
444+ out new_snapshot "$2"
445+ out forkgram_commit "$S_COMMIT"
446+ out forkgram_version "$S_VERSION"
447+ out telegram_base "$S_BASE"
448+ out tag_version "$(tag_version "$S_VERSION" "$(git rev-parse --short=10 "$S_COMMIT" 2>/dev/null || printf '%s' "${S_COMMIT:0:10}")")"
451+# ---------------------------------------------------------------- the replay
453+rebase_in_progress() {
455+ for d in rebase-merge rebase-apply; do
456+ [ ! -d "$(git -C "$WT" rev-parse --path-format=absolute --git-path "$d")" ] || return 0
461+conflict_kinds() { # "stages<TAB>path" lines -> "what happened: path" lines
464+ if (k == "123") w = "changed on both sides"
465+ else if (k == "23") w = "added on both sides"
466+ else if (k == "13") w = "Forkgram deleted it, MultiGram changed it"
467+ else if (k == "12") w = "MultiGram deleted it, Forkgram changed it"
468+ else if (k == "2") w = "only on the Forkgram side (rename or delete conflict)"
469+ else if (k == "3") w = "only on the MultiGram side (rename or delete conflict)"
470+ else w = "conflict (stages " k ")"
475+# Replays upstream..tip onto onto in a temporary worktree. Sets REPLAY to "ok" and
476+# CANDIDATE, or to "conflict" and the conflict_* outputs.
479+replay() { # onto upstream tip
480+ local onto=$1 upstream=$2 tip=$3 rc=0 rounds=0 from=0 stopped unmerged stages files stack pos total resolved
481+ local rlog=$WORK/rebase.log
483+ git worktree add --quiet --detach "$WT" "$tip" >"$WORK/worktree.log" 2>&1 ||
484+ { cat "$WORK/worktree.log" >&2; die "Could not create a temporary worktree for the rebase."; }
485+ # The caller's git config must not change the replay: no update-refs (it would move the
486+ # caller's own branches), no merge-preserving rebase, no autostash, no hooks, no signing.
487+ local rb=(git -C "$WT" -c rerere.enabled=true -c rerere.autoUpdate=true -c rebase.updateRefs=false
488+ -c rebase.rebaseMerges=false -c rebase.autoStash=false -c rebase.backend=merge
489+ -c core.hooksPath=/dev/null -c commit.gpgSign=false ${GITID[@]+"${GITID[@]}"})
490+ log "Replaying $(git rev-list --count "$upstream..$tip") MultiGram commit(s) onto $(short "$onto")"
491+ # LC_ALL=C: the messages below are read back (rerere and dropped commits).
492+ GIT_EDITOR=true LC_ALL=C "${rb[@]}" rebase --no-autosquash --empty=drop --onto "$onto" "$upstream" >"$rlog" 2>&1 || rc=$?
493+ while [ $rc -ne 0 ]; do
494+ if ! rebase_in_progress; then
495+ print_untrusted "$(tail -n 40 "$rlog" | tr '\r' '\n')"
496+ die "git rebase failed without a conflict; see the log above."
498+ unmerged=$(git -C "$WT" ls-files -u)
499+ [ -z "$unmerged" ] || break
500+ # rerere resolved every conflict of this commit from a recorded resolution: go on.
501+ stopped=$(git -C "$WT" rev-parse -q --verify REBASE_HEAD || echo unknown)
502+ resolved=$(tail -c +$((from + 1)) "$rlog" | tr '\r' '\n' |
503+ sed -n -E "s/^(Resolved|Staged) '(.*)' using previous resolution\.\$/$(short "$stopped") \2/p")
504+ [ -n "$resolved" ] || resolved="$(short "$stopped") (files not named by git)"
505+ out rerere_resolved "$(get rerere_resolved)${o_rerere_resolved:+$NL}$resolved"
506+ rounds=$((rounds + 1))
507+ [ $rounds -le 1000 ] || die "git rebase keeps stopping; giving up."
508+ log "rerere resolved the conflicts in $(short "$stopped") from a recorded resolution; continuing"
510+ from=$(wc -c <"$rlog")
511+ GIT_EDITOR=true LC_ALL=C "${rb[@]}" rebase --continue >>"$rlog" 2>&1 || rc=$?
515+ dropped=$(tr '\r' '\n' <"$rlog" | grep -E '^(dropping |warning: skipped previously applied commit)' || true)
516+ [ -z "$dropped" ] || out dropped "$dropped"
518+ if [ $rc -eq 0 ]; then
519+ CANDIDATE=$(git -C "$WT" rev-parse HEAD)
524+ stopped=$(git -C "$WT" rev-parse -q --verify REBASE_HEAD || true)
525+ stages=$(printf '%s\n' "$unmerged" | awk -F'\t' '{ split($1, a, " "); s[$2] = s[$2] a[3] } END { for (p in s) print s[p] "\t" p }' | sort -t "$(printf '\t')" -k 2)
526+ files=$(printf '%s\n' "$stages" | cut -f 2-)
527+ stack=$(git rev-list --reverse "$upstream..$tip")
528+ total=$(printf '%s\n' "$stack" | grep -c . || true)
529+ pos=$(printf '%s\n' "$stack" | grep -n -x -F "${stopped:-none}" | cut -d: -f1 || true)
530+ out conflict_commit "$stopped"
531+ out conflict_subject "$( [ -z "$stopped" ] || git log -1 --format=%s "$stopped")"
532+ out conflict_position "${pos:-?} of $total"
533+ out conflict_files "$files"
534+ out conflict_details "$(printf '%s\n' "$stages" | conflict_kinds)"
535+ print_untrusted "$(tr '\r' '\n' <"$rlog" | grep -v '^Rebasing (' | tail -n 40)"
536+ git -C "$WT" rebase --abort >/dev/null 2>&1 || true
540+# ---------------------------------------------------------------- sync
543+ local remote= push=no force=no use_next=no allow_drop=no
544+ while [ $# -gt 0 ]; do
546+ --remote) remote=${2:?--remote needs a name}; shift 2 ;;
547+ --remote=*) remote=${1#*=}; shift ;;
548+ --push) push=yes; shift ;;
549+ --force) force=yes; shift ;;
550+ --use-next) use_next=yes; shift ;;
551+ --allow-drop) allow_drop=yes; shift ;;
552+ --url) FORKGRAM_URL=${2:?--url needs a URL}; shift 2 ;;
553+ --url=*) FORKGRAM_URL=${1#*=}; shift ;;
554+ --branch) FORKGRAM_BRANCH=${2:?--branch needs a name}; shift 2 ;;
555+ --branch=*) FORKGRAM_BRANCH=${1#*=}; shift ;;
556+ --history-depth) FORKGRAM_HISTORY_DEPTH=${2:?--history-depth needs a number}; shift 2 ;;
557+ --history-depth=*) FORKGRAM_HISTORY_DEPTH=${1#*=}; shift ;;
558+ -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
559+ *) die "Unknown option: $1 (see --help)" ;;
562+ [ "$push" = no ] || [ -n "$remote" ] || die "--push needs --remote."
563+ [ "$force" = no ] || [ "$use_next" = no ] || die "--force and --use-next do not go together."
564+ [ "$allow_drop" = no ] || [ "$use_next" = yes ] || die "--allow-drop only goes with --use-next."
565+ [[ $FORKGRAM_HISTORY_DEPTH =~ ^[0-9]+$ ]] || die "--history-depth must be a number."
567+ check_free_branches "$FG" "$NEXT"
569+ # 1. Where forkgram, multigram and multigram-next are now, and the multigram-next this
570+ # script built last (rec_prev).
571+ local old mg next_prev= rec_prev=
572+ if [ -n "$remote" ]; then
573+ local heads specs what
574+ heads=$(git ls-remote "$remote" "refs/heads/$FG" "refs/heads/$MG" "refs/heads/$NEXT" "$REC_REF") ||
575+ die "Could not read the branches of $remote."
576+ [ -n "$(remote_ref "$heads" "refs/heads/$FG")" ] ||
577+ die "$remote has no $FG branch. It holds the Forkgram snapshots; create it first (see README.md)."
578+ [ -n "$(remote_ref "$heads" "refs/heads/$MG")" ] ||
579+ die "$remote has no $MG branch yet. Create it (the latest $FG snapshot plus the MultiGram commits) and push it; the sync replays that stack and cannot start without it."
580+ next_prev=$(remote_ref "$heads" "refs/heads/$NEXT")
581+ rec_prev=$(remote_ref "$heads" "$REC_REF")
582+ specs=("+refs/heads/$FG:refs/remotes/$remote/$FG" "+refs/heads/$MG:refs/remotes/$remote/$MG")
584+ if [ -n "$next_prev" ]; then
585+ specs+=("+refs/heads/$NEXT:refs/remotes/$remote/$NEXT")
586+ what="$FG, $MG and $NEXT"
588+ log "Fetching $what from $remote"
589+ git fetch --no-tags --quiet "$remote" "${specs[@]}" || die "Could not fetch $what from $remote."
590+ old=$(git rev-parse --verify "refs/remotes/$remote/$FG^{commit}")
591+ mg=$(git rev-parse --verify "refs/remotes/$remote/$MG^{commit}")
592+ [ -z "$next_prev" ] || next_prev=$(git rev-parse --verify "refs/remotes/$remote/$NEXT^{commit}")
594+ old=$(git rev-parse -q --verify "refs/heads/$FG^{commit}") ||
595+ die "There is no $FG branch here. It holds the Forkgram snapshots; fetch it or pass --remote."
596+ mg=$(git rev-parse -q --verify "refs/heads/$MG^{commit}") ||
597+ die "There is no $MG branch yet. Create it (the latest $FG snapshot plus the MultiGram commits); the sync replays that stack and cannot start without it."
598+ next_prev=$(git rev-parse -q --verify "refs/heads/$NEXT^{commit}" || true)
599+ rec_prev=$(git rev-parse -q --verify "$REC_REF^{commit}" || true)
601+ out existing_next "$next_prev"
603+ # 2. The latest snapshot and the stack on top of it.
604+ read_snapshot "$old" ||
605+ die "The tip of $FG ($(short "$old")) has no Forkgram-Commit line, so it is not a Forkgram snapshot."
606+ local old_commit=$S_COMMIT old_version=$S_VERSION base behind stack_count
607+ out previous_snapshot "$old"
608+ out previous_forkgram_commit "$old_commit"
609+ out previous_version "$old_version"
611+ base=$(git merge-base "$old" "$mg") || die "$MG shares no history with $FG."
612+ out multigram_base "$base"
613+ if [ "$base" = "$old" ]; then behind=no; else behind=yes; fi
614+ if [ "$behind" = yes ] && ! read_snapshot "$base"; then
615+ warn "$MG is based on $(short "$base"), which is not a $FG snapshot."
617+ stack_count=$(git rev-list --count "$old..$mg")
618+ out stack_count "$stack_count"
619+ [ "$stack_count" -gt 0 ] || warn "$MG has no commits on top of $FG."
621+ # 3a. A stack fixed by hand, already pushed to multigram-next.
622+ if [ "$use_next" = yes ]; then
624+ [ -n "$next_prev" ] || die "There is no $NEXT branch. Push the fixed stack to $NEXT first (see README.md)."
625+ git merge-base --is-ancestor "$old" "$next_prev" ||
626+ die "$NEXT is not based on the latest $FG snapshot ($(short "$old"), Forkgram $old_version). Rebase it onto $FG first."
627+ [ "$next_prev" != "$old" ] || die "$NEXT has no commits on top of $FG."
628+ set_snapshot_outputs "$old" no
629+ check_no_github "$old" "$next_prev" "$NEXT"
630+ # Everything on multigram must still be there: commits pushed to multigram after the fix
631+ # was started would otherwise leave the app branch at the promotion.
632+ missing=$(missing_commits "$base" "$mg" "$old" "$next_prev")
633+ if [ -n "$missing" ]; then
634+ n=$(printf '%s\n' "$missing" | grep -c .)
635+ if [ "$allow_drop" = yes ]; then
636+ out dropped "$missing"
637+ warn "$NEXT leaves out $n of $MG's commit(s); --allow-drop accepts that (see the dropped output)."
639+ out missing "$missing"
640+ die "$NEXT does not carry $n of $MG's commit(s):$NL$missing${NL}They were probably pushed to $MG after the fix on $NEXT was started. Add them to $NEXT (git switch $NEXT && git cherry-pick <commit>...), push it and run with use_next again. If $NEXT leaves them out on purpose (Forkgram has them now, or you folded or dropped them), run use_next with allow_drop (--allow-drop)."
643+ CANDIDATE=$next_prev
644+ out candidate "$CANDIDATE"
645+ out candidate_count "$(git rev-list --count "$old..$CANDIDATE")"
646+ check_local_next "$CANDIDATE"
647+ set_branch "$FG" "$old" "Forkgram $old_version snapshot"
648+ set_branch "$NEXT" "$CANDIDATE" "use multigram-next as the candidate"
650+ out status candidate
651+ out message "Using $NEXT ($(short "$CANDIDATE")) as it is: $(get candidate_count) commit(s) on the Forkgram $old_version snapshot."
652+ log "$(get message)"
656+ # 3b. Forkgram's tip.
657+ local tip new new_snapshot tbase version
658+ log "Reading $FORKGRAM_URL ($FORKGRAM_BRANCH)"
659+ tip=$(git ls-remote "$FORKGRAM_URL" "refs/heads/$FORKGRAM_BRANCH") || die "Could not reach Forkgram at $FORKGRAM_URL."
660+ tip=$(remote_ref "$tip" "refs/heads/$FORKGRAM_BRANCH")
661+ [ -n "$tip" ] || die "Forkgram has no $FORKGRAM_BRANCH branch at $FORKGRAM_URL."
662+ out forkgram_tip "$tip"
664+ if [ "$tip" = "$old_commit" ] && [ "$force" = no ]; then
665+ set_snapshot_outputs "$old" no
668+ if [ "$behind" = no ]; then
669+ out status up-to-date
670+ out message "Nothing to do: Forkgram's $FORKGRAM_BRANCH is still $(short "$tip") (Forkgram $old_version), the latest $FG snapshot, and $MG is based on it."
671+ log "$(get message)"
674+ out message "No new Forkgram release, but $MG is still based on $(short "$base"), not on the Forkgram $old_version snapshot ($(short "$old")): the last sync could not move it. Fix the stack on $NEXT and run with --use-next, or retry with --force."
675+ warn "$(get message)"
680+ # From here on the run builds a new multigram-next. It never replaces one that holds
681+ # commits it did not make (a fix by hand): that one waits for --use-next, or for someone
682+ # to delete it. Nothing is snapshotted meanwhile, so the fix stays based on the latest
683+ # snapshot and --use-next can take it.
684+ if ! next_is_ours "$next_prev" "$rec_prev" "$mg"; then
685+ set_snapshot_outputs "$old" no
688+ out status next-edited
689+ if [ "$tip" = "$old_commit" ]; then
690+ out message "$NEXT ($(short "$next_prev")) has commits the sync did not make (a fix by hand?), so --force does not replace it. Run with --use-next to compile and promote it, or delete $NEXT to let the sync rebuild it from $MG."
692+ out message "Forkgram's $FORKGRAM_BRANCH has moved to $(short "$tip"), but $NEXT ($(short "$next_prev")) has commits the sync did not make (a fix by hand?), so the sync changes nothing: no new snapshot, no new $NEXT. Run with --use-next to compile and promote $NEXT, or delete it to let the sync rebuild it from $MG; the next run then takes the new Forkgram commit."
694+ warn "$(get message)"
698+ check_merges "$old" "$mg"
700+ if [ "$tip" = "$old_commit" ]; then
701+ log "No new Forkgram release; --force: rebuilding $NEXT on the current snapshot"
706+ [ "$TIP" != "$old_commit" ] ||
707+ die "Forkgram's $FORKGRAM_BRANCH moved back to the latest snapshot's commit during the sync; run it again."
709+ out forkgram_tip "$tip"
710+ version=$(forkgram_version "$TIP_TREE" "$TIP_SUBJECT")
711+ [ "$version" != unknown ] || warn "Could not read the Forkgram version (APP_VERSION_NAME in gradle.properties)."
712+ find_telegram_base "$tip"
714+ make_snapshot "$tip" "$TIP_TREE" "$old" "$version" "$tbase"
717+ log "New snapshot $(short "$new"): Forkgram $version ($(short "$tip")), Telegram base: ${tbase:-not found}"
718+ if [ "$version" = "$old_version" ]; then
719+ warn "Forkgram's $FORKGRAM_BRANCH moved but its version is still $version."
722+ set_snapshot_outputs "$new" "$new_snapshot"
723+ version=$(get forkgram_version)
724+ check_no_github "$old" "$new" "$FG"
727+ replay "$new" "$old" "$mg"
728+ if [ "$REPLAY" = ok ]; then
729+ check_no_github "$new" "$CANDIDATE" "$NEXT"
730+ check_local_next "$CANDIDATE"
732+ set_branch "$FG" "$new" "Forkgram $version snapshot"
733+ if [ "$REPLAY" = ok ]; then
734+ set_branch "$NEXT" "$CANDIDATE" "replay of $MG onto Forkgram $version"
735+ git update-ref -m "sync-forkgram: the $NEXT it built" "$REC_REF" "$CANDIDATE"
736+ out candidate "$CANDIDATE"
737+ out candidate_count "$(git rev-list --count "$new..$CANDIDATE")"
742+ # 5. The push. refs/multigram-sync/next records the multigram-next the sync built, so a
743+ # later run can tell it from one changed by hand.
744+ local refspecs=() leases=() pushed=()
745+ if [ "$new_snapshot" = yes ]; then
746+ refspecs+=("$new:refs/heads/$FG")
747+ leases+=("--force-with-lease=refs/heads/$FG:$old")
750+ if [ -n "$CANDIDATE" ]; then
751+ refspecs+=("$CANDIDATE:refs/heads/$NEXT" "$CANDIDATE:$REC_REF")
752+ leases+=("--force-with-lease=refs/heads/$NEXT:$next_prev" "--force-with-lease=$REC_REF:$rec_prev")
755+ if [ "$push" = yes ] && [ ${#refspecs[@]} -gt 0 ]; then
756+ log "Pushing ${pushed[*]} to $remote"
757+ git push --atomic --quiet "${leases[@]}" "$remote" "${refspecs[@]}" || die "The push of ${pushed[*]} to $remote failed."
758+ out pushed "${pushed[*]}"
763+ if [ "$REPLAY" = ok ]; then
764+ out status candidate
765+ out message "$NEXT ($(short "$CANDIDATE")) is $MG's $(get stack_count) commit(s) replayed onto Forkgram $version ($(short "$(get forkgram_commit)")); $(get candidate_count) commit(s) on top of the snapshot."
766+ log "$(get message)"
767+ [ -z "$(get dropped)" ] || warn "Some MultiGram commits were left out because Forkgram already has them; see the dropped output."
771+ out message "The MultiGram stack does not apply to Forkgram $version: commit $(short "$(get conflict_commit)") ($(get conflict_position)) conflicts in $(printf '%s\n' "$(get conflict_files)" | grep -c .) file(s). $MG is unchanged."
772+ annotate error "$(get message)"
776+# ---------------------------------------------------------------- promote
779+ local remote= cand= prev= version= shallow=no
780+ while [ $# -gt 0 ]; do
782+ --remote) remote=${2:?}; shift 2 ;;
783+ --candidate) cand=${2:?}; shift 2 ;;
784+ --previous) prev=${2:?}; shift 2 ;;
785+ --version) version=${2:?}; shift 2 ;;
786+ --shallow) shallow=yes; shift ;;
787+ -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
788+ *) die "Unknown option: $1 (see --help)" ;;
791+ [ -n "$remote" ] || die "promote needs --remote."
792+ [[ $cand =~ ^[0-9a-f]{40}$ ]] || die "promote needs --candidate with a full commit id."
793+ [[ $prev =~ ^[0-9a-f]{40}$ ]] || die "promote needs --previous with a full commit id."
794+ [[ $version =~ ^[0-9A-Za-z._-]+$ ]] || die "promote needs --version (letters, digits, dot, dash)."
797+ local heads now_mg now_next depth=() tag base n existing
798+ heads=$(git ls-remote "$remote" "refs/heads/$MG" "refs/heads/$NEXT") || die "Could not read the branches of $remote."
799+ now_mg=$(remote_ref "$heads" "refs/heads/$MG")
800+ now_next=$(remote_ref "$heads" "refs/heads/$NEXT")
801+ [ "$now_mg" = "$prev" ] ||
802+ die "$MG on $remote is ${now_mg:-missing}, not $prev that the candidate was built from: it was pushed to meanwhile. Not promoting."
803+ [ "$now_next" = "$cand" ] ||
804+ die "$NEXT on $remote is ${now_next:-missing}, not the compiled candidate $cand. Not promoting."
805+ [ "$shallow" = no ] || depth=(--depth=1)
806+ git fetch --no-tags --quiet ${depth[@]+"${depth[@]}"} "$remote" \
807+ "+refs/heads/$MG:refs/remotes/$remote/$MG" "+refs/heads/$NEXT:refs/remotes/$remote/$NEXT" ||
808+ die "Could not fetch $MG and $NEXT from $remote."
809+ ! has_github "$cand" || die "Refusing to promote: the candidate contains a .github folder."
810+ out candidate "$cand"
811+ out previous_multigram "$prev"
813+ if [ "$cand" = "$prev" ]; then
814+ git push --atomic --quiet "--force-with-lease=refs/heads/$NEXT:$cand" "$remote" ":refs/heads/$NEXT" ||
815+ die "Could not delete $NEXT on $remote."
816+ out status unchanged
818+ out message "$MG already is the candidate ($(short "$cand")); deleted $NEXT."
819+ log "$(get message)"
823+ base=multigram-before-$version
824+ existing=$(git ls-remote --tags "$remote" "refs/tags/$base*" | awk '{ print $2 }') ||
825+ die "Could not read the tags of $remote."
828+ while grep -qxF "refs/tags/$tag" <<<"$existing"; do
832+ log "Tagging $(short "$prev") as $tag, moving $MG to $(short "$cand"), deleting $NEXT"
833+ git push --atomic --quiet \
834+ "--force-with-lease=refs/heads/$MG:$prev" "--force-with-lease=refs/heads/$NEXT:$cand" \
835+ "$remote" "$cand:refs/heads/$MG" "$prev:refs/tags/$tag" ":refs/heads/$NEXT" ||
836+ die "The push that moves $MG failed; $MG is unchanged."
839+ out message "$MG moved from $(short "$prev") (tagged $tag) to $(short "$cand"); $NEXT deleted."
840+ log "$(get message)"
845+ sync | promote) cmd=$1; shift ;;
847+# A conflict makes sync_main return 2, which (set -e) ends the script with exit code 2.