Using Git History as the Merge Base for Content Edited in Production

Stem Shovel's user documentation lives in two places at once. The starting text of every page is a markdown file in the repo, thirty-nine of them under scripts/user-docs/ for the docs pages and the tool pages' own copy, written alongside the feature each one describes. The page a visitor reads at stemshovel.com/docs is a row in the production database, edited in the app's own markdown editor. The file seeds the page once. After that, the database copy is the truth, and the file keeps changing anyway, because every release that touches a tool also touches its docs.
Anyone who has shipped editable documentation, a CMS with content that starts life in a repo, or a config table with defaults has this problem in some form. Two copies of the same text, each with a claim to be current, and no good day to reconcile them. This post is about how we reconcile them now, which needed a three-way merge, and about the one piece of it worth stealing: the merge base comes out of git history, found by matching text, and nobody has to record it.
Two Ways to Get It Wrong
The first version of the refresh script did the obvious thing. bun run db:update-docs <slug> read the file and wrote it over the page. That is correct exactly as long as nobody edits the page, and on 2026-10-07 we learned how long that is. The Studio page's copy had been rewritten in the app, we asked for a refresh after each Studio change, and the rewrite kept reverting. The commit that fixed it says what happened in one line: a page edited in the app was overwritten by the refreshes we asked for.
So the script learned to skip. A version written by the app carries an author; the script's writes carry none; if the newest version has an author, the page is edited, leave it alone and say so. --force overwrites anyway, and --list and --restore bring any version back, since the versions table keeps them. That stopped the losses.
It also stopped the updates. Once a page had been edited once, no seed change ever reached it again. Every feature that touched a seed file became a note for a human: open the editor for this page, find this paragraph, apply this change, repeat for the next page. By October the notes were hard to follow, and the person following them said so. Overwrite loses the edits; never update loses the updates. Both are the same mistake viewed from different ends: treating the two copies as one text with a winner, when they are two descendants of a common ancestor.
What a Three-Way Merge Needs
Git solves this every day, and the solution has a shape. You need three texts: the common ancestor (the base), one descendant (the page as edited in the app), and the other descendant (the seed file as it stands now). Line up the lines of the base that survive on both sides, and between each pair of those anchors, one of four things is true. Neither side changed the region: keep it. One side changed it: take that side. Both changed it the same way: take it once. Both changed it differently: that is a conflict, and a human decides.
We wrote this ourselves rather than shelling out to git merge-file, because the texts live in a database, and because what we wanted to control (what counts as the same line, and who wins) is what a general tool hides. The whole merge is src/lib/utils/mergeThreeWay.ts, just over a hundred lines with its comments. A longest-common-subsequence match between the base and each side picks the anchors; the regions between them are settled by the four cases above:
if (same(ask, osk) && same(bsk, osk)) out.push(...as);
else if (same(ask, osk)) {
out.push(...bs);
added += bsk.filter((l) => !osk.includes(l)).length;
removed += osk.filter((l) => !bsk.includes(l)).length;
} else if (same(bsk, osk) || same(ask, bsk)) out.push(...as);
else if (options.prefer) {
conflicts.push({ base: os, ours: as, theirs: bs, taken: options.prefer });
out.push(...(options.prefer === "ours" ? as : bs));
} else {
conflicts.push({ base: os, ours: as, theirs: bs });
out.push(`<<<<<<< ${labels.ours}`, ...as, "=======", ...bs, `>>>>>>> ${labels.theirs}`);
}
The test file pins each case with a small looper page: an intro rewritten in the app and a section appended to the seed merge without a conflict; a line removed on one side goes; the same line changed differently on both sides comes back with markers, and the rest of the page still merges around it.
None of that is novel. It is diff3, written down again. The hard part of a three-way merge is never the merge. It is the base.
The Base Is in Git Already
For a merge to be right, the base has to be the exact text both sides descended from: the seed text the page was last written from. The app never recorded that. The versions table stores the markdown and a content hash of each save, and the app prunes to the newest ten. The seed file, meanwhile, may have been committed many times since. Which of those commits did this page come from?
The answer is that git has kept every seed text that ever existed, and the page has kept its recent versions, and the base is the newest one that appears in both. The script walks the file's git log, newest first, reads the file at each revision, normalizes it the way the script normalizes everything it writes, hashes it, and stops at the first revision whose hash is one of the page's saved versions:
const { stdout } = await run("git", ["log", "--format=%h", "--", file], { cwd: REPO });
for (const sha of shas) {
const { stdout } = await run("git", ["show", `${sha}:${file}`], { cwd: REPO });
const text = normalize(stdout);
if (hashes.has(await hash(text))) return { text, from: `git ${sha}` };
}
That is the whole trick, and it is why we wrote this post. Nothing had to be designed in advance. No column saying "seeded from commit abc123," no migration to add one, no risk that the column lies because someone wrote a page by another path. The repo is a complete history of one descendant's ancestry, the versions table is a partial history of the other's, and the intersection of the two is the common ancestor by definition. Content hashes make the intersection cheap to find.
Two consequences follow from reading the base this way. The first is that "edited in the app" stops meaning "the newest version has an author" and starts meaning "the page's text differs from its base." The author test would have got this wrong: a page the script has merged carries no author on its newest version, so the plain update mode would have treated it as unedited and written the seed over the merge. The second is that the merge has to leave the next merge a base. When --apply writes a merged page, it writes two versions: the seed text first, so that it matches a git revision, and then the merged result on top of it. The next run finds the seed version, uses it as the base, and merges only what the file has changed since.
The First Production Run
The merge shipped on 2026-10-12 as bun run db:update-docs --merge, a dry run by default. Without --apply it prints, for every page whose file differs from it, what would happen: the seed simply replaces a page that was never edited; the merge is clean, with the lines it would bring; or there is a conflict, with both texts of each conflicting region. Nothing is written until you have read it. The release checklist has the two commands as its docs step, read first, then apply.
The first run on production merged thirteen pages and left two conflicts. Both were the same shape: the editor had shortened a passage in the app, and a later release had extended the same passage in the seed. That is a true conflict, the kind diff3 exists to surface, and the script reported it and exited 0, because a conflict is the run's answer rather than its failure.
Then the Editor Started Lying About Whitespace
What came next is the part that separates a merge that works on fixtures from one people will keep using. Over the following ten days the editor kept hitting conflicts on blocks they had just written. Twice the conflict was on text the seed and the page agreed on, word for word. The chord player page conflicted on a block identical to the seed's except for a trailing blank line the app's editor saves. Elsewhere a non-breaking space had crept into a line, the way browser editors produce them, and that line no longer matched its anchor.
The first fix was narrow: the script already normalized seed files on read (LF, trimmed, one trailing newline), and now it normalizes the page's markdown the same way before comparing or merging. The second fix went into the merge itself. Lines are compared through a key that strips trailing whitespace and turns non-breaking spaces into spaces, while the text that comes out is still each side's as written:
const key = (l: string) => l.replace(/ /g, " ").replace(/\s+$/, "");
The test for it has the app's copy carrying trailing spaces and a non-breaking space, the seed renaming a heading, and expects a clean merge in which the page's own spacing comes through untouched.
The third change was a policy. The editor's actual request was that they needed to be able to make edits easily, and a merge that blocked a page over a paragraph they had just rewritten was in the way. So the page always wins now. A region changed differently on both sides keeps the page's text, with a note printed of what the seed wanted there, and the seed's other changes still land. The prefer option in the merge is that policy; --strict turns it off and brings the markers back for the rare case where the seed's side should win and someone wants to see both first. The note is addressed to whoever changed the seed, not to the editor: make the seed say what the page says, or carry the change another way.
That reversal deserves saying plainly. Ten days earlier we had thought conflicts were the point. They are, for code. For a documentation page that one person edits on the live site, the person at the editor is the authority, and the script's job is to carry the rest of the seed's changes to them without getting in the way.
What We Would Do Differently
The base lookup is the piece we would keep exactly. Deriving provenance from content hashes against git history is cheap, needs no schema, and cannot drift from the truth, because it is computed from the truth.
The edge it has is documented rather than solved: the app keeps ten versions, so a page edited ten times since its last seed write, with the seed changed in between, has lost its base. The script reports that and offers --force or a hand merge. The script's own writes are already exempt from the prune. Building it again, we would probably also stamp a seed write with the revision it came from, as a shortcut that falls back to the hash walk when it is missing. Belt and braces, with the braces still the source of truth.
The other thing we would think harder about earlier is the unit of comparison. Markdown paragraphs are single lines, so a line-level merge treats any edit to a paragraph as a change to the whole paragraph. The two conflicts in the first run were real, but a word-level merge inside a conflicting region might have settled some of the later ones without a policy. We chose lines because diff3 is a known quantity and the tests are easy to read, and we still think that was right for the first version.
Our own site has a smaller version of the same problem. This blog moved out of markdown files in the repo and into a CMS this year, and we settled the two-copies question the blunt way: the CMS is the only copy, and the old files survive as a frozen test fixture for the markdown parser. That works when the content stops being seeded. Stem Shovel's docs never will, because they are written with the code, and the three-way merge is what lets both keep moving.
If you have seeded content that someone edits in production, the shape to look for is this. Keep every version of both sides somewhere (the repo already does half of it for free). Find the common ancestor by matching content, not by recording it. Merge in three ways, dry run first. And decide who wins before the first conflict, not after the tenth.