Migrating a Prompt Library's Version Control Branching Strategy
10 min read · updated August 11, 2026
The instinct at the start of a migration is a branch: prompts/anthropic alongside prompts/openai, both live, both maintained. Six weeks later nobody can say which differences are deliberate and which are edits that were only ever applied to one side. This is a layout problem with a mechanical fix.
Why a branch per provider is the wrong shape
A git branch is a claim that some work is temporarily separate and will be reconciled. Everything about the tooling assumes it: merge conflicts exist to force a decision at reconciliation time, and a branch that never merges never triggers one.
Provider variants never merge. They are permanently parallel by design, so the reconciliation event that would have caught the drift never arrives. What actually happens is that a fix to a shared instruction — a policy line, a tone rule, a new escalation path — gets applied to whichever branch the author had checked out, and the other copy silently keeps the old text. Nothing conflicts because nothing is being merged. The two prompts diverge in ways nobody chose, and the divergence is indistinguishable from the intentional differences because both are just lines that differ.
Long-lived branches also break everything else that reads the repository. Your prompt registry can only see one branch at a time, diffs in review show provider differences mixed with content changes, and blame on a shared line points at the copy rather than at the decision.
Base and overlay, with the divergence visible
The right shape is one branch containing both variants, with the shared text stored once and each variant expressed as the smallest possible delta from it. Composition rather than duplication.
prompts/
triage/
base.md # everything both providers share
overlays/
incumbent.md # deltas for the current provider
candidate.md # deltas for the migration target
manifest.yaml
tests/
cases.yamlThe overlay is not a full copy with edits. It is a list of named operations against the base, so that a reviewer reading the overlay sees only the divergence and nothing else:
# prompts/triage/overlays/candidate.md --- base: base.md base_sha: 8f2c1ad4e9b07c3f5d61a2884e0fbb31c7d95e60 --- ## replace: delimiters Wrap each section in XML tags rather than markdown headings. Sections: <ticket>, <policy>, <history>. ## remove: thoroughness-preamble The base instructs the model to "be thorough and check every field". Removed: this target overtriggers on that phrasing and produces multi-paragraph answers for one-line tickets. ## add: format Respond with a single JSON object and no other text.
Every block carries its reason. That is not documentation politeness; it is the field that lets the next migration decide whether the override is still needed, which is exactly the problem the style guide migration page solves for prose rules.
Two properties fall out of this layout that a branch cannot give you. The overlay file’s length is a direct measurement of how far apart the two providers are, so a growing overlay is a visible signal that the abstraction is failing and the base is no longer shared in any meaningful sense. And a pull request that touches shared behaviour shows one diff rather than two, so a reviewer sees the decision once instead of reading the same change twice and hoping they match.
Keep the test cases with the base rather than with the overlays. A case that only exists for one provider is usually a case that is asserting on that provider’s house style, and being forced to write it once against both variants surfaces that immediately. The few cases that genuinely are provider-specific — a probe for a capability only one side has — get an explicit skip marker naming the reason, which is a much smaller and more honest set than a per-provider test directory tends to accumulate.
The recorded base hash is the whole mechanism
The base_sha field is what replaces the merge conflict. It records the exact content of the base at the moment this overlay was last reviewed against it. When somebody edits the base, every overlay’s recorded hash goes stale, and CI can say so.
That single check converts silent drift into a required review. The author who changes a shared policy line is told, at pull-request time, that three overlays were written against the old base and must be re-read. Most of the time re-reading confirms nothing needs to change and the fix is bumping the hash — which is fine, because the point is that a human looked. The failure mode being prevented is not a wrong overlay; it is an overlay nobody considered.
Building it
- Diff your two existing variants and sort every difference into shared, deliberate, or accidental. Accidental differences are resolved now, into the base. This step is the migration; the rest is plumbing.
- Write the base from the shared text. It should read as a complete, working prompt for a hypothetical provider, not as a fragment.
- Write one overlay per provider containing only the deliberate differences, each with a reason line.
- Write the composer: a function that reads base plus overlay and emits the final prompt string. Keep it dull — ordered text operations, no templating language.
- Add the CI check below and make it blocking.
- Delete the provider branches. Leaving them as a fallback recreates the problem within a sprint.
#!/usr/bin/env python3
"""Fail CI when an overlay's recorded base hash is stale."""
import hashlib, pathlib, sys, yaml
def sha(path: pathlib.Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
stale = []
for overlay in pathlib.Path("prompts").rglob("overlays/*.md"):
head = overlay.read_text().split("---")[1]
meta = yaml.safe_load(head)
base = overlay.parent.parent / meta["base"]
actual = sha(base)
if actual != meta["base_sha"]:
stale.append((overlay, meta["base_sha"][:12], actual[:12]))
for path, recorded, actual in stale:
print(f"STALE {path}: recorded {recorded}, base is now {actual}")
print(" re-read the overlay against the new base, then bump base_sha")
sys.exit(1 if stale else 0)Tagging the cutover and the rollback
Branches are the wrong tool for variants and tags are the right tool for releases. During a migration the thing you need to be able to name is a coordinate: which prompt version was running against which model. One without the other is not a rollback target, because reverting the prompt while leaving the model in place restores a combination that was never tested.
Tag immutably at each cutover step — prompt/2026-08-11/candidate-10pct — and record the model identifier in the tag message rather than only in configuration. Configuration is mutable and often lives in a different system, so six weeks later the tag is the only artefact that still knows what the pair was. That pairing is what makes a rollback a single documented action instead of an archaeology exercise, and it is the same coordinate the prompt versioning page argues for in the steady state.
One further rule that saves an incident: never let the composer run at request time in production. Compose at build time, commit the composed artefacts, and have the application load those. A composition bug then fails a build rather than serving a subtly wrong prompt to a fraction of live traffic, and the composed file is a diffable object a reviewer can read in a pull request — which, during a migration, is the object people actually want to look at.