Skip to main content

Plugin drift comes from duplication, not carelessness — here's how we fixed it

·938 words·5 mins
Tech WordPress Git DevOps

That’s where we were with our_custom_shipping_plugin: three sites, three copies, none of them clearly the right one.

How it got this way
#

Illustration for How it got this way The plugin started life in one site’s repo — reasonable at the time, since it was site-specific, lived next to the theme, and was easy to track. Then a second site needed the same shipping logic, so someone copied it over, then a third. Each copy picked up small tweaks: a hardcoded store name here, a different fallback label there. No one was being careless; that’s just what happens when code is duplicated instead of shared.

The deeper problem was that wp-content/plugins/ in each site repo had no clear taxonomy. Third-party plugins installed via Composer or direct download were mixed in with our own custom code, all tracked — or not tracked — under the same .gitignore logic. Some plugins were in version control, some weren’t, and the reasons had long since been forgotten.

When I finally sat down to audit it, the .git situation was honestly a bit FUBAR. One plugin directory had its own nested .git folder from what looked like an old manual clone nobody cleaned up — and I didn’t immediately understand why that was a problem, which made it worse. Git treats a directory containing its own .git folder as a nested repository and silently excludes its contents from staging, so the directory shows up in the parent repo but none of its files do. It’s not registered as a submodule; it just quietly disappears from your commit surface. Once I understood the mechanism it was easy to fix, but for a few minutes I was genuinely unsure what I was looking at.

What we decided and why
#

Illustration for What we decided and why The fix had two parts: clean up the plugin’s internals, and fix how it’s shared across repos.

For the internals, the issue was straightforward. Any value that differed between sites — store name, shipping zone identifiers, display labels — was hardcoded directly in the plugin PHP, which is fine for a one-site plugin, but makes a shared plugin impossible to maintain cleanly — so we moved all of it into wp-config.php constants. The plugin now reads WP_CUSTOM_SHIPPING_STORE_NAME instead of having "Brand A" buried on line 47. Each site defines its own constants; the plugin logic stays identical.

For the sharing mechanism, the real choice was between Git submodules and Git subtree. Submodules are the standard answer, but they require extra clone steps, they confuse CI pipelines if not configured carefully, and on at least one previous project they tripped up a junior dev who missed the --recurse-submodules flag during onboarding — the clone looked fine, the plugin directory was just empty, and it took an embarrassing amount of time to diagnose. Git subtree merges the plugin repo’s history directly into the parent repo at a specified prefix path: no .gitmodules file, no detached HEAD surprises, and the working tree looks normal to anyone who clones the parent. We went with subtree. Pushing changes back upstream requires an explicit git subtree push and some discipline, but that’s a workflow tax I’d rather pay than manage submodule confusion across a mixed-skill team.

The migration
#

Illustration for The migration 1. Fix the .gitignore first.

We updated the parent site repo to explicitly ignore all of wp-content/plugins/ and carve out exceptions only for our tracked custom plugins:

# Ignore all plugins by default
wp-content/plugins/*

# Track only our custom plugins
!wp-content/plugins/our_custom_shipping_plugin/
!wp-content/plugins/our_mapping_plugin/

This makes the intent explicit and prevents third-party plugins from accidentally creeping into version history.

2. Remove the old tracked copy and import via subtree.

Before importing, we had to remove the existing tracked directory cleanly — skipping this causes the subtree add to fail with a conflict, which I learned the slightly annoying way:

git rm -r wp-content/plugins/our_custom_shipping_plugin
git commit -m "Remove tracked copy of our_custom_shipping_plugin before subtree import"

Then add the plugin repo as a remote and pull it in:

git remote add our_custom_shipping_plugin_remote <internal-repo-url>
git fetch our_custom_shipping_plugin_remote

git subtree add \
  --prefix=wp-content/plugins/our_custom_shipping_plugin \
  our_custom_shipping_plugin_remote main --squash

The --squash flag collapses the plugin’s full commit history into a single merge commit in the parent repo, which keeps the parent’s log readable — otherwise you inherit every commit from the plugin’s history, which is noisy. The trade-off is that you lose per-commit traceability from the parent side, but the full history still exists in the plugin’s own repo, as long as that repo stays accessible.

When it worked, the terminal confirmed it cleanly:

Added dir 'wp-content/plugins/our_custom_shipping_plugin'

Short message, but satisfying.

What this changes in practice
#

Illustration for What this changes in practice The plugin now has one canonical home. When we update shipping logic, we update it once in the plugin repo and pull that change into each site repo via:

git subtree pull \
  --prefix=wp-content/plugins/our_custom_shipping_plugin \
  our_custom_shipping_plugin_remote main --squash

Each site’s wp-config.php supplies its own constants, so the same plugin code behaves differently per brand without any branching in the plugin itself.

The subtree workflow is not zero-friction. The pull and push commands are verbose, and you have to remember which remote is registered in which site repo. We’re documenting it in the repo README so it doesn’t stay tribal knowledge — but compared to manually diffing three copies of a file and wondering which one is current, it’s a real improvement.

The wp-config.php constants pattern is the part worth generalizing: anywhere you have the same codebase serving multiple contexts, configuration should flow in from the environment rather than be baked into the code. It’s easy to skip when you’re moving fast and the second site feels like “just a copy” — but that’s exactly the moment the drift starts.

Related

Building a one-way CMS sync workflow that doesn't bite you later
·1292 words·7 mins
Tech CMS DevOps WordPress Staging
Most CMS staging failures are workflow failures, not technical ones. This covers the design decisions, the bash automation, and the wp_options audit that turned out to be the real work.
When bumping PHP memory isn't enough: tracing serialized cache bloat in a page builder
·750 words·4 mins
Tech WordPress Performance PHP Caching
A WordPress 500 traced to PHP memory looked like a simple limit bump — until the real cause turned out to be a page builder serializing large CSS blobs into the object cache on every request.
That 4 MB `options:notoptions` key is why your WordPress site throws a 500 every ten days
·788 words·4 mins
Tech WordPress Redis Performance Caching
An intermittent WordPress 500 that cleared on refresh turned out to be a single 4 MB Redis key growing without a TTL. Here is what the big-keys scan showed, why the mechanism is easy to miss, and the three config changes that stopped it.