My Mastodon instance runs on a fork of a fork of a fork. glitch-soc upstream, then neatchee’s fork merging three of TheEssem’s feature branches (emoji reactions, bubble timeline, GIF picker), then my own commits on top for fedi.my.id. The chain worked until I wanted a glitch-soc update. Then I waited for neatchee to merge it first. Nothing against neatchee, that fork is a gift to anyone who wants those features without maintaining them. But my instance’s update schedule was chained to neatchee’s calendar, and I wanted that gone.
The fix: turn the fork’s entire delta into a StGit patch stack against latest glitch-soc main. Updates become stg rebase instead of a merge ritual. This post covers the basics of stgit and everything that went wrong (and a few things that went right) while we rebuilt around 470 files of changes into five patches.
StGit in ten minutes
StGit (stacked git) keeps a branch as an ordered list of patches instead of a pile of commits. Think of commits you are allowed to edit forever. It works like Quilt or Mercurial’s Queues extension, except patches are stored as ordinary git commits, so git log, gitk and every other git tool keeps working underneath.
Everything below comes from the official tutorial and the man pages. stg help lists all commands, stg help <cmd> and man stg-<cmd> explain each one.
Setup is one command per branch. StGit operates on a regular git repository and stores stack metadata for the current branch:
git clone https://github.com/glitch-soc/mastodon.git && cd mastodonstg initCreating a patch: stg new starts it, edits in the working tree follow, stg refresh folds those edits into the topmost patch.
stg new reaction-list -m "Emoji reactions"$EDITOR app/models/status_reaction.rbstg refreshThe whole state of the stack shows up in stg series, listed bottom to top:
+ reaction-list # applied> bubble-timeline # applied, topmost- gif-picker # unapplied+ means applied, - unapplied, > marks the topmost patch, and 0 marks an empty patch. Unapplied patches are invisible to git log; they sit on the shelf until stg push reapplies them.
The verbs worth knowing:
stg push/stg popmove patches on and off the stack, so you can edit a patch that is not on top: pop everything above it,stg refresh, push back.stg goto <patch>pushes or pops until that patch sits on top. Fastest way to jump to any point in the history.stg float/stg sinkreorder patches within the stack.stg refresh --patch <name>folds working-tree changes into a named patch without popping anything above it.stg squashcombines two patches into one.stg editrewrites the commit message of any patch, applied or not.stg uncommit --number 6converts the last 6 ordinary commits into patches, named from their commit messages. The reverse trip isstg commit --all.
Conflicts behave like git conflicts. Reordering or pushing a patch onto a base that touched the same lines can fail with UU status, you edit the file, stg add it, stg refresh, and push on. When a reorganization turns out to be a bad idea, stg undo rewinds the last stgit operation. This is the part quilt users already know, and it is what plain git rebase never gave us: per-patch undo.
Then there is the reason this whole post exists. stg rebase <base> pops the stack, moves the branch to the new base, and pushes every patch back. And stg import turns external patch files, patch series with a series file, or even mbox mailboxes into stack patches. Version 2.6.1 is what we used.
What we built
Inspiration came from entinyGram, a Telegram Android client built as an independent StGit patchset on top of official Telegram and Inugram. I copied the idea and rebuilt the extraction from scratch. The result lives at PegelinuxTop/fedi-patchset:
.base-commit pinned glitch-soc SHA the stack targetsseries application order, 0001..0005patches/0001 feature/reaction-list, 97 files (TheEssem, rebased)patches/0002 feature/bubble-timeline, 60 filespatches/0003 feature/gif-picker, 31 filespatches/0004 fedi/branding-themes, 273 filespatches/0005 fedi/compat-overlay, 14 filesscripts/ setup, lint, compare, export, image builddocs/ runbook, verification, rebase procedureThe .base-commit file pins glitch-soc main at b3877d5b24 (2026-10-05). Everything in the repo exists to rebuild a complete Mastodon tree from that base plus the five patches. The patches stay plain git format, so git am works too. StGit is the editing layer on top, for when the next rebase churns a file and a patch needs surgery.
What went wrong, and what we learned
Folder diffs lie
First instinct: diff dev against latest glitch-soc, extract patches from that. Wrong. The folder diff showed 949 changed files, but most of that is glitch-soc moving forward while dev sat on an older base. Our real delta was 464 files, the diff between neatchee’s last glitch merge and dev.
Per-file classification saved the rebuild. If dev’s copy of a file is byte-identical to latest glitch-soc, the feature patch applies cleanly (421 files). If upstream churned it (43 files), the custom change needs hand re-application into a compat overlay patch. Extract from git history, diff trees only for verification.
Merge ranges have two parents
To extract TheEssem’s feature content I needed the diff a merge introduced. merge^1..merge gives the feature side. merge^2..merge diffs against the second parent and produced a beautiful 1474-file patch that was complete garbage. The first version of a gif-picker fix patch was exactly this mistake. One file changed, nine hundred false positives.
Binary patches need --full-index
stg import failed with “cannot apply binary patch without full index line” on any patch touching a PNG, because the patches were generated with default git diff options. Every patch got regenerated with git diff --full-index. Now it is muscle memory.
Mixing git and stgit needs stg repair
The workflow that actually built the stack was not the documented one. stg new plus stg refresh kept failing with “HEAD and stack top are not the same” after any plain git commit touched the branch. What worked, every time:
git add <files>git commit -m "..."stg repairstg uncommit -n 1 <patch-name>stg repair reconciles the branch after raw git modified it. stg uncommit then converts the commit into a named patch. Without repair, stg uncommit fails with error: cannot uncommit ... which does not have exactly one parent, because the base commit is a merge with two parents and uncommit refuses to swallow that.
Small but annoying: the import syntax is stg import --name=X file.patch. The bare positional form from older docs does not parse.
Verify against the right target
The original plan was “diff -rq against the fork’s dev branch must be empty”. Unachievable and wrong. Dev sat on older glitch-soc while the new base moved 706 files forward. The correct reference is a fresh merge: take latest glitch-soc main, merge dev on top, resolve the 8 conflicted files by hand, and call that ground truth. Then the patch stack must reproduce that tree exactly, and a compare-with-fork.sh script checks every one of the 464 files.
Stale third-party patches revert upstream code silently
Early imports of TheEssem branch patches carried old file copies, and some of those files had already been fixed in glitch-soc itself. The stale patches quietly reverted composer fixes in actions/compose.js and counter.js. Nothing failed. The code just got worse. Every differing file gets classified, never assumed.
git merge loses moved imports
The reference merge had 8 conflicts, and one of them was sneaky: a Dropdown import that upstream moved to a different import block got treated as delete-here plus add-there, and the merged file dropped it. Classic move-across-conflict hazard. Review every conflict, not just the markers.
Docs claims must match what actually ran
A review pass caught the README claiming eslint passes when 4 errors existed. The fix went to the source: dropped a redundant eslint-disable, a wrong type='button' attribute, and two eslint config overrides that were too broad. Never fix the wording when the claim is false.
Migrations are sacred
Rails migrations from the feature branches get copied verbatim: original filenames, timestamps, class names. Data migrations never get squashed. schema.rb is never hand-patched, it regenerates via db:migrate. The repo’s lint-patches.sh scans db/(post_)?migrate/ for timestamp collisions, including post_migrate, which the first version missed.
Merge finds real bugs for free
The reference merge surfaced a pre-existing fork bug: fan_out_on_write_service.rb read the old show_reblogs_in_public_timelines key while the admin UI wrote renamed keys with no defaults. Result: reblogs never fanned out to public timelines. Nobody noticed because the code path looked alive. The merge forced both sides into one file and the bug fell out.
CI-only failures exist
Two failures appeared only on GitHub Actions. The runner had no git identity and git apply reported “does not apply cleanly” for patches that applied everywhere else. And a bare main ref resolved through git ls-remote matched glitch-soc’s main instead of our fork, so the build patched the wrong tree. Both fixed with explicit identity and exact-ref fallback.
The maintenance loop now
When glitch-soc ships the next release:
scripts/setup.sh --dest ~/src/fedi-rebase --stggit fetch upstream && NEW_BASE=$(git rev-parse upstream/main)stg rebase "$NEW_BASE"scripts/export-patches.sh --from ~/src/fedi-rebase --base "$NEW_BASE"Fetch, rebase, resolve whatever churned, export, verify. Neatchee is out of the dependency path. The patchset repo, CI-built Docker image (pinned to glitch-<sha12>, never latest), and dev branch sync are done. Final verification: 7934 rspec examples passing, 0 failures, rubocop clean across 3369 files, patches export byte-for-byte reproducible.
Deploy to fedi.my.id is the one step left, and the runbook assigns it to me.