on main help sign in
8 files changed+2444−3
since splitsnapshots
unifiedsplit
.github/workflows/build.yml+42−2 .github/workflows/release.yml+242 .github/workflows/sync-forkgram.yml+177 README.md+130−1 scripts/export-patches.sh+86 scripts/selftest-sync.sh+646 scripts/sync-forkgram.sh+848 scripts/sync-report.py+273
.github/workflows/build.yml+42−2
@@ -20,6 +20,10 @@ on:
2020 apk:
2121 type: boolean
2222 default: false
23+ build_number:
24+ description: MultiGram build number (0 to 9), the fourth part of the version (release.yml sets it)
25+ type: string
26+ default: '0'
2327
2428permissions:
2529 contents: read
@@ -67,6 +71,15 @@ jobs:
6771 with:
6872 tool-cache: false
6973 android: false
74+ - name: Add swap space
75+ # Runners for private repositories have about 8 GB of memory; the native build and the
76+ # Gradle and Kotlin daemons together can use more, and the runner dies without a log.
77+ run: |
78+ sudo fallocate -l 8G /multigram-swap
79+ sudo chmod 600 /multigram-swap
80+ sudo mkswap /multigram-swap
81+ sudo swapon /multigram-swap
82+ free -h
7083 - uses: actions/setup-java@v4
7184 with:
7285 distribution: temurin
@@ -81,16 +94,43 @@ jobs:
8194 env:
8295 APP_ID: ${{ secrets.MULTIGRAM_APP_ID }}
8396 APP_HASH: ${{ secrets.MULTIGRAM_APP_HASH }}
97+ KEYSTORE_BASE64: ${{ secrets.MULTIGRAM_KEYSTORE_BASE64 }}
98+ KEYSTORE_PASSWORD: ${{ secrets.MULTIGRAM_KEYSTORE_PASSWORD }}
99+ KEY_ALIAS: ${{ secrets.MULTIGRAM_KEY_ALIAS }}
100+ KEY_PASSWORD: ${{ secrets.MULTIGRAM_KEY_PASSWORD }}
101+ BUILD_NUMBER: ${{ inputs.build_number }}
84102 run: |
85103 vars=gradle.properties
104+ # Forkgram's versioning: versionName APP_VERSION_NAME.N and versionCode
105+ # (APP_VERSION_CODE * 10 + N) * 10 + the flavor's ABI code, with N = ADDITIONAL_BUILD_NUMBER.
106+ case "${BUILD_NUMBER:-0}" in [0-9]) ;; *) echo "::error::build_number must be one digit"; exit 1;; esac
107+ echo "ADDITIONAL_BUILD_NUMBER=${BUILD_NUMBER:-0}" >> $vars
86108 echo "org.gradle.workers.max=1" >> $vars
109+ # Forkgram asks for an 8 GB heap, sized for the bigger runners of public repositories.
110+ echo "org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8" >> $vars
111+ echo "kotlin.daemon.jvmargs=-Xmx2g" >> $vars
112+ echo "org.gradle.parallel=false" >> $vars
87113 [ -n "$APP_ID" ] && echo "APP_ID=$APP_ID" >> $vars
88114 [ -n "$APP_HASH" ] && echo "APP_HASH=$APP_HASH" >> $vars
89− echo "RELEASE_KEYSTORE_FILE=$PWD/TMessagesProj/config/release.keystore" >> $vars
115+ signing=()
116+ if [ -n "$KEYSTORE_BASE64" ]; then
117+ # Your own release key, from the MULTIGRAM_KEYSTORE_* secrets; it never touches the repository.
118+ echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
119+ signing=(
120+ "-PRELEASE_KEYSTORE_FILE=$RUNNER_TEMP/release.keystore"
121+ "-PRELEASE_STORE_PASSWORD=$KEYSTORE_PASSWORD"
122+ "-PRELEASE_KEY_ALIAS=$KEY_ALIAS"
123+ "-PRELEASE_KEY_PASSWORD=${KEY_PASSWORD:-$KEYSTORE_PASSWORD}"
124+ )
125+ else
126+ # Without RELEASE_KEYSTORE_FILE the build signs with the public test key in
127+ # TMessagesProj/config/test.keystore: fine for testing, not for an APK you give out.
128+ echo "::warning::No MULTIGRAM_KEYSTORE_BASE64 secret: the APK is signed with the public test key."
129+ fi
90130 sudo apt update
91131 sudo apt -y install ninja-build meson nasm gperf pkg-config unzip
92132 rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
93− ./gradlew :TMessagesProj_App:assembleAfatRelease
133+ ./gradlew "${signing[@]}" :TMessagesProj_App:assembleAfatRelease
94134 - uses: actions/upload-artifact@v4
95135 with:
96136 name: multigram-apk
.github/workflows/release.ymlnew file+242
@@ -0,0 +1,242 @@
1+name: Release MultiGram
2+run-name: Release ${{ inputs.ref }}${{ inputs.dry_run && ' (dry run)' || '' }}
3+
4+# Builds the release APK of a MultiGram commit and publishes it as a GitHub Release.
5+#
6+# Version: Forkgram's own scheme, APP_VERSION_NAME.N (for example 12.10.6.1), where APP_VERSION_NAME
7+# comes from gradle.properties of the Forkgram snapshot and N (1 to 9) counts MultiGram releases on
8+# that Forkgram version. The Android versionCode is (APP_VERSION_CODE * 10 + N) * 10 + 9, so every
9+# release installs as an update over the one before, and a new Forkgram version starts again at N = 1.
10+# The tag is the version itself, like Forkgram's tags.
11+
12+on:
13+ workflow_dispatch:
14+ inputs:
15+ ref:
16+ description: Branch, tag or commit to release
17+ default: multigram
18+ required: true
19+ number:
20+ description: MultiGram build number for this Forkgram version (1 to 9). Empty picks the next free one.
21+ required: false
22+ default: ''
23+ prerelease:
24+ description: Mark it as a pre-release (always the case without your own signing key)
25+ type: boolean
26+ default: false
27+ dry_run:
28+ description: Only work out the version and the release notes; build and publish nothing
29+ type: boolean
30+ default: false
31+
32+permissions:
33+ contents: read
34+
35+concurrency:
36+ group: release
37+ cancel-in-progress: false
38+
39+jobs:
40+ check_secrets:
41+ # A job of its own, so that no secret value is registered in the job that writes the release
42+ # notes: the runner drops any job output that contains a secret's value (a key alias like
43+ # "release" would otherwise blank the notes).
44+ name: Check the secrets
45+ runs-on: ubuntu-latest
46+ outputs:
47+ signed: ${{ steps.s.outputs.signed }}
48+ steps:
49+ - name: Check the secrets
50+ id: s
51+ env:
52+ DRY_RUN: ${{ inputs.dry_run }}
53+ APP_ID: ${{ secrets.MULTIGRAM_APP_ID }}
54+ APP_HASH: ${{ secrets.MULTIGRAM_APP_HASH }}
55+ KEYSTORE_BASE64: ${{ secrets.MULTIGRAM_KEYSTORE_BASE64 }}
56+ KEYSTORE_PASSWORD: ${{ secrets.MULTIGRAM_KEYSTORE_PASSWORD }}
57+ KEY_ALIAS: ${{ secrets.MULTIGRAM_KEY_ALIAS }}
58+ run: |
59+ set -euo pipefail
60+ # Stop here, in seconds, rather than after the long build. A dry run only warns.
61+ problem() { if [ "$DRY_RUN" = true ]; then echo "::warning::$1"; else echo "::error::$1"; exit 1; fi; }
62+ if [ -z "$APP_ID" ] || [ -z "$APP_HASH" ]; then
63+ problem "The MULTIGRAM_APP_ID and MULTIGRAM_APP_HASH secrets are not set, so the APK could not log in. Add them (Settings > Secrets and variables > Actions) and run this again."
64+ fi
65+ if [ -n "$KEYSTORE_BASE64" ]; then
66+ if [ -z "$KEYSTORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
67+ problem "MULTIGRAM_KEYSTORE_BASE64 is set, but MULTIGRAM_KEYSTORE_PASSWORD or MULTIGRAM_KEY_ALIAS is not: signing would fail at the end of the build. Add both."
68+ fi
69+ echo "signed=true" >> "$GITHUB_OUTPUT"
70+ else
71+ echo "::warning::No MULTIGRAM_KEYSTORE_BASE64 secret: the release will be signed with Forkgram's public test key and published as a pre-release."
72+ echo "signed=false" >> "$GITHUB_OUTPUT"
73+ fi
74+
75+ prepare:
76+ name: Work out the version
77+ needs: check_secrets
78+ runs-on: ubuntu-latest
79+ outputs:
80+ sha: ${{ steps.v.outputs.sha }}
81+ number: ${{ steps.v.outputs.number }}
82+ version: ${{ steps.v.outputs.version }}
83+ version_code: ${{ steps.v.outputs.version_code }}
84+ prerelease: ${{ steps.v.outputs.prerelease }}
85+ notes: ${{ steps.v.outputs.notes }}
86+ steps:
87+ - uses: actions/checkout@v4
88+ with:
89+ ref: ${{ inputs.ref }}
90+ fetch-depth: 0
91+ - name: Version, secrets and release notes
92+ id: v
93+ env:
94+ NUMBER: ${{ inputs.number }}
95+ PRERELEASE: ${{ inputs.prerelease }}
96+ DRY_RUN: ${{ inputs.dry_run }}
97+ SIGNED: ${{ needs.check_secrets.outputs.signed }}
98+ run: |
99+ set -euo pipefail
100+ fail() { echo "::error::$1"; exit 1; }
101+ sha=$(git rev-parse HEAD)
102+ name=$(sed -n 's/^APP_VERSION_NAME=//p' gradle.properties | tail -n 1)
103+ code=$(sed -n 's/^APP_VERSION_CODE=//p' gradle.properties | tail -n 1)
104+ [[ "$name" =~ ^[0-9]+(\.[0-9]+)*$ ]] || fail "APP_VERSION_NAME in gradle.properties is '$name', not a version number."
105+ [[ "$code" =~ ^[0-9]+$ ]] || fail "APP_VERSION_CODE in gradle.properties is '$code', not a number."
106+
107+ # The next free N for this Forkgram version, from the tags that already exist.
108+ last=$(git tag -l "$name.*" | sed -n "s/^${name//./\\.}\.\([0-9]\)$/\1/p" | sort -n | tail -n 1)
109+ if [ -n "$NUMBER" ]; then
110+ n=$NUMBER
111+ else
112+ n=$(( ${last:-0} + 1 ))
113+ fi
114+ [[ "$n" =~ ^[1-9]$ ]] || fail "The MultiGram build number must be 1 to 9 (got '$n'). Nine releases on Forkgram $name are the most its version codes allow; wait for the next Forkgram version."
115+ version="$name.$n"
116+ if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then
117+ fail "Tag $version already exists: MultiGram $version is already released. Leave the number empty to take the next free one."
118+ fi
119+ if [ -n "$last" ] && [ "$n" -le "$last" ]; then
120+ echo "::warning::$name.$last is already released, so $version has a lower version code and will not install as an update over it."
121+ fi
122+ version_code=$(( (code * 10 + n) * 10 + 9 ))
123+
124+ # What it is built on, from the snapshot commit the sync made.
125+ snap=$(git log -n 1 --format=%H --grep='^Forkgram-Version: ' HEAD || true)
126+ fg_version=unknown; tg_version=unknown
127+ if [ -n "$snap" ]; then
128+ msg=$(git log -1 --format=%B "$snap")
129+ fg_version=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Version: //p' | head -n 1)
130+ tg_version=$(printf '%s\n' "$msg" | sed -n 's/^Telegram-Base: .*(update to \([0-9.]*\).*/\1/p' | head -n 1)
131+ fg_version=${fg_version:-unknown}; tg_version=${tg_version:-unknown}
132+ fi
133+
134+ signed=${SIGNED:-false}
135+ prerelease=$PRERELEASE
136+ [ "$signed" = true ] || prerelease=true
137+
138+ notes=$(mktemp)
139+ {
140+ echo "MultiGram $version is Forkgram $fg_version (Telegram $tg_version) with the MultiGram changes listed below."
141+ echo
142+ if [ "$signed" = true ]; then
143+ echo "**Signing:** signed with the MultiGram release key. It installs as an update over earlier MultiGram releases signed with the same key."
144+ else
145+ echo "**Signing: public test key.** This APK is signed with Forkgram's public test key (\`TMessagesProj/config/test.keystore\`), because the repository has no \`MULTIGRAM_KEYSTORE_BASE64\` secret. Anyone can sign an \"update\" for it, and a later release signed with a real key cannot update it. Use it for testing only."
146+ fi
147+ echo
148+ echo "**Version:** $version (Android version code $version_code). Built from $sha."
149+ echo
150+ echo "**MultiGram changes on top of Forkgram $fg_version:**"
151+ echo
152+ if [ -n "$snap" ]; then
153+ git log --reverse --format='- %s (%h)' "$snap..HEAD"
154+ else
155+ echo "- (no Forkgram snapshot found in the history)"
156+ fi
157+ } > "$notes"
158+
159+ {
160+ echo "sha=$sha"
161+ echo "number=$n"
162+ echo "version=$version"
163+ echo "version_code=$version_code"
164+ echo "prerelease=$prerelease"
165+ echo "notes<<MULTIGRAM_NOTES_EOF"
166+ cat "$notes"
167+ echo "MULTIGRAM_NOTES_EOF"
168+ } >> "$GITHUB_OUTPUT"
169+ {
170+ if [ "$DRY_RUN" = true ]; then echo "## MultiGram $version (dry run: nothing built or published)"; else echo "## MultiGram $version"; fi
171+ echo
172+ echo "Pre-release: $prerelease"
173+ echo
174+ cat "$notes"
175+ } >> "$GITHUB_STEP_SUMMARY"
176+ cat "$notes"
177+
178+ build:
179+ name: Build
180+ needs: [check_secrets, prepare]
181+ if: ${{ !inputs.dry_run }}
182+ uses: ./.github/workflows/build.yml
183+ with:
184+ ref: ${{ needs.prepare.outputs.sha }}
185+ apk: true
186+ build_number: ${{ needs.prepare.outputs.number }}
187+ secrets: inherit
188+
189+ publish:
190+ name: Publish the release
191+ needs: [prepare, build]
192+ runs-on: ubuntu-latest
193+ permissions:
194+ contents: write
195+ steps:
196+ - uses: actions/download-artifact@v4
197+ with:
198+ name: multigram-apk
199+ path: apk
200+ - name: Check the APK and publish it
201+ env:
202+ GH_TOKEN: ${{ github.token }}
203+ SHA: ${{ needs.prepare.outputs.sha }}
204+ VERSION: ${{ needs.prepare.outputs.version }}
205+ VERSION_CODE: ${{ needs.prepare.outputs.version_code }}
206+ PRERELEASE: ${{ needs.prepare.outputs.prerelease }}
207+ NOTES: ${{ needs.prepare.outputs.notes }}
208+ run: |
209+ set -euo pipefail
210+ fail() { echo "::error::$1"; exit 1; }
211+ shopt -s nullglob
212+ apks=(apk/*.apk)
213+ [ "${#apks[@]}" -eq 1 ] || fail "Expected one APK in the build artifact, found ${#apks[@]}."
214+ file="MultiGram-$VERSION.apk"
215+ mv "${apks[0]}" "$file"
216+
217+ tools=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -n 1)
218+ badging=$("$tools/aapt2" dump badging "$file" | head -n 1)
219+ echo "$badging"
220+ got_name=$(printf '%s' "$badging" | sed -n "s/.* versionName='\([^']*\)'.*/\1/p")
221+ got_code=$(printf '%s' "$badging" | sed -n "s/.* versionCode='\([^']*\)'.*/\1/p")
222+ [ "$got_name" = "$VERSION" ] || fail "The APK says versionName $got_name, expected $VERSION."
223+ [ "$got_code" = "$VERSION_CODE" ] || fail "The APK says versionCode $got_code, expected $VERSION_CODE."
224+ cert=$("$tools/apksigner" verify --print-certs "$file" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -n 1)
225+ [ -n "$cert" ] || fail "apksigner could not verify the APK's signature."
226+ sha256sum "$file" > SHA256SUMS
227+
228+ {
229+ printf '%s\n' "$NOTES"
230+ echo
231+ echo "**Checksums:** \`$(cut -d' ' -f1 SHA256SUMS)\` ($file, SHA-256). Signing certificate SHA-256: \`$cert\`."
232+ } > notes.md
233+
234+ flags=()
235+ if [ "$PRERELEASE" = true ]; then flags+=(--prerelease); fi
236+ gh release create "$VERSION" "$file" SHA256SUMS \
237+ --repo "$GITHUB_REPOSITORY" \
238+ --target "$SHA" \
239+ --title "MultiGram $VERSION" \
240+ --notes-file notes.md \
241+ "${flags[@]}"
242+ echo "Published https://github.com/$GITHUB_REPOSITORY/releases/tag/$VERSION" | tee -a "$GITHUB_STEP_SUMMARY"
.github/workflows/sync-forkgram.ymlnew file+177
@@ -0,0 +1,177 @@
1+name: Sync with Forkgram
2+run-name: Sync with Forkgram${{ inputs.use_next && ' (use multigram-next)' || '' }}${{ inputs.allow_drop && ' (allow drop)' || '' }}${{ inputs.force && ' (force)' || '' }}
3+
4+# Every 6 hours: snapshot a new Forkgram release onto "forkgram", replay the MultiGram
5+# stack onto it as "multigram-next", compile that, and only if it compiles move "multigram"
6+# to it. Conflicts and failures go to an issue labelled forkgram-sync; multigram stays as
7+# it was. A multigram-next changed by hand is never replaced. See README.md and
8+# scripts/sync-forkgram.sh.
9+on:
10+ schedule:
11+ - cron: '23 */6 * * *'
12+ workflow_dispatch:
13+ inputs:
14+ force:
15+ description: Rebuild multigram-next even without a new Forkgram release (tests the pipeline, or retries after a failed compile)
16+ type: boolean
17+ default: false
18+ use_next:
19+ description: Skip the rebase; compile and promote the multigram-next branch you fixed by hand
20+ type: boolean
21+ default: false
22+ allow_drop:
23+ description: Only with use_next. Accept a multigram-next that leaves out some of multigram's commits (Forkgram has them now, or you dropped them on purpose)
24+ type: boolean
25+ default: false
26+
27+permissions: {}
28+
29+concurrency:
30+ group: forkgram-sync
31+ cancel-in-progress: false
32+
33+jobs:
34+ sync:
35+ name: Snapshot Forkgram and replay the stack
36+ runs-on: ubuntu-latest
37+ timeout-minutes: 60
38+ permissions:
39+ contents: write # push forkgram, multigram-next and refs/multigram-sync/next
40+ issues: read # see whether the report job has anything to do
41+ outputs:
42+ status: ${{ steps.sync.outputs.status }}
43+ message: ${{ steps.sync.outputs.message }}
44+ error: ${{ steps.sync.outputs.error }}
45+ new_snapshot: ${{ steps.sync.outputs.new_snapshot }}
46+ forkgram_commit: ${{ steps.sync.outputs.forkgram_commit }}
47+ forkgram_version: ${{ steps.sync.outputs.forkgram_version }}
48+ snapshot: ${{ steps.sync.outputs.snapshot }}
49+ previous_snapshot: ${{ steps.sync.outputs.previous_snapshot }}
50+ multigram: ${{ steps.sync.outputs.multigram }}
51+ multigram_base: ${{ steps.sync.outputs.multigram_base }}
52+ candidate: ${{ steps.sync.outputs.candidate }}
53+ tag_version: ${{ steps.sync.outputs.tag_version }}
54+ conflict_commit: ${{ steps.sync.outputs.conflict_commit }}
55+ conflict_subject: ${{ steps.sync.outputs.conflict_subject }}
56+ conflict_position: ${{ steps.sync.outputs.conflict_position }}
57+ conflict_files: ${{ steps.sync.outputs.conflict_files }}
58+ conflict_details: ${{ steps.sync.outputs.conflict_details }}
59+ dropped: ${{ steps.sync.outputs.dropped }}
60+ existing_next: ${{ steps.sync.outputs.existing_next }}
61+ forkgram_tip: ${{ steps.sync.outputs.forkgram_tip }}
62+ report: ${{ steps.need.outputs.report }}
63+ steps:
64+ # Blobless: the history and trees of forkgram and multigram come cheap, and file
65+ # contents are fetched only when the rebase needs them.
66+ - uses: actions/checkout@v4
67+ with:
68+ fetch-depth: 1
69+ filter: blob:none
70+ - name: Sync
71+ id: sync
72+ env:
73+ FORCE: ${{ inputs.force }}
74+ USE_NEXT: ${{ inputs.use_next }}
75+ ALLOW_DROP: ${{ inputs.allow_drop }}
76+ run: |
77+ git config user.name 'github-actions[bot]'
78+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
79+ args=(--remote origin --push)
80+ if [ "$FORCE" = true ]; then args+=(--force); fi
81+ if [ "$USE_NEXT" = true ]; then args+=(--use-next); fi
82+ if [ "$ALLOW_DROP" = true ]; then args+=(--allow-drop); fi
83+ bash scripts/sync-forkgram.sh "${args[@]}"
84+ # Most scheduled runs find nothing new. Skip the report job then, since every job
85+ # costs at least a billed minute: it only has work when an issue is open to close
86+ # (up-to-date), or none is open yet (behind).
87+ - name: Check whether the report job has anything to do
88+ id: need
89+ if: steps.sync.outputs.status == 'up-to-date' || steps.sync.outputs.status == 'behind'
90+ env:
91+ GH_TOKEN: ${{ github.token }}
92+ GH_REPO: ${{ github.repository }}
93+ STATUS: ${{ steps.sync.outputs.status }}
94+ run: |
95+ open=$(gh issue list --label forkgram-sync --state open --json number --jq length) || open=unknown
96+ echo "Open forkgram-sync issues: $open"
97+ case "$STATUS:$open" in
98+ up-to-date:0 | behind:[1-9]*) echo report=no >>"$GITHUB_OUTPUT" ;;
99+ *) echo report=yes >>"$GITHUB_OUTPUT" ;;
100+ esac
101+
102+ compile:
103+ name: Compile multigram-next
104+ needs: sync
105+ if: needs.sync.outputs.candidate != ''
106+ permissions:
107+ contents: read
108+ uses: ./.github/workflows/build.yml
109+ with:
110+ ref: ${{ needs.sync.outputs.candidate }}
111+
112+ promote:
113+ name: Move multigram to multigram-next
114+ needs: [sync, compile]
115+ if: needs.compile.result == 'success'
116+ runs-on: ubuntu-latest
117+ timeout-minutes: 15
118+ permissions:
119+ contents: write # move multigram, tag its old tip, delete multigram-next
120+ outputs:
121+ status: ${{ steps.promote.outputs.status }}
122+ tag: ${{ steps.promote.outputs.tag }}
123+ steps:
124+ - uses: actions/checkout@v4
125+ with:
126+ fetch-depth: 1
127+ filter: blob:none
128+ - name: Promote
129+ id: promote
130+ env:
131+ CANDIDATE: ${{ needs.sync.outputs.candidate }}
132+ PREVIOUS: ${{ needs.sync.outputs.multigram }}
133+ VERSION: ${{ needs.sync.outputs.tag_version }}
134+ run: |
135+ bash scripts/sync-forkgram.sh promote --remote origin --shallow \
136+ --candidate "$CANDIDATE" --previous "$PREVIOUS" --version "$VERSION"
137+
138+ report:
139+ name: Report on the forkgram-sync issue
140+ needs: [sync, compile, promote]
141+ if: ${{ !cancelled() && needs.sync.outputs.report != 'no' }}
142+ runs-on: ubuntu-latest
143+ timeout-minutes: 10
144+ permissions:
145+ contents: read # the report script
146+ issues: write # open, comment on and close the forkgram-sync issue
147+ steps:
148+ - uses: actions/checkout@v4
149+ with:
150+ sparse-checkout: scripts
151+ - name: Open, update or close the issue
152+ env:
153+ GH_TOKEN: ${{ github.token }}
154+ GH_REPO: ${{ github.repository }}
155+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
156+ SYNC_RESULT: ${{ needs.sync.result }}
157+ COMPILE_RESULT: ${{ needs.compile.result }}
158+ PROMOTE_RESULT: ${{ needs.promote.result }}
159+ PROMOTE_TAG: ${{ needs.promote.outputs.tag }}
160+ SYNC_STATUS: ${{ needs.sync.outputs.status }}
161+ SYNC_MESSAGE: ${{ needs.sync.outputs.message }}
162+ SYNC_ERROR: ${{ needs.sync.outputs.error }}
163+ SYNC_FORKGRAM_COMMIT: ${{ needs.sync.outputs.forkgram_commit }}
164+ SYNC_FORKGRAM_VERSION: ${{ needs.sync.outputs.forkgram_version }}
165+ SYNC_SNAPSHOT: ${{ needs.sync.outputs.snapshot }}
166+ SYNC_PREVIOUS_SNAPSHOT: ${{ needs.sync.outputs.previous_snapshot }}
167+ SYNC_MULTIGRAM_BASE: ${{ needs.sync.outputs.multigram_base }}
168+ SYNC_CANDIDATE: ${{ needs.sync.outputs.candidate }}
169+ SYNC_CONFLICT_COMMIT: ${{ needs.sync.outputs.conflict_commit }}
170+ SYNC_CONFLICT_SUBJECT: ${{ needs.sync.outputs.conflict_subject }}
171+ SYNC_CONFLICT_POSITION: ${{ needs.sync.outputs.conflict_position }}
172+ SYNC_CONFLICT_FILES: ${{ needs.sync.outputs.conflict_files }}
173+ SYNC_CONFLICT_DETAILS: ${{ needs.sync.outputs.conflict_details }}
174+ SYNC_DROPPED: ${{ needs.sync.outputs.dropped }}
175+ SYNC_EXISTING_NEXT: ${{ needs.sync.outputs.existing_next }}
176+ SYNC_FORKGRAM_TIP: ${{ needs.sync.outputs.forkgram_tip }}
177+ run: python3 scripts/sync-report.py
README.md+130−1
@@ -8,4 +8,133 @@ MultiGram is Forkgram (https://github.com/forkgram/TelegramAndroid) plus a small
88| `multigram` | The app: the latest `forkgram` snapshot plus the MultiGram commits on top. Build this branch. |
99| `main` | This README, the automation that keeps `multigram` in step with Forkgram, and helper scripts. |
1010
11−Setup is in progress.
11+The sync also uses `multigram-next` (the next `multigram`, while it is being checked) and tags
12+the `multigram` it replaces as `multigram-before-<version>`.
13+
14+[PATCH.md](https://github.com/iko-soy/Multigram/blob/docs/PATCH.md) on the `docs` branch says what
15+to do after a Telegram update when the sync needs a hand, and lists every MultiGram change with
16+the exact code, so the stack can be re-applied by hand.
17+
18+## Build
19+
20+**Actions > Build MultiGram > Run workflow**, ref `multigram`. It compiles the Java and Kotlin
21+code (a few minutes). Tick **APK** to also build a release APK; it compiles the native code
22+too, so it takes much longer, and the APK is attached to the run as `multigram-apk`.
23+
24+## Releases
25+
26+**Actions > Release MultiGram > Run workflow** builds the release APK of `multigram` and publishes
27+it on the Releases page (about 80 minutes; tick **dry_run** to see only the version and notes).
28+Versions follow Forkgram's scheme: `12.10.6.1` is the first MultiGram release on Forkgram 12.10.6,
29+`12.10.6.2` the second, and after a Forkgram update the count starts again at 1 (at most 9 per
30+Forkgram version). The tag is the version. It needs the API id and hash secrets below. Without the
31+signing secrets the APK is signed with Forkgram's public test key and published as a pre-release
32+that says so. Details: section 10.6 of
33+[PATCH.md](https://github.com/iko-soy/Multigram/blob/docs/PATCH.md).
34+
35+## Secrets
36+
37+Set these in **Settings > Secrets and variables > Actions**:
38+
39+- `MULTIGRAM_APP_ID` and `MULTIGRAM_APP_HASH`: your own API id and hash from
40+ https://my.telegram.org (API development tools). Without them the APK cannot log in.
41+- Signing (optional): `MULTIGRAM_KEYSTORE_BASE64` (your release keystore, `base64 -w0 release.keystore`),
42+ `MULTIGRAM_KEYSTORE_PASSWORD`, `MULTIGRAM_KEY_ALIAS` and `MULTIGRAM_KEY_PASSWORD` (if it differs
43+ from the store password). Without them the APK is signed with Forkgram's public test key
44+ (`TMessagesProj/config/test.keystore`), which is fine for testing but lets anyone sign an
45+ "update" for it. Keep the same key for every build you give out, or phones can't update. The
46+ rebrand toolkit can make a key for you (see "Signing and updates" in `multigram/rebrand/README.md`).
47+
48+## How the sync works
49+
50+The **Sync with Forkgram** workflow runs every 6 hours (and by hand):
51+
52+1. If Forkgram's `dev` has a new tip (Forkgram rebases `dev` onto every Telegram release, so each
53+ new tip is a release candidate), it adds a snapshot commit to `forkgram`: that tree without
54+ `.github`, with `Forkgram-Commit`, `Forkgram-Version` and `Telegram-Base` lines in the message.
55+2. It replays the MultiGram commits (`forkgram..multigram`) onto the snapshot as `multigram-next`
56+ (`git rebase --onto`).
57+3. It compiles `multigram-next` with the Build MultiGram workflow.
58+4. If that passes, it tags the old `multigram` as `multigram-before-<version>`, moves `multigram`
59+ to `multigram-next`, deletes `multigram-next` and closes the sync issue.
60+5. On a conflict or a failed compile it opens an issue labelled `forkgram-sync` (or comments on the
61+ open one) with the details; `multigram` stays as it was.
62+
63+It never replaces a `multigram-next` that has commits it did not make (your fix, say): it then
64+changes nothing, not even `forkgram`, and says so on the issue until you run **use_next** or
65+delete `multigram-next`. It records the `multigram-next` it built last in the ref
66+`refs/multigram-sync/next`. Keep `multigram` linear (rebase, do not merge): the replay flattens
67+merges, so a merge commit with changes of its own stops the sync.
68+
69+Run it by hand with **force** to rebuild `multigram-next` without a new release (to test the
70+pipeline, or to retry), or with **use_next** to compile and promote a `multigram-next` you fixed
71+yourself. **use_next** checks that `multigram-next` still has every commit on `multigram` (a
72+commit pushed to `multigram` after you started the fix would otherwise be lost); tick
73+**allow_drop** as well if it leaves some out on purpose.
74+
75+The work is done by `scripts/sync-forkgram.sh` (see `--help`). It also runs locally without
76+pushing anything: from a checkout of `main`, `bash scripts/sync-forkgram.sh --remote origin`
77+leaves the results in your local `forkgram` and `multigram-next` branches.
78+`bash scripts/selftest-sync.sh` tests it against fake repositories.
79+
80+rerere (git's memory of conflict fixes) only knows the fixes recorded in the clone it runs in.
81+It helps when you redo a rebase or run the script in your own clone; the scheduled sync starts
82+from a fresh clone every time, so it reports a conflict again until `multigram` carries the fix.
83+
84+## When the sync issue opens
85+
86+The issue has the exact commands; in short:
87+
88+**Conflict.** Replay the stack yourself, fix the files, push the result to `multigram-next`:
89+
90+```sh
91+git fetch origin
92+git switch -C multigram-next origin/multigram
93+git config rerere.enabled true # reuses your fixes if you redo this rebase here
94+git rebase origin/forkgram
95+# fix the files git names, then: git add <files> && git rebase --continue (repeat)
96+git push --force origin multigram-next
97+```
98+
99+**Compile failure.** Fix the build on top of `multigram-next` and fold the fix into the commit it
100+belongs to:
101+
102+```sh
103+git fetch origin
104+git switch -C multigram-next origin/multigram-next
105+# edit, then, once per MultiGram commit your fix touches (name only that commit's files):
106+git commit --fixup=<the MultiGram commit it belongs to> -- <its files>
107+GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/forkgram
108+git push --force origin multigram-next
109+```
110+
111+Then **Actions > Sync with Forkgram > Run workflow** with **use_next** ticked: it compiles
112+`multigram-next` and, if that passes, moves `multigram` to it and closes the issue. If the
113+compile failed because of a runner or network hiccup, **Re-run failed jobs** on the run page
114+is enough. Until then the sync leaves your `multigram-next` alone; if you give up on it, delete
115+it (`git push origin --delete multigram-next`) and the next sync starts over from `multigram`.
116+
117+## Patches
118+
119+`bash scripts/export-patches.sh --base origin/forkgram --head origin/multigram` writes the stack
120+as numbered patches to `multigram-patches/`, with `APPLY.txt` saying how to apply them onto a
121+plain Forkgram checkout (`git am -3`).
122+
123+## What is on the multigram branch
124+
125+- `multigram/rebrand/`: the rebrand toolkit that gives a build its own name, applicationId,
126+ launcher icon and signing key (instructions in its `README.md`).
127+- `multigram/hide-search-bar/`: the "Hide chat list search" option in Fork Client Settings >
128+ Chat list view (off by default), which removes the search bar and search icon from the chat list.
129+- `multigram/`: the other MultiGram tools and notes, such as the per-install random style and
130+ its palette check; the app code is in
131+ `TMessagesProj/src/main/java/org/telegram/messenger/multigram/`.
132+
133+## Repository settings
134+
135+- `main` must be the default branch: scheduled workflows only run from it.
136+- The workflows ask for the token permissions they need, so the default workflow permissions
137+ can stay read-only.
138+- Issues must be enabled: the sync reports on an issue labelled `forkgram-sync`.
139+- If you protect `forkgram`, `multigram`, `multigram-next` or `multigram-before-*` tags with
140+ branch protection or rulesets, let GitHub Actions push and force-push them.
scripts/export-patches.shnew file+86
@@ -0,0 +1,86 @@
1+#!/usr/bin/env bash
2+# Write the MultiGram stack as numbered patches (git format-patch forkgram..multigram) into
3+# a folder, with APPLY.txt saying how to apply them onto a plain Forkgram checkout.
4+#
5+# scripts/export-patches.sh [--base REF] [--head REF] [--force] [FOLDER]
6+#
7+# --base REF the snapshot branch (default: forkgram)
8+# --head REF the app branch (default: multigram)
9+# --force replace the patches of an earlier export in FOLDER
10+# FOLDER where to write them (default: multigram-patches)
11+#
12+# For branches you have not checked out locally, pass --base origin/forkgram
13+# --head origin/multigram after a git fetch.
14+set -euo pipefail
15+
16+die() { printf 'export-patches: %s\n' "$*" >&2; exit 1; }
17+
18+base=forkgram head=multigram force=no dir=multigram-patches
19+while [ $# -gt 0 ]; do
20+ case $1 in
21+ --base) base=${2:?--base needs a ref}; shift 2 ;;
22+ --head) head=${2:?--head needs a ref}; shift 2 ;;
23+ --force) force=yes; shift ;;
24+ -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
25+ -*) die "unknown option: $1 (see --help)" ;;
26+ *) dir=$1; shift ;;
27+ esac
28+done
29+
30+git rev-parse --git-dir >/dev/null 2>&1 || die "run this inside a clone of the MultiGram repository"
31+base_c=$(git rev-parse -q --verify "$base^{commit}") || die "no such ref: $base"
32+head_c=$(git rev-parse -q --verify "$head^{commit}") || die "no such ref: $head"
33+count=$(git rev-list --count "$base_c..$head_c")
34+[ "$count" -gt 0 ] || die "$head has no commits on top of $base"
35+[ -z "$(git rev-list --merges "$base_c..$head_c")" ] ||
36+ die "$head has merge commits on top of $base; git format-patch cannot export them"
37+
38+# The snapshot the stack sits on, and the Forkgram commit it was made from.
39+snap=$(git merge-base "$base_c" "$head_c") || die "$head shares no history with $base"
40+msg=$(git log -1 --format=%B "$snap")
41+fg_commit=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Commit:[[:space:]]*\([0-9a-f]\{40\}\).*/\1/p' | tail -n 1)
42+fg_version=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Version:[[:space:]]*\([0-9A-Za-z._+-]*\).*/\1/p' | tail -n 1)
43+[ -n "$fg_commit" ] || die "$(git rev-parse --short "$snap") (where $head meets $base) is not a Forkgram snapshot"
44+[ -n "$fg_version" ] || fg_version=unknown
45+if [ "$snap" != "$base_c" ]; then
46+ printf 'export-patches: note: %s is based on an older snapshot than %s; the patches are for Forkgram %s\n' \
47+ "$head" "$base" "$fg_version" >&2
48+fi
49+
50+mkdir -p "$dir"
51+if [ -n "$(ls -A "$dir")" ]; then
52+ [ "$force" = yes ] || die "$dir is not empty (use --force to replace an earlier export)"
53+ find "$dir" -mindepth 1 -maxdepth 1 \( -name '[0-9][0-9][0-9][0-9]-*.patch' -o -name APPLY.txt \) -exec rm -f {} +
54+ [ -z "$(ls -A "$dir")" ] || die "$dir holds files this script did not write; pick another folder"
55+fi
56+
57+git format-patch --quiet --full-index --binary -o "$dir" "$base_c..$head_c"
58+abs=$(cd "$dir" && pwd)
59+
60+cat >"$dir/APPLY.txt" <<EOF
61+MultiGram patches
62+=================
63+
64+$count patch(es): the MultiGram commits of $head ($(git rev-parse --short "$head_c")), made on the
65+forkgram snapshot $(git rev-parse --short "$snap"), which is Forkgram $fg_version with the .github folder removed:
66+
67+ Forkgram commit $fg_commit
68+
69+Apply them onto a plain Forkgram checkout of that commit. Forkgram tags its releases
70+<version>.0 (so $fg_version.0 here); if the tag is missing, fetch the commit instead.
71+
72+ git clone --depth 1 --branch $fg_version.0 https://github.com/forkgram/TelegramAndroid.git
73+ cd TelegramAndroid
74+ git rev-parse HEAD # should print $fg_commit
75+ git switch -c multigram
76+ git am -3 $abs/*.patch
77+
78+-3 lets git fall back to a three-way merge when a patch does not apply as it is.
79+If git am still stops, it names the patch and the files: fix them, "git add" them and
80+run "git am --continue" (or "git am --skip" to leave that patch out, "git am --abort"
81+to go back). The same commands work on a newer Forkgram release (expect more stops);
82+clone without --depth 1 then, so -3 can find the older versions of the files.
83+EOF
84+
85+printf '%s\n' "Wrote $count patch(es) and APPLY.txt to $abs"
86+sed -n '/^ git clone/,/^ git am/p' "$dir/APPLY.txt"
scripts/selftest-sync.shnew file+646
@@ -0,0 +1,646 @@
1+#!/usr/bin/env bash
2+# Self-test for sync-forkgram.sh and export-patches.sh. Builds a fake Forkgram remote and a
3+# fake MultiGram remote in a temporary folder (nothing touches GitHub or your clone) and
4+# runs the sync through its cases: no new release, --force, a release that applies, a
5+# release that conflicts, the hand fix (rerere, a multigram-next the sync must not replace,
6+# --use-next and the check that it carries all of multigram), promote, the .github guard,
7+# a missing multigram branch, local branches that must not move, merge commits, a full
8+# clone that must stay full, and the patch export.
9+#
10+# bash scripts/selftest-sync.sh [--keep] (--keep leaves the folder for a look)
11+set -euo pipefail
12+
13+HERE=$(cd "$(dirname "$0")" && pwd)
14+SYNC=$HERE/sync-forkgram.sh
15+EXPORT=$HERE/export-patches.sh
16+T=$(mktemp -d "${TMPDIR:-/tmp}/multigram-selftest.XXXXXX")
17+KEEP=no
18+[ "${1:-}" != --keep ] || KEEP=yes
19+trap '[ $KEEP = yes ] && echo "left in $T" || rm -rf "$T"' EXIT
20+
21+# A clean git setup, whatever the user's own config says.
22+export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=$T/gitconfig
23+git config --global user.name "Self Test"
24+git config --global user.email selftest@example.invalid
25+git config --global init.defaultBranch main
26+git config --global advice.detachedHead false
27+unset GITHUB_ACTIONS GITHUB_OUTPUT GITHUB_STEP_SUMMARY RUNNER_TEMP FORKGRAM_URL FORKGRAM_BRANCH || true
28+export FORKGRAM_URL=file://$T/forkgram.git FORKGRAM_BRANCH=dev
29+
30+PASS=0
31+FAIL=0
32+ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; }
33+bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n' "$1"; }
34+eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: got [$2], want [$3]"; fi; }
35+has() { case $2 in *"$3"*) ok "$1" ;; *) bad "$1: [$3] not in [$2]" ;; esac; }
36+title() { printf '\n== %s\n' "$*"; }
37+
38+# Runs the sync script; keeps its exit code in RC and its outputs in $T/out.
39+run() { # dir args...
40+ local d=$1
41+ shift
42+ : >"$T/out"
43+ RC=0
44+ (cd "$d" && GITHUB_OUTPUT=$T/out bash "$SYNC" "$@") >"$T/log" 2>&1 || RC=$?
45+ sed -e '/^---- outputs ----$/,$d' -e 's/^/ | /' "$T/log"
46+}
47+o() { # key -> value from the last run's outputs
48+ python3 - "$T/out" "$1" <<'PY'
49+import sys
50+path, key = sys.argv[1], sys.argv[2]
51+lines = open(path).read().split('\n')
52+vals, i = {}, 0
53+while i < len(lines):
54+ line = lines[i]
55+ if '<<' in line:
56+ k, d = line.split('<<', 1)
57+ j = lines.index(d, i + 1)
58+ vals[k] = '\n'.join(lines[i + 1:j])
59+ i = j + 1
60+ else:
61+ i += 1
62+print(vals.get(key, ''), end='')
63+PY
64+}
65+rref() { git --git-dir="$T/multigram.git" rev-parse -q --verify "$1" || true; }
66+
67+# ------------------------------------------------------------------ fake Forkgram
68+
69+git init -q --bare "$T/forkgram.git"
70+git --git-dir="$T/forkgram.git" config uploadpack.allowFilter true
71+git --git-dir="$T/forkgram.git" config uploadpack.allowAnySHA1InWant true
72+git init -q "$T/fg"
73+cd "$T/fg"
74+
75+main_java() { # name line, tail line
76+ printf 'class Main {\n String name = "%s";\n int a = 1;\n int b = 2;\n int c = 3;\n int d = 4;\n // %s\n}\n' "$1" "$2"
77+}
78+props() { printf 'APP_VERSION_CODE=%s\nAPP_VERSION_NAME=%s\nAPP_PACKAGE=org.example\n' "$1" "$2"; }
79+gitlink() { git update-index --add --cacheinfo "160000,$1,$2"; }
80+subst() { sed "$1" "$2" >"$2.new" && mv "$2.new" "$2"; } # sed -i, on any sed
81+setver() { subst "s/^APP_VERSION_NAME=.*/APP_VERSION_NAME=$1/" gradle.properties; }
82+upstream() { # version code name tail sublink : a Telegram "update to" commit
83+ mkdir -p app
84+ props "$2" "$1" >gradle.properties
85+ main_java "$3" "$4" >app/Main.java
86+ printf 'class Util {}\n' >app/Util.java
87+ printf '[submodule "lib/sub"]\n\tpath = lib/sub\n\turl = https://example.invalid/sub.git\n[submodule "topsub"]\n\tpath = topsub\n\turl = https://example.invalid/top.git\n' >.gitmodules
88+ git add -A
89+ gitlink "$5" lib/sub
90+ gitlink 2222222222222222222222222222222222222222 topsub
91+ git commit -q -m "update to $1 ($2)"
92+}
93+fork_commits() { # version : Forkgram's own commits, then the release commit
94+ mkdir -p .github/workflows
95+ printf 'on: push\n' >.github/workflows/ci.yml
96+ printf 'class Fork {}\n' >app/Fork.java
97+ git add -A
98+ git commit -q -m "Added fork settings."
99+ setver "$1"
100+ printf '// release %s\n' "$1" >>app/Fork.java
101+ git commit -q -am "[Release] $1."
102+}
103+
104+upstream 1.0.0 100 Fork "end" 1111111111111111111111111111111111111111
105+fork_commits 1.0.1
106+git push -q "$T/forkgram.git" HEAD:refs/heads/dev
107+R1=$(git rev-parse HEAD)
108+U1=$(git rev-parse HEAD~2)
109+
110+# ------------------------------------------------------------------ fake MultiGram
111+
112+git init -q --bare "$T/multigram.git"
113+git --git-dir="$T/multigram.git" config uploadpack.allowFilter true
114+git --git-dir="$T/multigram.git" config uploadpack.allowAnySHA1InWant true
115+git init -q "$T/mgsetup"
116+cd "$T/mgsetup"
117+git fetch -q "$T/forkgram.git" dev
118+tree=$(git ls-tree FETCH_HEAD | grep -v " .github$" | git mktree)
119+S1=$(printf 'Forkgram 1.0.1 snapshot\n\nSource: https://github.com/forkgram/TelegramAndroid/commit/%s\nForkgram-Commit: %s\nForkgram-Version: 1.0.1\nTelegram-Base: %s (update to 1.0.0)\n\nTree is Forkgram'"'"'s dev branch at that commit with the .github folder removed.\n' "$R1" "$R1" "$U1" | git commit-tree "$tree")
120+git switch -q -c multigram "$S1"
121+mkdir -p multigram
122+printf 'MultiGram notes\n' >multigram/README.md
123+git add -A && git commit -q -m "Add MultiGram notes"
124+subst 's/"Fork"/"MultiGram"/' app/Main.java
125+git commit -q -am "Rename the app"
126+printf 'class Util { /* MultiGram: hook */ }\n' >app/Util.java
127+git commit -q -am "Add a MultiGram hook"
128+git switch -q --orphan main
129+printf 'main\n' >README.md
130+git add README.md && git commit -q -m "Start main"
131+git push -q "$T/multigram.git" main "$S1:refs/heads/forkgram" multigram
132+MG0=$(rref multigram)
133+
134+# "CI": a blobless, depth-1 clone of main, like actions/checkout with filter: blob:none.
135+ci_clone() {
136+ rm -rf "$T/ci"
137+ git clone -q --filter=blob:none --depth 1 --branch main "file://$T/multigram.git" "$T/ci"
138+ git -C "$T/ci" config user.name "github-actions[bot]"
139+ git -C "$T/ci" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
140+}
141+
142+# ------------------------------------------------------------------ A: no new release
143+title "A. No new Forkgram release"
144+ci_clone
145+run "$T/ci" --remote origin --push
146+eq "exit code" "$RC" 0
147+eq "status" "$(o status)" up-to-date
148+eq "new_snapshot" "$(o new_snapshot)" no
149+eq "forkgram unchanged" "$(rref forkgram)" "$S1"
150+eq "no multigram-next" "$(rref multigram-next)" ""
151+
152+# ------------------------------------------------------------------ B: --force
153+title "B. --force with no new release, then promote"
154+ci_clone
155+run "$T/ci" --remote origin --push --force
156+eq "exit code" "$RC" 0
157+eq "status" "$(o status)" candidate
158+eq "new_snapshot" "$(o new_snapshot)" no
159+eq "candidate is multigram (nothing to replay)" "$(o candidate)" "$MG0"
160+eq "multigram-next pushed" "$(rref multigram-next)" "$MG0"
161+eq "pushed" "$(o pushed)" multigram-next
162+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
163+ci_clone
164+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
165+eq "promote exit code" "$RC" 0
166+eq "promote status" "$(o status)" unchanged
167+eq "multigram unchanged" "$(rref multigram)" "$MG0"
168+eq "multigram-next deleted" "$(rref multigram-next)" ""
169+
170+# ------------------------------------------------------------------ C: clean release
171+title "C. New Forkgram release 1.1.0 that applies cleanly"
172+cd "$T/fg"
173+git reset -q --hard "$U1"
174+printf 'class Other {}\n' >app/Other.java
175+git add -A
176+upstream 1.1.0 110 Fork "end, 1.1.0" 3333333333333333333333333333333333333333
177+U2=$(git rev-parse HEAD)
178+fork_commits 1.1.0
179+R2=$(git rev-parse HEAD)
180+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
181+ci_clone
182+run "$T/ci" --remote origin --push
183+eq "exit code" "$RC" 0
184+eq "status" "$(o status)" candidate
185+eq "new_snapshot" "$(o new_snapshot)" yes
186+eq "forkgram_commit" "$(o forkgram_commit)" "$R2"
187+eq "forkgram_version" "$(o forkgram_version)" 1.1.0
188+eq "telegram_base" "$(o telegram_base)" "$U2 (update to 1.1.0)"
189+eq "previous_version" "$(o previous_version)" 1.0.1
190+S2=$(rref forkgram)
191+eq "forkgram pushed" "$S2" "$(o snapshot)"
192+eq "snapshot parent is the previous snapshot" "$(git --git-dir="$T/multigram.git" rev-parse "$S2^")" "$S1"
193+eq "snapshot has no .github" "$(git --git-dir="$T/multigram.git" ls-tree --name-only "$S2" .github)" ""
194+want=$(git -C "$T/fg" ls-tree "$R2" | grep -v " .github$")
195+eq "snapshot tree = Forkgram tree minus .github" "$(git --git-dir="$T/multigram.git" ls-tree "$S2")" "$want"
196+eq "gitlink lib/sub kept" "$(git --git-dir="$T/multigram.git" ls-tree "$S2" lib/sub | cut -f1)" "160000 commit 3333333333333333333333333333333333333333"
197+eq "gitlink topsub kept" "$(git --git-dir="$T/multigram.git" ls-tree "$S2" topsub | cut -f1)" "160000 commit 2222222222222222222222222222222222222222"
198+msg=$(git --git-dir="$T/multigram.git" log -1 --format=%B "$S2")
199+has "message title" "$msg" "Forkgram 1.1.0 snapshot"
200+has "message Source" "$msg" "Source: file://$T/forkgram.git (dev) at $R2"
201+has "message Forkgram-Commit" "$msg" "Forkgram-Commit: $R2"
202+has "message Forkgram-Version" "$msg" "Forkgram-Version: 1.1.0"
203+has "message Telegram-Base" "$msg" "Telegram-Base: $U2 (update to 1.1.0)"
204+has "message tail" "$msg" "Tree is Forkgram's dev branch at that commit with the .github folder removed."
205+C1=$(rref multigram-next)
206+eq "multigram-next = candidate" "$C1" "$(o candidate)"
207+eq "candidate has 3 commits on the snapshot" "$(git --git-dir="$T/multigram.git" rev-list --count "$S2..$C1")" 3
208+eq "candidate subjects" "$(git --git-dir="$T/multigram.git" log --reverse --format=%s "$S2..$C1" | tr '\n' '|')" "Add MultiGram notes|Rename the app|Add a MultiGram hook|"
209+eq "candidate Main.java: stack change on the new upstream" "$(git --git-dir="$T/multigram.git" show "$C1:app/Main.java" | sed -n '2p;7p' | tr '\n' '|')" ' String name = "MultiGram";| // end, 1.1.0|'
210+eq "candidate keeps upstream's new file" "$(git --git-dir="$T/multigram.git" show "$C1:app/Other.java")" "class Other {}"
211+eq "candidate tree = snapshot + stack diff" \
212+ "$(git --git-dir="$T/multigram.git" diff --stat "$S2" "$C1" | tail -n1)" \
213+ "$(git --git-dir="$T/multigram.git" diff --stat "$S1" "$MG0" | tail -n1)"
214+eq "multigram not moved yet" "$(rref multigram)" "$MG0"
215+eq "the sync recorded the multigram-next it built" "$(rref refs/multigram-sync/next)" "$C1"
216+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
217+ci_clone
218+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
219+eq "promote exit code" "$RC" 0
220+eq "promote status" "$(o status)" promoted
221+eq "multigram moved to the candidate" "$(rref multigram)" "$C1"
222+eq "old multigram tagged" "$(rref refs/tags/multigram-before-1.1.0)" "$MG0"
223+eq "multigram-next deleted" "$(rref multigram-next)" ""
224+
225+# ------------------------------------------------------------------ D: conflicting release
226+title "D. New Forkgram release 1.2.0 that conflicts"
227+cd "$T/fg"
228+git reset -q --hard "$U2"
229+upstream 1.2.0 120 "Fork 2" "end, 1.1.0" 3333333333333333333333333333333333333333
230+fork_commits 1.2.0
231+R3=$(git rev-parse HEAD)
232+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
233+MG1=$(rref multigram)
234+ci_clone
235+run "$T/ci" --remote origin --push
236+eq "exit code" "$RC" 2
237+eq "status" "$(o status)" conflict
238+S3=$(rref forkgram)
239+eq "snapshot pushed anyway" "$S3" "$(o snapshot)"
240+eq "forkgram_version" "$(o forkgram_version)" 1.2.0
241+eq "conflict commit is the rename" "$(o conflict_commit)" "$(git --git-dir="$T/multigram.git" rev-parse "$MG1~1")"
242+eq "conflict subject" "$(o conflict_subject)" "Rename the app"
243+eq "conflict position" "$(o conflict_position)" "2 of 3"
244+eq "conflict files" "$(o conflict_files)" app/Main.java
245+eq "conflict details" "$(o conflict_details)" "changed on both sides: app/Main.java"
246+eq "multigram untouched" "$(rref multigram)" "$MG1"
247+eq "no multigram-next" "$(rref multigram-next)" ""
248+eq "no candidate" "$(o candidate)" ""
249+eq "pushed" "$(o pushed)" forkgram
250+
251+# ------------------------------------------------------------------ E: behind
252+title "E. Next run, no new release: multigram is behind"
253+ci_clone
254+run "$T/ci" --remote origin --push
255+eq "exit code" "$RC" 0
256+eq "status" "$(o status)" behind
257+eq "multigram_base is the old snapshot" "$(o multigram_base)" "$S2"
258+eq "nothing pushed" "$(o pushed)" ""
259+
260+# ------------------------------------------------------------------ F: hand fix with rerere
261+title "F. Hand fix in a local clone; rerere replays it on the next local run"
262+git clone -q "file://$T/multigram.git" "$T/owner"
263+cd "$T/owner"
264+git config rerere.enabled true
265+git switch -q -C multigram-next origin/multigram
266+if git rebase -q --onto "$S3" "$S2" >/dev/null 2>&1; then bad "hand rebase should conflict"; fi
267+main_java "MultiGram 2" "end, 1.1.0" >app/Main.java
268+git add app/Main.java
269+GIT_EDITOR=true git rebase --continue >/dev/null 2>&1
270+FIX=$(git rev-parse HEAD)
271+eq "hand fix has 3 commits on the new snapshot" "$(git rev-list --count "$S3..$FIX")" 3
272+git switch -q --detach
273+git branch -q -D multigram-next
274+# The owner's own branches, with rebase.updateRefs on: the replay must not move them.
275+git config rebase.updateRefs true
276+git branch -q -f multigram origin/multigram
277+git branch -q -f wip origin/multigram~1
278+run "$T/owner" --remote origin --force
279+git config --unset rebase.updateRefs
280+eq "exit code" "$RC" 0
281+eq "status" "$(o status)" candidate
282+eq "local multigram not moved (rebase.updateRefs)" "$(git rev-parse multigram)" "$MG1"
283+eq "local wip not moved (rebase.updateRefs)" "$(git rev-parse wip)" "$(git rev-parse "$MG1~1")"
284+eq "local record = local multigram-next" "$(git rev-parse refs/multigram-sync/next)" "$(git rev-parse multigram-next)"
285+has "rerere_resolved names the file" "$(o rerere_resolved)" "app/Main.java"
286+eq "local multigram-next has the recorded resolution" "$(git show multigram-next:app/Main.java | sed -n 2p)" ' String name = "MultiGram 2";'
287+eq "same tree as the hand fix" "$(git rev-parse "multigram-next^{tree}")" "$(git rev-parse "$FIX^{tree}")"
288+eq "local forkgram = latest snapshot" "$(git rev-parse forkgram)" "$S3"
289+eq "nothing pushed without --push" "$(rref multigram-next)" ""
290+
291+# ------------------------------------------------------------------ G: --use-next
292+title "G. The hand fix on multigram-next: never replaced; --use-next checks it, then promote"
293+git push -q -f origin "$FIX:refs/heads/multigram-next"
294+ci_clone
295+run "$T/ci" --remote origin --push --force
296+eq "--force: exit code" "$RC" 0
297+eq "--force: status" "$(o status)" next-edited
298+eq "--force: existing_next" "$(o existing_next)" "$FIX"
299+eq "--force: multigram-next kept" "$(rref multigram-next)" "$FIX"
300+eq "--force: nothing pushed" "$(o pushed)" ""
301+cd "$T/fg"
302+setver 1.2.1
303+git commit -q -am "[Release] 1.2.1."
304+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
305+ci_clone
306+run "$T/ci" --remote origin --push
307+eq "new release: status" "$(o status)" next-edited
308+eq "new release: forkgram_tip is the new commit" "$(o forkgram_tip)" "$(git -C "$T/fg" rev-parse HEAD)"
309+eq "new release: no new snapshot" "$(rref forkgram)" "$S3"
310+eq "new release: multigram-next kept" "$(rref multigram-next)" "$FIX"
311+has "new release: message says why" "$(o message)" "has commits the sync did not make"
312+git reset -q --hard "$R3"
313+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
314+cd "$T/owner"
315+git fetch -q origin
316+git switch -q -C late origin/multigram
317+printf 'late\n' >app/Late.java
318+git add -A && GIT_AUTHOR_DATE="2030-01-01T00:00:00Z" git commit -q -m "Late MultiGram commit"
319+LATE=$(git rev-parse HEAD)
320+git push -q origin late:multigram
321+git switch -q --detach
322+git branch -q -D late
323+ci_clone
324+run "$T/ci" --remote origin --push --use-next
325+eq "late commit: exit code" "$RC" 1
326+eq "late commit: status" "$(o status)" error
327+eq "late commit: missing" "$(o missing)" "$LATE Late MultiGram commit"
328+has "late commit: error names it" "$(o error)" "Late MultiGram commit"
329+has "late commit: error offers allow_drop" "$(o error)" "allow_drop"
330+ci_clone
331+run "$T/ci" --remote origin --push --use-next --allow-drop
332+eq "--allow-drop: status" "$(o status)" candidate
333+eq "--allow-drop: dropped" "$(o dropped)" "$LATE Late MultiGram commit"
334+ci_clone
335+run "$T/ci" --remote origin --push --allow-drop
336+eq "--allow-drop without --use-next: exit code" "$RC" 1
337+git -C "$T/owner" push -q -f origin "$MG1:refs/heads/multigram"
338+ci_clone
339+run "$T/ci" --remote origin --push --use-next
340+eq "exit code" "$RC" 0
341+eq "status" "$(o status)" candidate
342+eq "candidate is the hand fix" "$(o candidate)" "$FIX"
343+eq "the hand fix carries the whole stack" "$(o missing)$(o dropped)" ""
344+eq "forkgram_version" "$(o forkgram_version)" 1.2.0
345+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
346+ci_clone
347+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
348+eq "promote status" "$(o status)" promoted
349+eq "multigram = hand fix" "$(rref multigram)" "$FIX"
350+eq "tag" "$(rref refs/tags/multigram-before-1.2.0)" "$MG1"
351+ci_clone
352+run "$T/ci" --remote origin --push
353+eq "and now up to date" "$(o status)" up-to-date
354+
355+# ------------------------------------------------------------------ H: dropped commit
356+title "H. Release 1.3.0 already has one MultiGram change: that commit is dropped"
357+cd "$T/fg"
358+git reset -q --hard "$R3"
359+mkdir -p multigram && printf 'MultiGram notes\n' >multigram/README.md
360+git add -A && git commit -q -m "Take the notes file"
361+setver 1.3.0
362+git commit -q -am "[Release] 1.3.0."
363+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
364+ci_clone
365+run "$T/ci" --remote origin --push
366+eq "status" "$(o status)" candidate
367+eq "telegram_base still found" "$(o telegram_base)" "$(git -C "$T/fg" rev-parse "HEAD~4") (update to 1.2.0)"
368+eq "stack_count" "$(o stack_count)" 3
369+eq "candidate_count" "$(o candidate_count)" 2
370+if [ -n "$(o dropped)" ]; then ok "dropped is reported"; else bad "dropped is empty"; fi
371+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
372+
373+# ------------------------------------------------------------------ I: promote lease
374+title "I. Promote refuses when multigram moved after the sync"
375+cd "$T/owner"
376+git fetch -q origin
377+git commit -q --allow-empty -m "Someone else's commit" && git push -q -f origin "HEAD:refs/heads/multigram"
378+ci_clone
379+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
380+eq "exit code" "$RC" 1
381+eq "status" "$(o status)" error
382+has "error says why" "$(o error)" "pushed to meanwhile"
383+eq "multigram-next kept" "$(rref multigram-next)" "$cand"
384+git push -q -f origin "$prev:refs/heads/multigram"
385+ci_clone
386+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
387+eq "promote after restoring multigram" "$(o status)" promoted
388+
389+# ------------------------------------------------------------------ J: .github guard
390+title "J. A stack commit that adds .github is refused"
391+cd "$T/owner"
392+git fetch -q origin
393+git switch -q -C multigram origin/multigram
394+mkdir -p .github && printf 'x\n' >.github/extra.yml
395+git add -A && git commit -q -m "Add a workflow file"
396+git push -q -f origin multigram
397+BAD=$(git rev-parse HEAD)
398+F_BEFORE=$(rref forkgram)
399+ci_clone
400+run "$T/ci" --remote origin --push --force
401+eq "exit code" "$RC" 1
402+eq "status" "$(o status)" error
403+has "error names .github" "$(o error)" ".github"
404+eq "nothing pushed" "$(rref multigram-next)" ""
405+eq "forkgram unchanged" "$(rref forkgram)" "$F_BEFORE"
406+git push -q -f origin "$BAD~1:refs/heads/multigram"
407+git switch -q --detach
408+
409+# ------------------------------------------------------------------ K: guards
410+title "K. Guards: branch checked out, no multigram branch"
411+git switch -q -C forkgram origin/forkgram
412+run "$T/owner" --force
413+eq "checked-out forkgram: exit code" "$RC" 1
414+has "checked-out forkgram: error" "$(o error)" "checked out in a worktree"
415+git switch -q --detach
416+git init -q --bare "$T/empty.git"
417+git -C "$T/owner" push -q "$T/empty.git" "origin/forkgram:refs/heads/forkgram"
418+git -C "$T/owner" remote add empty "$T/empty.git"
419+run "$T/owner" --remote empty --push
420+eq "no multigram: exit code" "$RC" 1
421+has "no multigram: error" "$(o error)" "has no multigram branch yet"
422+run "$T/owner" --remote empty --use-next --force
423+eq "--force with --use-next: exit code" "$RC" 1
424+git fetch -q origin
425+UNPUSHED=$(git commit-tree -p origin/multigram -m "Unpushed fix" "origin/multigram^{tree}")
426+git branch -q -f multigram-next "$UNPUSHED"
427+F_BEFORE=$(git rev-parse forkgram)
428+run "$T/owner" --remote origin --force
429+eq "unpushed local multigram-next: exit code" "$RC" 1
430+has "unpushed local multigram-next: error" "$(o error)" "on no other branch or tag"
431+eq "unpushed local multigram-next: not moved" "$(git rev-parse multigram-next)" "$UNPUSHED"
432+eq "unpushed local multigram-next: local forkgram not moved" "$(git rev-parse forkgram)" "$F_BEFORE"
433+git branch -q -D multigram-next
434+
435+# ------------------------------------------------------------------ L: local mode
436+title "L. Local branches, no remote"
437+cd "$T/owner"
438+git fetch -q origin
439+git branch -q -f forkgram origin/forkgram
440+git branch -q -f multigram origin/multigram
441+run "$T/owner"
442+eq "status" "$(o status)" up-to-date
443+HAND=$(git commit-tree -p multigram -m "Hand fix" "multigram^{tree}")
444+git branch -q -f multigram-next "$HAND"
445+run "$T/owner" --force
446+eq "hand-made local multigram-next: exit code" "$RC" 0
447+eq "hand-made local multigram-next: status" "$(o status)" next-edited
448+eq "hand-made local multigram-next: kept" "$(git rev-parse multigram-next)" "$HAND"
449+git branch -q -D multigram-next
450+
451+# ------------------------------------------------------------------ M: export-patches
452+title "M. export-patches.sh, applied onto a plain Forkgram checkout with git am -3"
453+cd "$T/owner"
454+EXP_RC=0
455+bash "$EXPORT" --base origin/forkgram --head origin/multigram "$T/patches" >"$T/log" 2>&1 || EXP_RC=$?
456+sed 's/^/ | /' "$T/log" | head -n 8
457+eq "export exit code" "$EXP_RC" 0
458+eq "patch files" "$(cd "$T/patches" && ls ./*.patch | wc -l | tr -d ' ')" 2
459+if [ -f "$T/patches/APPLY.txt" ]; then ok "APPLY.txt written"; else bad "APPLY.txt missing"; fi
460+has "APPLY.txt names the Forkgram commit" "$(cat "$T/patches/APPLY.txt")" "$(git -C "$T/fg" rev-parse HEAD)"
461+git clone -q -b dev "$T/forkgram.git" "$T/plain"
462+cd "$T/plain"
463+git switch -q -c multigram
464+if git am -q -3 "$T/patches"/*.patch >/dev/null 2>&1; then ok "git am -3 applies"; else bad "git am -3 failed"; fi
465+eq "result = multigram + Forkgram's .github" \
466+ "$(git ls-tree -r HEAD | grep -v ' .github/')" \
467+ "$(git -C "$T/owner" ls-tree -r origin/multigram)"
468+EXP_RC=0
469+bash "$EXPORT" --base origin/forkgram --head origin/multigram "$T/patches" >/dev/null 2>&1 || EXP_RC=$?
470+eq "refuses a non-empty folder without --force" "$EXP_RC" 1
471+
472+# ------------------------------------------------------------------ N: Forkgram already fetched
473+title "N. Forkgram's tip already in the local clone; a history too short for Telegram-Base"
474+cd "$T/fg"
475+printf 'class Later {}\n' >app/Later.java
476+git add -A && git commit -q -m "Added a later feature."
477+setver 1.5.0
478+git commit -q -am "[Release] 1.5.0."
479+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
480+R5=$(git rev-parse HEAD)
481+git clone -q "file://$T/multigram.git" "$T/full"
482+run "$T/full" --remote origin
483+eq "full clone: status" "$(o status)" candidate
484+has "full clone: fetched into a scratch repository" "$(cat "$T/log")" "into a scratch repository"
485+eq "full clone: still not shallow" "$(git -C "$T/full" rev-parse --is-shallow-repository)" false
486+eq "full clone: snapshot tree" "$(git -C "$T/full" ls-tree forkgram)" "$(git -C "$T/fg" ls-tree "$R5" | grep -v " .github$")"
487+eq "full clone: version from gradle.properties" "$(o forkgram_version)" 1.5.0
488+if git -C "$T/full" fsck --no-progress >"$T/fsck" 2>&1; then ok "full clone: fsck is clean"; else bad "full clone: fsck: $(head -n 3 "$T/fsck")"; fi
489+cd "$T/owner"
490+git fetch -q "$T/forkgram.git" "+refs/heads/dev:refs/forkgram-full/dev"
491+git fetch -q origin
492+git branch -q -f forkgram origin/forkgram
493+git branch -q -f multigram origin/multigram
494+FORKGRAM_HISTORY_DEPTH=1 run "$T/owner" --remote origin
495+eq "status" "$(o status)" candidate
496+has "used the local copy" "$(cat "$T/log")" "already here"
497+eq "no Telegram-Base when the history is too short" "$(o telegram_base)" ""
498+if git log -1 --format=%B forkgram | grep -q '^Telegram-Base:'; then bad "Telegram-Base line written"; else ok "no Telegram-Base line"; fi
499+eq "snapshot tree" "$(git ls-tree forkgram)" "$(git ls-tree "$R5" | grep -v " .github$")"
500+eq "local forkgram is the new snapshot, origin untouched" "$(git rev-parse forkgram^)" "$(rref forkgram)"
501+eq "not a shallow repository" "$(git rev-parse --is-shallow-repository)" false
502+
503+# ------------------------------------------------------------------ O: tag already taken
504+title "O. A second promotion for the same Forkgram version gets its own tag"
505+cd "$T/owner"
506+git fetch -q origin
507+git switch -q -C multigram-next origin/multigram
508+printf 'more\n' >>multigram/README.md 2>/dev/null || { mkdir -p multigram; printf 'more\n' >multigram/README.md; }
509+git add -A && git commit -q -m "Another MultiGram change"
510+git push -q -f origin multigram-next
511+git switch -q --detach
512+ci_clone
513+run "$T/ci" --remote origin --push --use-next
514+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
515+eq "same version as the last promotion" "$tv" 1.3.0
516+ci_clone
517+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
518+eq "status" "$(o status)" promoted
519+eq "tag with a suffix" "$(o tag)" multigram-before-1.3.0-2
520+eq "tag points at the old multigram" "$(rref refs/tags/multigram-before-1.3.0-2)" "$prev"
521+eq "first tag untouched" "$(rref refs/tags/multigram-before-1.3.0)" "$FIX"
522+
523+# ------------------------------------------------------------------ Q: merge commits
524+title "Q. Merge commits on multigram: a plain merge is flattened, one with its own changes stops the sync"
525+cd "$T/owner"
526+git fetch -q origin
527+MGQ=$(git rev-parse origin/multigram)
528+git switch -q -C side origin/forkgram
529+printf 'class Side {}\n' >app/Side.java
530+git add -A && git commit -q -m "Side feature"
531+git switch -q -C multigram "$MGQ"
532+git merge -q --no-ff --no-edit side
533+git push -q -f origin multigram
534+run "$T/owner" --remote origin --force
535+eq "plain merge: exit code" "$RC" 0
536+eq "plain merge: status" "$(o status)" candidate
537+has "plain merge: warned" "$(cat "$T/log")" "only join their parents"
538+eq "plain merge: candidate = snapshot + everything multigram changes" \
539+ "$(git diff "$(o snapshot)" multigram-next -- | grep -v '^index ')" "$(git diff origin/forkgram multigram -- | grep -v '^index ')"
540+eq "plain merge: candidate is linear" "$(git rev-list --merges "origin/forkgram..multigram-next")" ""
541+git reset -q --hard "$MGQ"
542+git merge -q --no-ff --no-commit side
543+printf 'class Glue {}\n' >app/Glue.java
544+git add -A && git commit -q -m "Merge side with glue"
545+git push -q -f origin multigram
546+run "$T/owner" --remote origin --force
547+eq "merge with changes: exit code" "$RC" 1
548+has "merge with changes: error names it" "$(o error)" "Merge side with glue"
549+git push -q -f origin "$MGQ:refs/heads/multigram"
550+git switch -q --detach
551+git branch -q -D side multigram
552+
553+# ------------------------------------------------------------------ P: sync-report.py
554+title "P. sync-report.py against a fake gh"
555+mkdir -p "$T/bin"
556+cat >"$T/bin/gh" <<'PY'
557+#!/usr/bin/env python3
558+# A tiny stand-in for the gh CLI: keeps issues in a JSON file.
559+import json, os, sys
560+path = os.environ['FAKE_GH_STATE']
561+st = json.load(open(path)) if os.path.exists(path) else {'issues': [], 'labels': []}
562+a = sys.argv[1:]
563+def opt(name):
564+ return a[a.index(name) + 1] if name in a else None
565+def body():
566+ f = opt('--body-file')
567+ return open(f).read() if f else ''
568+def issue(n):
569+ return next(i for i in st['issues'] if i['number'] == int(n))
570+if a[:2] == ['label', 'create']:
571+ st['labels'].append(a[2])
572+elif a[:2] == ['issue', 'list']:
573+ print(json.dumps([{'number': i['number'], 'title': i['title']} for i in st['issues']
574+ if i['state'] == 'open' and opt('--label') in i['labels']]))
575+elif a[:2] == ['issue', 'view']:
576+ i = issue(a[2])
577+ print(json.dumps({'body': i['body'], 'comments': [{'body': c} for c in i['comments']]}))
578+elif a[:2] == ['issue', 'create']:
579+ if opt('--label') not in st['labels']:
580+ sys.exit('label missing')
581+ n = len(st['issues']) + 1
582+ st['issues'].append({'number': n, 'title': opt('--title'), 'body': body(), 'labels': [opt('--label')],
583+ 'state': 'open', 'comments': []})
584+ print(f'https://example.invalid/issues/{n}')
585+elif a[:2] == ['issue', 'comment']:
586+ issue(a[2])['comments'].append(body())
587+elif a[:2] == ['issue', 'edit']:
588+ issue(a[2])['title'] = opt('--title')
589+elif a[:2] == ['issue', 'close']:
590+ i = issue(a[2]); i['state'] = 'closed'; i['comments'].append(opt('--comment'))
591+else:
592+ sys.exit('fake gh: unsupported ' + ' '.join(a))
593+json.dump(st, open(path, 'w'))
594+PY
595+chmod +x "$T/bin/gh"
596+export REPORT_GH=$T/bin/gh FAKE_GH_STATE=$T/gh.json RUN_URL=https://example.invalid/runs/1
597+report() { # VAR=value... : runs sync-report.py with only these job results and outputs
598+ env -i PATH="$PATH" REPORT_GH="$REPORT_GH" FAKE_GH_STATE="$FAKE_GH_STATE" RUN_URL="$RUN_URL" "$@" \
599+ python3 "$HERE/sync-report.py" 2>&1 | sed 's/^/ | /'
600+}
601+gh_state() { python3 -c "import json,sys; st=json.load(open('$T/gh.json')); print(eval(sys.argv[1]))" "$1"; }
602+A40=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa B40=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb C40=cccccccccccccccccccccccccccccccccccccccc
603+conflict=(SYNC_RESULT=failure COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=conflict
604+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_FORKGRAM_COMMIT=$A40 SYNC_SNAPSHOT=$B40 SYNC_PREVIOUS_SNAPSHOT=$C40
605+ SYNC_CONFLICT_COMMIT=$C40 "SYNC_CONFLICT_SUBJECT=Rename the \`app\`" "SYNC_CONFLICT_POSITION=2 of 3"
606+ SYNC_CONFLICT_FILES=app/Main.java "SYNC_CONFLICT_DETAILS=changed on both sides: app/Main.java")
607+report "${conflict[@]}"
608+eq "conflict opens an issue" "$(gh_state "len(st['issues'])")" 1
609+eq "issue title" "$(gh_state "st['issues'][0]['title']")" "Forkgram sync needs a hand (Forkgram 1.2.0)"
610+has "issue body has the rebase command" "$(gh_state "st['issues'][0]['body']")" "git rebase --onto $B40 $C40"
611+has "issue body has the file" "$(gh_state "st['issues'][0]['body']")" "changed on both sides: app/Main.java"
612+has "backticks in the subject are neutralised" "$(gh_state "st['issues'][0]['body']")" "Rename the 'app'"
613+report "${conflict[@]}"
614+eq "the same conflict is not reported twice" "$(gh_state "len(st['issues'][0]['comments'])")" 0
615+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=behind SYNC_FORKGRAM_VERSION=1.2.0
616+eq "behind with an open issue adds nothing" "$(gh_state "len(st['issues'][0]['comments'])")" 0
617+report SYNC_RESULT=success COMPILE_RESULT=failure PROMOTE_RESULT=skipped SYNC_STATUS=candidate \
618+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_CANDIDATE=$A40 SYNC_SNAPSHOT=$B40
619+eq "compile failure comments on the open issue" "$(gh_state "len(st['issues'][0]['comments'])")" 1
620+has "comment has the autosquash fix" "$(gh_state "st['issues'][0]['comments'][0]")" "git rebase -i --autosquash $B40"
621+next_edited=(SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=next-edited
622+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_FORKGRAM_COMMIT=$A40 SYNC_FORKGRAM_TIP=$B40 SYNC_EXISTING_NEXT=$C40)
623+report "${next_edited[@]}"
624+eq "next-edited comments on the open issue" "$(gh_state "len(st['issues'][0]['comments'])")" 2
625+has "next-edited comment says what to do" "$(gh_state "st['issues'][0]['comments'][1]")" "use_next"
626+has "next-edited comment names the new Forkgram commit" "$(gh_state "st['issues'][0]['comments'][1]")" "${B40:0:10}"
627+report "${next_edited[@]}"
628+eq "the same next-edited is not reported twice" "$(gh_state "len(st['issues'][0]['comments'])")" 2
629+report SYNC_RESULT=success COMPILE_RESULT=success PROMOTE_RESULT=success SYNC_STATUS=candidate \
630+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_CANDIDATE=$A40 PROMOTE_TAG=multigram-before-1.2.0 "SYNC_DROPPED=$C40 Late \`commit\`"
631+eq "promotion closes the issue" "$(gh_state "st['issues'][0]['state']")" closed
632+has "closing note names the tag" "$(gh_state "st['issues'][0]['comments'][-1]")" "multigram-before-1.2.0"
633+has "closing note lists the dropped commits" "$(gh_state "st['issues'][0]['comments'][-1]")" "$C40 Late 'commit'"
634+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=up-to-date SYNC_FORKGRAM_VERSION=1.2.0
635+eq "up to date with no open issue: nothing" "$(gh_state "len(st['issues'])")" 1
636+report SYNC_RESULT=failure COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=error "SYNC_ERROR=origin has no multigram branch yet."
637+eq "a sync error opens a new issue" "$(gh_state "len(st['issues'])")" 2
638+has "error text in the issue" "$(gh_state "st['issues'][1]['body']")" "origin has no multigram branch yet."
639+report SYNC_RESULT=cancelled COMPILE_RESULT=skipped PROMOTE_RESULT=skipped
640+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=up-to-date SYNC_FORKGRAM_VERSION=1.2.0
641+eq "in step again closes it" "$(gh_state "st['issues'][1]['state']")" closed
642+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=behind SYNC_FORKGRAM_VERSION=1.3.0
643+eq "behind with no open issue opens one" "$(gh_state "[i['state'] for i in st['issues']]")" "['closed', 'closed', 'open']"
644+
645+printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
646+[ "$FAIL" -eq 0 ]
scripts/sync-forkgram.shnew file+848
@@ -0,0 +1,848 @@
1+#!/usr/bin/env bash
2+# Keep MultiGram in step with Forkgram.
3+#
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]
8+#
9+# sync (the default)
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.
35+#
36+# promote
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.
40+#
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.
45+#
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.
73+set -euo pipefail
74+
75+FORKGRAM_URL=${FORKGRAM_URL:-https://github.com/forkgram/TelegramAndroid}
76+FORKGRAM_BRANCH=${FORKGRAM_BRANCH:-dev}
77+FORKGRAM_HISTORY_DEPTH=${FORKGRAM_HISTORY_DEPTH:-600}
78+
79+FG=forkgram
80+MG=multigram
81+NEXT=multigram-next
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
85+
86+NL=$'\n'
87+WORK=
88+WT=
89+ERROR_MSG=
90+OUT_KEYS=()
91+GITID=()
92+
93+# ---------------------------------------------------------------- output and logging
94+
95+log() { printf 'sync: %s\n' "$*" >&2; }
96+
97+annotate() { # level message
98+ if [ -n "${GITHUB_ACTIONS:-}" ]; then
99+ local m=$2
100+ m=${m//'%'/'%25'}; m=${m//$'\r'/'%0D'}; m=${m//$'\n'/'%0A'}
101+ printf '::%s::%s\n' "$1" "$m"
102+ else
103+ printf 'sync: %s: %s\n' "$1" "$2" >&2
104+ fi
105+}
106+
107+warn() { annotate warning "$*"; }
108+
109+die() {
110+ ERROR_MSG=$*
111+ out status error
112+ out error "$ERROR_MSG"
113+ out message "$ERROR_MSG"
114+ exit 1
115+}
116+
117+out() { # key value...
118+ local k=$1 have
119+ shift
120+ printf -v "o_$k" '%s' "$*"
121+ have=" ${OUT_KEYS[*]-} "
122+ case $have in *" $k "*) ;; *) OUT_KEYS+=("$k") ;; esac
123+}
124+
125+get() { local n="o_$1"; printf '%s' "${!n-}"; }
126+
127+random_hex() { od -An -N16 -tx1 /dev/urandom | tr -d ' \n'; }
128+
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.
131+print_untrusted() {
132+ if [ -n "${GITHUB_ACTIONS:-}" ]; then
133+ local token
134+ token=$(random_hex)
135+ printf '::stop-commands::%s\n%s\n::%s::\n' "$token" "$1" "$token"
136+ else
137+ printf '%s\n' "$1"
138+ fi
139+}
140+
141+emit_outputs() {
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
146+ v=$(get "$k")
147+ if [ -n "${GITHUB_OUTPUT:-}" ]; then
148+ printf '%s<<%s\n%s\n%s\n' "$k" "$delim" "$v" "$delim" >>"$GITHUB_OUTPUT"
149+ fi
150+ case $v in
151+ *$'\n'*) text+="$k:"$'\n'"$(printf '%s\n' "$v" | sed 's/^/ /')"$'\n' ;;
152+ *) text+="$k=$v"$'\n' ;;
153+ esac
154+ done
155+ print_untrusted "---- outputs ----"$'\n'"${text%$'\n'}"
156+}
157+
158+md() { printf '%s' "$1" | tr '`' "'"; } # untrusted text for a Markdown code span or block
159+
160+write_summary() {
161+ [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ ${#OUT_KEYS[@]} -gt 0 ] || return 0
162+ local k v
163+ {
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
170+ v=$(get "$k")
171+ [ -z "$v" ] || printf '| %s | `%s` |\n' "$k" "$(md "$v" | tr '|' '/')"
172+ done
173+ for k in conflict_commit conflict_subject conflict_position conflict_details dropped missing rerere_resolved error; do
174+ v=$(get "$k")
175+ [ -z "$v" ] || printf '\n**%s**\n```text\n%s\n```\n' "$k" "$(md "$v")"
176+ done
177+ } >>"$GITHUB_STEP_SUMMARY"
178+}
179+
180+cleanup() {
181+ local rc=$?
182+ set +e
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; }
186+ fi
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
190+ out status error
191+ out error "${ERROR_MSG:-the script stopped unexpectedly (exit $rc); see the log above}"
192+ fi
193+ if [ "$(get status)" = error ]; then annotate error "$(get error)"; fi
194+ emit_outputs
195+ write_summary
196+ exit $rc
197+}
198+trap cleanup EXIT
199+
200+# ---------------------------------------------------------------- git helpers
201+
202+short() { git rev-parse --short "$1" 2>/dev/null || printf '%s' "${1:0:10}"; }
203+
204+enter_repo() {
205+ local top
206+ top=$(git rev-parse --show-toplevel 2>/dev/null) || die "Run this inside a clone of the MultiGram repository."
207+ cd "$top"
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")
212+ fi
213+}
214+
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[@]}"} "$@"; }
218+
219+remote_ref() { # "ls-remote output" refname -> sha or empty
220+ printf '%s\n' "$1" | awk -v r="$2" '$2 == r { print $1; exit }'
221+}
222+
223+check_free_branches() { # branch... : this script moves these branches
224+ local list b
225+ list=$(git worktree list --porcelain)
226+ for b; do
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."
229+ fi
230+ done
231+}
232+
233+set_branch() { # branch sha why
234+ local old
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")"
240+ else
241+ log "local branch $1: created at $(short "$2")"
242+ fi
243+}
244+
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
249+}
250+
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]
254+ local cur others
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)
258+ [ -n "$others" ] ||
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."
260+}
261+
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
268+ {
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"
273+ } >"$f"
274+ awk -F'\t' '
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 }
278+ $1 == "M" {
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)
282+ print $2 " " s
283+ }' "$f"
284+}
285+
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))
297+ continue
298+ fi
299+ bad+="$(short "$m") $(git log -1 --format=%s "$m")$NL"
300+ done
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."
303+ fi
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."
305+}
306+
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
309+}
310+
311+tag_version() { # version fallback -> a string that is safe in a tag name
312+ local v
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
315+ printf '%s' "$v"
316+}
317+
318+has_github() { [ -n "$(git ls-tree --name-only "$1" -- .github)" ]; }
319+
320+check_no_github() { # from to what : every commit in from..to
321+ local c
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)."
325+ fi
326+ done
327+}
328+
329+# ---------------------------------------------------------------- snapshot helpers
330+
331+S_COMMIT= S_VERSION= S_BASE=
332+read_snapshot() { # commit : reads the Forkgram-* lines of a snapshot commit
333+ local body
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")
340+}
341+
342+forkgram_version() { # tree subject -> APP_VERSION_NAME from gradle.properties, else the "[Release] X." subject
343+ local v
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')
349+ fi
350+ safe_version "$v"
351+}
352+
353+TBASE=
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
356+ TBASE=
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."
362+ return 0
363+ fi
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."
366+ return 0
367+ fi
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."
372+ return 0
373+ fi
374+ sha=${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"))"
377+}
378+
379+source_link() { # commit
380+ local u=${FORKGRAM_URL%/}
381+ u=${u%.git}
382+ case $u in
383+ https://github.com/*/*) printf '%s/commit/%s' "$u" "$1" ;;
384+ *) printf '%s (%s) at %s' "$FORKGRAM_URL" "$FORKGRAM_BRANCH" "$1" ;;
385+ esac
386+}
387+
388+snapshot_tree() { # tree -> the same tree without the top-level .github
389+ local entry
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
394+}
395+
396+NEW_SNAPSHOT=
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."
400+ {
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"
407+ } >"$msg"
408+ NEW_SNAPSHOT=$(g commit-tree "$tree" -p "$3" -F "$msg") || die "Could not create the snapshot commit."
409+}
410+
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
415+ local tipgit=(git)
416+ TIP=$1
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}")
425+ else
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."
436+ fi
437+ TIP_TREE=$("${tipgit[@]}" rev-parse --verify "$TIP^{tree}")
438+ TIP_SUBJECT=$("${tipgit[@]}" log -1 --format=%s "$TIP")
439+}
440+
441+set_snapshot_outputs() { # snapshot new_snapshot(yes|no)
442+ read_snapshot "$1" || die "The snapshot $(short "$1") has no Forkgram-Commit line."
443+ out snapshot "$1"
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}")")"
449+}
450+
451+# ---------------------------------------------------------------- the replay
452+
453+rebase_in_progress() {
454+ local d
455+ for d in rebase-merge rebase-apply; do
456+ [ ! -d "$(git -C "$WT" rev-parse --path-format=absolute --git-path "$d")" ] || return 0
457+ done
458+ return 1
459+}
460+
461+conflict_kinds() { # "stages<TAB>path" lines -> "what happened: path" lines
462+ awk -F'\t' '{
463+ k = $1
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 ")"
471+ print w ": " $2
472+ }'
473+}
474+
475+# Replays upstream..tip onto onto in a temporary worktree. Sets REPLAY to "ok" and
476+# CANDIDATE, or to "conflict" and the conflict_* outputs.
477+CANDIDATE=
478+REPLAY=
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
482+ WT=$WORK/replay
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."
497+ fi
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"
509+ rc=0
510+ from=$(wc -c <"$rlog")
511+ GIT_EDITOR=true LC_ALL=C "${rb[@]}" rebase --continue >>"$rlog" 2>&1 || rc=$?
512+ done
513+
514+ local dropped
515+ dropped=$(tr '\r' '\n' <"$rlog" | grep -E '^(dropping |warning: skipped previously applied commit)' || true)
516+ [ -z "$dropped" ] || out dropped "$dropped"
517+
518+ if [ $rc -eq 0 ]; then
519+ CANDIDATE=$(git -C "$WT" rev-parse HEAD)
520+ REPLAY=ok
521+ return 0
522+ fi
523+
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
537+ REPLAY=conflict
538+}
539+
540+# ---------------------------------------------------------------- sync
541+
542+sync_main() {
543+ local remote= push=no force=no use_next=no allow_drop=no
544+ while [ $# -gt 0 ]; do
545+ case $1 in
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)" ;;
560+ esac
561+ done
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."
566+ enter_repo
567+ check_free_branches "$FG" "$NEXT"
568+
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")
583+ what="$FG and $MG"
584+ if [ -n "$next_prev" ]; then
585+ specs+=("+refs/heads/$NEXT:refs/remotes/$remote/$NEXT")
586+ what="$FG, $MG and $NEXT"
587+ fi
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}")
593+ else
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)
600+ fi
601+ out existing_next "$next_prev"
602+
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"
610+ out multigram "$mg"
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."
616+ fi
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."
620+
621+ # 3a. A stack fixed by hand, already pushed to multigram-next.
622+ if [ "$use_next" = yes ]; then
623+ local missing n
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)."
638+ else
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)."
641+ fi
642+ fi
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"
649+ out pushed ""
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)"
653+ return 0
654+ fi
655+
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"
663+
664+ if [ "$tip" = "$old_commit" ] && [ "$force" = no ]; then
665+ set_snapshot_outputs "$old" no
666+ out candidate ""
667+ out pushed ""
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)"
672+ else
673+ out status behind
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)"
676+ fi
677+ return 0
678+ fi
679+
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
686+ out candidate ""
687+ out pushed ""
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."
691+ else
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."
693+ fi
694+ warn "$(get message)"
695+ return 0
696+ fi
697+ check_local_next
698+ check_merges "$old" "$mg"
699+
700+ if [ "$tip" = "$old_commit" ]; then
701+ log "No new Forkgram release; --force: rebuilding $NEXT on the current snapshot"
702+ new=$old
703+ new_snapshot=no
704+ else
705+ fetch_tip "$tip"
706+ [ "$TIP" != "$old_commit" ] ||
707+ die "Forkgram's $FORKGRAM_BRANCH moved back to the latest snapshot's commit during the sync; run it again."
708+ tip=$TIP
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"
713+ tbase=$TBASE
714+ make_snapshot "$tip" "$TIP_TREE" "$old" "$version" "$tbase"
715+ new=$NEW_SNAPSHOT
716+ new_snapshot=yes
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."
720+ fi
721+ fi
722+ set_snapshot_outputs "$new" "$new_snapshot"
723+ version=$(get forkgram_version)
724+ check_no_github "$old" "$new" "$FG"
725+
726+ # 4. The replay.
727+ replay "$new" "$old" "$mg"
728+ if [ "$REPLAY" = ok ]; then
729+ check_no_github "$new" "$CANDIDATE" "$NEXT"
730+ check_local_next "$CANDIDATE"
731+ fi
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")"
738+ else
739+ out candidate ""
740+ fi
741+
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")
748+ pushed+=("$FG")
749+ fi
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")
753+ pushed+=("$NEXT")
754+ fi
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[*]}"
759+ else
760+ out pushed ""
761+ fi
762+
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."
768+ return 0
769+ fi
770+ out status conflict
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)"
773+ return 2
774+}
775+
776+# ---------------------------------------------------------------- promote
777+
778+promote_main() {
779+ local remote= cand= prev= version= shallow=no
780+ while [ $# -gt 0 ]; do
781+ case $1 in
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)" ;;
789+ esac
790+ done
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)."
795+ enter_repo
796+
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"
812+
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
817+ out tag ""
818+ out message "$MG already is the candidate ($(short "$cand")); deleted $NEXT."
819+ log "$(get message)"
820+ return 0
821+ fi
822+
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."
826+ tag=$base
827+ n=1
828+ while grep -qxF "refs/tags/$tag" <<<"$existing"; do
829+ n=$((n + 1))
830+ tag=$base-$n
831+ done
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."
837+ out status promoted
838+ out tag "$tag"
839+ out message "$MG moved from $(short "$prev") (tagged $tag) to $(short "$cand"); $NEXT deleted."
840+ log "$(get message)"
841+}
842+
843+cmd=sync
844+case ${1:-} in
845+ sync | promote) cmd=$1; shift ;;
846+esac
847+# A conflict makes sync_main return 2, which (set -e) ends the script with exit code 2.
848+"${cmd}_main" "$@"
scripts/sync-report.pynew file+273
@@ -0,0 +1,273 @@
1+#!/usr/bin/env python3
2+"""Report a "Sync with Forkgram" run on the GitHub issue labelled forkgram-sync.
3+
4+Runs in the report job of .github/workflows/sync-forkgram.yml, after the sync, compile and
5+promote jobs. It reads their results and the sync outputs from the environment and:
6+
7+ - conflict, compile failure, promote failure, a sync error, or a multigram-next changed by
8+ hand that the sync would have replaced: opens an issue, or comments on the open one, with
9+ what happened and the local commands to fix it (the same event is reported once, however
10+ often the schedule runs);
11+ - multigram still behind with no new release: opens an issue only if none is open;
12+ - promoted, or multigram in step with Forkgram: closes the open issue with a note (naming
13+ any MultiGram commits the new multigram leaves out).
14+
15+Environment: SYNC_RESULT, COMPILE_RESULT, PROMOTE_RESULT (job results); SYNC_* (the sync
16+job's outputs, e.g. SYNC_STATUS, SYNC_FORKGRAM_VERSION); PROMOTE_TAG; RUN_URL; GH_TOKEN
17+and GH_REPO for the gh CLI. REPORT_GH names another gh binary (the self-test uses a fake).
18+Standard library only.
19+"""
20+import hashlib
21+import json
22+import os
23+import re
24+import subprocess
25+import sys
26+import tempfile
27+
28+LABEL = 'forkgram-sync'
29+GH = os.environ.get('REPORT_GH', 'gh')
30+SHA = re.compile(r'^[0-9a-f]{40}$')
31+VERSION = re.compile(r'^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$')
32+
33+
34+def env(name):
35+ return os.environ.get(name, '').strip()
36+
37+
38+def sha(name):
39+ value = env(name)
40+ return value if SHA.match(value) else ''
41+
42+
43+def short(value):
44+ return value[:10] if value else '?'
45+
46+
47+def version():
48+ value = env('SYNC_FORKGRAM_VERSION')
49+ return value if VERSION.match(value) else 'unknown'
50+
51+
52+def block(text):
53+ """Untrusted text (commit subjects, file names, errors) as a Markdown code block."""
54+ text = text.replace('`', "'").strip() or '(none)'
55+ return '```text\n' + text + '\n```'
56+
57+
58+def gh(*args):
59+ run = subprocess.run([GH, *args], capture_output=True, text=True)
60+ if run.returncode != 0:
61+ sys.stderr.write(run.stderr)
62+ raise SystemExit(f'gh {args[0]} {args[1] if len(args) > 1 else ""} failed')
63+ return run.stdout
64+
65+
66+def gh_body(*args, body):
67+ with tempfile.NamedTemporaryFile('w', suffix='.md', delete=False) as f:
68+ f.write(body)
69+ path = f.name
70+ try:
71+ return gh(*args, '--body-file', path)
72+ finally:
73+ os.unlink(path)
74+
75+
76+def open_issues():
77+ issues = json.loads(gh('issue', 'list', '--label', LABEL, '--state', 'open',
78+ '--json', 'number,title', '--limit', '20') or '[]')
79+ return sorted(i['number'] for i in issues)
80+
81+
82+def open_issue():
83+ numbers = open_issues()
84+ return numbers[0] if numbers else None
85+
86+
87+def already_reported(number, key):
88+ data = json.loads(gh('issue', 'view', str(number), '--json', 'body,comments'))
89+ texts = [data.get('body') or ''] + [c.get('body') or '' for c in data.get('comments', [])]
90+ return any(key in t for t in texts)
91+
92+
93+def marker(key):
94+ return f'<!-- forkgram-sync-key: {key} -->'
95+
96+
97+def title():
98+ return f'Forkgram sync needs a hand (Forkgram {version()})'
99+
100+
101+def run_line():
102+ url = env('RUN_URL')
103+ return f'Run: {url}' if url.startswith('https://') else ''
104+
105+
106+def use_next_step():
107+ return ('Then open **Actions > Sync with Forkgram > Run workflow**, tick **use_next** and run '
108+ 'it. It compiles `multigram-next` and, if that passes, moves `multigram` to it (the '
109+ 'old tip is kept as the tag `multigram-before-<version>`) and closes this issue.')
110+
111+
112+def body_conflict():
113+ snap, prev = sha('SYNC_SNAPSHOT') or '<new snapshot>', sha('SYNC_PREVIOUS_SNAPSHOT') or '<old snapshot>'
114+ commit = sha('SYNC_CONFLICT_COMMIT')
115+ return f"""**Forkgram {version()}** is out and is now on `forkgram` (snapshot `{short(snap)}`), but the MultiGram stack does not apply to it. `multigram` is unchanged.
116+
117+MultiGram commit `{short(commit)}` ({env('SYNC_CONFLICT_POSITION') or '?'}) stops the rebase:
118+{block(env('SYNC_CONFLICT_SUBJECT'))}
119+Files:
120+{block(env('SYNC_CONFLICT_DETAILS') or env('SYNC_CONFLICT_FILES'))}
121+{run_line()}
122+
123+### Fix it locally
124+
125+```sh
126+git fetch origin
127+git switch -C multigram-next origin/multigram
128+git config rerere.enabled true # reuses your fixes if you redo this rebase in this clone
129+git rebase --onto {snap} {prev}
130+# fix the files git names, then:
131+git add <files>
132+git rebase --continue # repeat until the rebase is done
133+git push --force origin multigram-next
134+```
135+
136+To leave a MultiGram commit out instead (Forkgram now does the same thing, say), run `git rebase --skip` at that commit.
137+
138+{use_next_step()}
139+"""
140+
141+
142+def body_compile():
143+ snap, cand = sha('SYNC_SNAPSHOT') or '<snapshot>', sha('SYNC_CANDIDATE')
144+ return f"""The MultiGram stack applied cleanly to **Forkgram {version()}**, but the compile check failed. `multigram` is unchanged; the candidate is on `multigram-next` (`{short(cand)}`).
145+
146+{run_line()}
147+
148+If the log shows a runner or network hiccup, use **Re-run failed jobs** on the run page. Otherwise:
149+
150+### Fix it locally
151+
152+```sh
153+git fetch origin
154+git switch -C multigram-next origin/multigram-next
155+# fix the build, then fold each fix into the MultiGram commit it belongs to,
156+# one fixup per commit, naming that commit's files (PATCH.md 6.3 on the docs branch):
157+git commit --fixup=<that commit> -- <its files>
158+GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash {snap}
159+git push --force origin multigram-next
160+```
161+
162+{use_next_step()}
163+"""
164+
165+
166+def body_promote():
167+ cand = sha('SYNC_CANDIDATE')
168+ return f"""The candidate for **Forkgram {version()}** compiled, but moving `multigram` to it failed, usually because someone pushed to `multigram` or `multigram-next` while the sync ran. `multigram` is unchanged; the candidate is still on `multigram-next` (`{short(cand)}`).
169+
170+{run_line()}
171+
172+Check that `multigram-next` holds everything `multigram` has now (rebase any new commits onto it and push it), then run **Actions > Sync with Forkgram** with **use_next**.
173+"""
174+
175+
176+def body_error():
177+ return f"""The sync stopped with an error. Nothing was pushed after the error; `multigram` is unchanged.
178+
179+{block(env('SYNC_ERROR') or env('SYNC_MESSAGE') or 'The sync job failed before it could say why; see the run log.')}
180+{run_line()}
181+
182+Fix the cause (the message says what it is), then run **Actions > Sync with Forkgram** again.
183+"""
184+
185+
186+def body_next_edited():
187+ nxt, tip, fc = sha('SYNC_EXISTING_NEXT'), sha('SYNC_FORKGRAM_TIP'), sha('SYNC_FORKGRAM_COMMIT')
188+ if tip and tip != fc:
189+ what = f"Forkgram's `dev` has moved on (`{short(tip)}`), but the sync did not take it"
190+ else:
191+ what = 'The sync was asked to rebuild `multigram-next` (force), but did not'
192+ return f"""{what}: `multigram-next` (`{short(nxt)}`) has commits the sync did not make, most likely a fix by hand, and the sync never replaces those. Nothing was pushed; `forkgram` and `multigram` are unchanged.
193+
194+{run_line()}
195+
196+- If `multigram-next` is ready, run **Actions > Sync with Forkgram > Run workflow** with **use_next**: it compiles `multigram-next` and, if that passes, moves `multigram` to it. The next scheduled sync then takes the new Forkgram commit.
197+- If you do not need it any more, delete it (`git push origin --delete multigram-next`); the next sync rebuilds it from `multigram`.
198+"""
199+
200+
201+def body_behind():
202+ return f"""`forkgram` has **Forkgram {version()}** (snapshot `{short(sha('SYNC_SNAPSHOT'))}`), but `multigram` is still based on the older snapshot `{short(sha('SYNC_MULTIGRAM_BASE'))}`: an earlier sync could not move it and no issue is open about it.
203+
204+{run_line()}
205+
206+Run **Actions > Sync with Forkgram** with **force** to try again; if the stack still does not apply, this issue gets the details and the commands to fix it.
207+"""
208+
209+
210+def report(key, body):
211+ number = open_issue()
212+ text = marker(key) + '\n' + body
213+ if number is None:
214+ gh('label', 'create', LABEL, '--color', 'D93F0B', '--force',
215+ '--description', 'The automatic sync with Forkgram needs a hand')
216+ print(gh_body('issue', 'create', '--title', title(), '--label', LABEL, body=text).strip())
217+ return
218+ if key and already_reported(number, marker(key)):
219+ print(f'issue #{number} already reports this; nothing to add')
220+ return
221+ gh('issue', 'edit', str(number), '--title', title())
222+ gh_body('issue', 'comment', str(number), body=text)
223+ print(f'commented on issue #{number}')
224+
225+
226+def close(note):
227+ for number in open_issues():
228+ gh('issue', 'close', str(number), '--reason', 'completed', '--comment', note)
229+ print(f'closed issue #{number}')
230+
231+
232+def main():
233+ results = [env('SYNC_RESULT'), env('COMPILE_RESULT'), env('PROMOTE_RESULT')]
234+ status = env('SYNC_STATUS')
235+ print(f'sync={results[0]} compile={results[1]} promote={results[2]} status={status or "-"}')
236+ if 'cancelled' in results:
237+ print('a job was cancelled; nothing to report')
238+ return
239+ fc = sha('SYNC_FORKGRAM_COMMIT')
240+ cand = sha('SYNC_CANDIDATE')
241+ if status == 'conflict':
242+ report(f'conflict {fc} {sha("SYNC_CONFLICT_COMMIT")}', body_conflict())
243+ elif results[0] != 'success':
244+ err = env('SYNC_ERROR') or 'unknown'
245+ report('error ' + hashlib.sha1(err.encode()).hexdigest()[:12], body_error())
246+ elif status == 'next-edited':
247+ report(f'next-edited {sha("SYNC_EXISTING_NEXT")} {sha("SYNC_FORKGRAM_TIP")}', body_next_edited())
248+ elif status == 'candidate' and results[1] == 'failure':
249+ report(f'compile {cand}', body_compile())
250+ elif status == 'candidate' and results[1] == 'success' and results[2] == 'failure':
251+ report(f'promote {cand}', body_promote())
252+ elif status == 'candidate' and results[2] == 'success':
253+ tag = env('PROMOTE_TAG')
254+ moved = f'the old tip is tagged `{tag}`' if re.match(r'^[0-9A-Za-z._-]+$', tag) else 'it already was the candidate'
255+ note = f'Done: `multigram` now carries Forkgram {version()} (`{short(cand)}`); {moved}. {run_line()}'.strip()
256+ if env('SYNC_DROPPED'):
257+ note += ('\n\nThese MultiGram commits are not on the new `multigram` (Forkgram already has '
258+ 'them, or `multigram-next` left them out with allow_drop):\n' + block(env('SYNC_DROPPED')))
259+ close(note)
260+ elif status == 'behind':
261+ if open_issue() is None:
262+ report('', body_behind())
263+ else:
264+ print('multigram is behind and an issue is already open; nothing to add')
265+ elif status == 'up-to-date':
266+ if open_issue() is not None:
267+ close(f'`multigram` is in step with Forkgram {version()} again. {run_line()}'.strip())
268+ else:
269+ print('nothing to report')
270+
271+
272+if __name__ == '__main__':
273+ main()