opsinjs
HandbookRegistry & distribution

Upgrades and diffs

How to take an update to code you own and have already edited, without either losing your changes or silently keeping a bug.

Overview

In a copy-not-dependency model there is no npm update. A fix here does not reach you; you go and get it, into a file you may have edited, in a codebase somebody else now maintains.

This is the central cost of the distribution model and it is worth taking seriously rather than discovering during an incident. The good news is that it is a merge problem, and merge problems have known tools. The bad news is that nothing will tell you an upgrade exists unless you build the habit of asking.

Which version do I have?

There is no lockfile here, because there is no dependency to lock. Nothing in an installed file records where it came from, and no tool can reconstruct it after the fact. The record that exists is your own commit history, so make it carry the answer: run add in its own commit, with nothing else in the diff, and put the item name and the date in the message. Your own changes go in the next commit. That one habit is the provenance of every copied file you own. It gives a later three-way merge a base to work from, it tells a reviewer which hunks arrived from upstream and which are yours, and it is the only thing a colleague can read a year from now to find out what you took.

npx shadcn@latest add @opsinjs/<item> --diff is a different question and not a substitute. It tells you how your file differs from upstream today. It cannot tell you which upstream you started from, so it cannot separate a local edit from an upstream change you have not taken yet. The commit answers where you started; the diff answers where you have drifted to.

The upgrade loop

Find out what changed

Read the changelog, then read the commit that installed the file. Those two are the whole answer. Do this before touching anything; an upgrade you cannot describe is one you cannot review.

Diff before you fetch

npx shadcn@latest add @opsinjs/result-card --diff

This shows what would change in your file without writing anything. It is the single most useful flag in the CLI for this model, and the one people discover last.

Decide per hunk, not per file

Some hunks are upstream fixes you want. Some are upstream style changes you do not care about. Some collide with a deliberate local change. Treat them separately. The temptation to accept or reject the whole file is what produces both lost fixes and lost customisations.

Take it

npx shadcn@latest add @opsinjs/result-card --overwrite

Then re-apply your local changes from the diff you just read. If your local changes are extensive, a three-way merge is better. See below.

Re-check

Type-check, run your tests, and re-run the accessibility and contrast checks that apply to your project. An upgrade that changes a token pair changes a measurement.

Three-way merge, when you have edited a lot

--overwrite is a two-way operation: upstream replaces yours. When you have made substantial deliberate changes, do it as a proper three-way merge instead.

The three sides are: the upstream version you originally copied, the upstream version you want, and your current file. Any merge tool can take it from there, and the result is a merge with conflicts you resolve rather than a choice between two whole files.

This only works if you can identify and obtain the version you originally copied, and nothing in the file tells you which that was. The commit that ran add is the record, which is why it earns a commit of its own. It is also the argument for opsinjs serving historical items rather than only the latest.

git merge-file --diff3 mine.tsx base.tsx theirs.tsx

Make upgrades reviewable

Four habits that make the difference between a system that can be updated and one that quietly ossifies.

Commit the install. In its own commit, as set out under Which version do I have? above. It is first here because the other three are recoverable without it and nothing recovers it.

Keep local changes small and marked. A comment saying why the local change exists survives a merge; a silent divergence does not, and the next person will "fix" it back.

Prefer composition over editing. Wrapping a component, or passing different content, produces no merge conflict at all. Editing its internals produces one every time. The system is designed to be edited, but not editing it is still cheaper.

Schedule the ask. Nothing pushes an update to you. A quarterly check of what has changed upstream is a small habit that prevents a large migration.

What an upgrade can break

No upgrade path has been exercised in anger: every catalogue row is still since: unreleased, so there is not yet a second version of anything to upgrade to. The list below is the set of hazards the design anticipates, and it is a specification of what upgrade notes will have to cover rather than a report of observed breakage.

  • A token pair moved. Your contrast measurements are stale. Re-run them.
  • A data attribute changed. If you styled against data-status, an attribute rename is a silent visual regression, not a type error.
  • A part was renamed in a compound component. Wrong nesting produces a component that renders and does nothing, which is worse than one that throws.
  • A dependency was added. registryDependencies grew, and add installed something else into your tree. Read the install output.
  • A clinical default changed. The rarest and the most serious. Any such change carries a changelog entry with a safety note; it is worth reading the changelog for these alone.

Verify it worked

The diff is empty

Re-run add --diff after upgrading. It should report no differences except your own deliberate local changes.

The install is recorded

Your commit should name the item and the date you took it, because the file will not. That record is what the next upgrade's three-way merge needs, and reconstructing it afterwards is guesswork.

Your local change is still there

Search for it. This is the check people skip and the one that catches a lost customisation.

Troubleshooting

--diff reports the whole file as changed. Line endings or formatting. Normalise both sides before comparing, and consider whether your formatter runs over installed files.

--overwrite destroyed a change I needed. Recover from git. Then adopt the commit-the-install-separately habit, which makes this recoverable by construction.

I cannot tell which version I originally copied. Nothing in the file says, so unless your commit history does, there is nothing to read. Diff against the current upstream and accept that this one is archaeology.

Upgrading one component pulled in three others. registryDependencies. Composition is explicit, and this is what explicit looks like.

Next

On this page