Dev.to · 7 min read

Your GitHub template repo is a one-time copy — we found out across 68 sites

Your GitHub template repo is a one-time copy — we found out across 68 sites

We run about a hundred repositories. Sixty-eight of them started life the same way: someone opened a GitHub template repo, clicked Use this template, and got a working site. Last week I counted which version of that template each one was actually running. Twenty-eight distinct versions were live at the same time, from 1.0.0 to 4.35.4. Four sites were still on major version 1. I want to be precise about the cause, because it is not what it looks like. Nobody had been lazy, and no process had broken down. The sites had diverged because there was never a mechanism for them not to. The copy is one-way, and git will tell you so A repository created from a GitHub template shares no commit with the template. Not a distant ancestor — none at all. GitHub squashes the template's history into a single initial commit in the new repo. Which means the obvious thing fails: $ git remote add template https://github.com/acme/service-template.git $ git fetch template $ git merge template/main fatal: refusing to merge unrelated histories You can force it with --allow-unrelated-histories, and you should not. Git will then treat every file as a conflicting add, because from its point of view these are two unrelated projects that happen to have the same filenames. git rebase has the same requirement, for the same reason. Both operations are defined in terms of a merge base, and there isn't one. So the template gains a security fix on day 30, a CI change on day 90, a dependency bump on day 200 — and none of it reaches any child repo. Someone copy-pastes it into a handful of repos, or it does not happen. At five repos that is tedious. At sixty-eight it does not happen. Cherry-pick does not need an ancestor There is exactly one git operation that does not care about a common ancestor: cherry-pick. It does not merge histories, it applies a patch. git cherry-pick --no-commit That works perfectly well between two repositories that have never met. And it is not a workaround — it produces a strictly better result than copying files. Commit with the original author and date: git commit \ --author="$(git log -1 --format='%an ' $SHA)" \ --date="$(git log -1 --format=%aD $SHA)" \ -m "$(git log -1 --format=%B $SHA)" …and the upstream fix stays findable in the child repo's history: $ git log --oneline 7c8ddeb Fix: token refresh raced with retry # authored upstream, months ago 67e64eb Bump actions/checkout to v5 641c0bd Initial commit from template An rsync would have given you one opaque "sync with template" commit, dated today, authored by whoever ran the script. Six months later, when you are bisecting, that difference is the whole ballgame. The hard part is not the cherry-pick Getting the patch to apply is a weekend. Getting it to apply without destroying what makes each repo its own is where the real work is, and where we got it wrong more than once. One protection rule is not enough Our first version had a single list of protected paths: on conflict, the local repo wins. It lost a site's entire branding within a week. The reason is subtle. A template commit can rewrite your logo cleanly — no conflict, nothing for anyone to arbitrate, no warning. A conflict-only rule never fires, and the file is silently replaced. So there have to be two rules: Files the repo owns outright — logo, favicon, hostnames, .env, docker-compose.yml. Restored from the repo after every replayed commit, conflict or not. These are unmergeable by nature: there is no sensible three-way merge of a PNG or of a hostname. Files the repo owns partly — a theme stylesheet that is 90% template and 10% your palette, a translations directory. Here the local version wins only on conflict. Protecting them absolutely would deny them every upstream fix forever; not protecting them would erase your part on the first conflicting commit. Everything else belongs to the template. And every file the template overwrites gets printed at the end of the run — more on that below. Merge commits are not skippable, they are forbidden Our first run stopped dead on a repo whose template history contained merges. cherry-pick refuses a merge commit unless you name a mainline with -m, and if you "unblock" it that way you duplicate content that both parents already contributed. The correct answer is --no-merges, always. A merge commit carries no content of its own; both its parents are already in the range you are replaying. Half-applied is worse than not applied If commit 14 of 20 fails, the tempting thing is to stop and report. Do not. The repo now carries part of the template's changes while its version file — and every dashboard, script and flag keyed off that version — claims a state it is not in. Every later run computes its range from that lie and compounds it. Roll the whole run back to where it started, tag the pre-run state first, and say which commit stopped you. The 502 that git reported as a success The incident that shaped the tool more than any design discussion: config/nginx/shared.conf contains the PHP-FPM container name, repeated three times in fastcgi_pass directives. It is not a logo, so it was not on anybody's protected list. The template's version overwrote it. nginx began looking for a container that did not exist on that host, and the site returned 502 for every request — while git log showed a clean, successful, complete update. Nothing failed. That is exactly what made it bad. So now: every file the template wins is listed at the end of the run, by name. It is the only place a graft can lose something, so it is the one thing that must never be silent. ! The template won 1 conflicted file: config/nginx/shared.conf This is the only place a graft loses something. If any of these belong to this repo rather than the template, add them to `protect` in .regraft.yml and re-run from 641c0bd. Knowing where you left off The last piece: on the next run, which commits are missing? Storing that in a file means a file to keep in sync, to merge, and to lose. We put it in the commit itself, as a trailer: Fix: token refresh raced with retry Regraft-source: 3d9e77b0a1c4e8f2d5b9a7c6e3f1d8b2a4c9e7f5 The next run reads the most recent one and starts from there. It survives clones, forks and rebases, because the record lives inside the history it describes. Where this ended up I extracted the tooling into a CLI, because none of the above is specific to our stack — it is specific to GitHub template repositories, which everyone uses and nobody can update. npx regraft init --template https://github.com/acme/service-template.git npx regraft status # read-only: what am I missing? npx regraft apply # graft it It drives the git binary, so the repository can be in any language. There is also a GitHub Action that opens the pull requests on a schedule, including a Dependabot-style mode where each template commit becomes its own reviewable PR. Source: github.com/achedon12/regraft Docs: achedon12.github.io/regraft MIT. status touches nothing, so it is safe to point at a repo you care about just to see the number. If you maintain repos created from a template, run the count. Twenty-eight versions was not a number I expected to find, and I am fairly sure we are not unusual.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News