Semantic vendoring
Note: this post is AI-assisted. It was written with Claude Fable 5.
How do you depend on code you intend to modify? Package managers assume you never touch the installed files. Forking gives you full control at the cost of maintaining a second repository. Most engineering practice quietly falls back to a third option, copying the files in and losing the connection to upstream.
Vendoring is the old name for that third option, copying a dependency’s source into your own tree and treating it as part of your project. It trades updates away for ownership, and for decades that trade was final. Coding agents change the terms. It is now practical to vendor code, modify it freely, and still pull every upstream release, because each update can be computed as a three-way merge between the original copy, your edited version, and the new upstream, with a language model resolving the conflicts that the merge machinery cannot. The deterministic part handles everything predictable, and the model reads both sides of a conflict and reconstructs what each change meant. I like to call this practice semantic vendoring, since the updates merge intent rather than lines of text.
I built pi-regraft to work this way. This post develops the general model first and describes the implementation at the end.
The gap between depending and forking
A package manager treats a dependency as an opaque, versioned artifact. The artifact stays pristine, updates are trivial, and the price is that you must never edit it. A fork inverts the trade. You can edit everything, and the price is a separate repository to maintain, an upstream to track, and code that no longer lives in your own tree.
Between the two sits plain copying, which is what actually happens to small things. A parser gets vendored into third_party/, a GitHub Actions workflow gets copied between repositories, an editor plugin or a configuration directory gets cloned from a template. The copies get edited, upstream keeps improving, and nobody ever merges the two again, because merging by hand means re-deriving what changed on each side since the copy was made.
The missing tool would treat copied files as ordinary parts of your repository while remembering exactly where they came from, so that pulling upstream changes without losing local edits becomes a single operation.
The update as a three-way merge
The operation this requires is old and well understood. Call the upstream snapshot that was originally copied B, the locally modified copy L, and the current upstream version U. An update must produce a merged version M that contains both the upstream changes and the local edits:
upstream change
B ---------------------> U
| |
local | |
edits | |
v v
L ---------------------> M
The tempting shortcut is to diff L against U and apply whatever comes out. A two-way diff fails here, because it cannot tell local additions from upstream deletions. A logging line added locally, one that upstream never had, shows up in that diff as something to remove. The updater needs the base B so it can compute two separate deltas, the local changes as the difference from B to L and the upstream changes as the difference from B to U, and then reconcile them into M.
This is precisely the three-way merge Git performs on divergent branches, with B playing the role of the merge base. Vendored code is a divergent branch in disguise. The two sides simply live in different repositories, so no single repository holds the common history, and the merge base has to be recorded by the vendoring tool instead of found by graph traversal.
Two practical consequences fall out of this. First, the tool must record the exact commit the copy came from, since that commit is the merge base and the whole scheme depends on it. Version tags are irrelevant. A commit hash is a perfectly good version identifier. Second, the base must be kept somewhere durable. Once it is lost, no algorithm can reliably separate local edits from upstream changes again.
Textual and semantic conflicts
A mechanical merge resolves most updates outright. When the local edit touches one function and upstream touches another, Git combines them and no intelligence is involved. The interesting residue is the conflicts, and they come in two kinds.
A textual conflict is the familiar one. Both sides changed the same lines, the merge stops, and the file gains conflict markers. A semantic conflict is worse. The merge completes cleanly, yet the result is wrong, because upstream renamed an API the local edit still calls, or changed an error model a local wrapper assumed. A line-based algorithm cannot even detect this kind, since no bytes disagree. The failure only surfaces when the type checker or the test suite runs.
The model’s share of the work
These conflicts are the part of the update that needs a language model. A model can read the upstream change, read the local edit, and reconstruct the intent of both. “Upstream moved retry handling into the client, and the local edit logs every failed request, so the log call belongs inside the new retry loop” is a resolution no line-based algorithm will ever produce.
The division of labor matters more than the model itself. Deterministic code owns everything that must be predictable. It fetches the upstream commit, computes the merge, tracks the recorded base, and refuses to run on a dirty worktree. The model only proposes edits, and correctness comes from the type checker, the test suite, and review of the final diff. Code handles state and safety. The model handles meaning and edits.
Regraft
pi-regraft is an implementation of this model, built as an extension for the Pi coding agent together with a standalone regraft CLI. Adding a graft copies an upstream tree, or a subdirectory of one, into a destination in your repository:
/regraft add https://github.com/example/tool.git@main#extensions/foo vendor/foo
Regraft writes the copied files, records the source and pinned commit in a regraft.json manifest, and commits the result as a pristine base commit on your branch. That commit is the durable B. You then edit vendor/foo and commit normally, like any other code you own.
When upstream moves, /regraft update foo reads the old pristine tree from your branch history, fetches the new upstream commit, and merges the three trees. It commits the new pristine base and restores the merged local version into your worktree. Clean merges leave nothing to do. Conflicted files get normal Git conflict markers, and the agent receives the file list along with any notes recorded earlier with /regraft note, which exist so that the six-month-old reason for a local edit is available at resolution time.
One rule carries the whole scheme. The pristine base commits, named chore(regraft): import upstream base, must stay in branch history. Rebasing them is fine. Squashing or dropping them destroys the merge bases, which is why Regraft treats them as untouchable and fails loudly when one is missing.
The package also includes Regrafter, a dedicated Pi agent that runs updates end to end. It resolves conflicts, runs the project checks, and pauses when an update needs a product decision, such as when upstream behavior and local behavior genuinely compete and someone has to pick. It holds a lease on the repository for the whole run and can resume after a pause without losing the conversation or the repository state.
Nothing in the mechanism is specific to Pi extensions, even though they were the motivating case. The unit Regraft manages is any subtree of any Git repository, so the same machinery covers a vendored parser, a copied CI workflow, or a dotfiles directory grafted from a template. The parts are old. Three-way merges date to the 1970s, and vendoring predates package managers. What was missing was a way to resolve the conflict residue cheaply enough to make “copy it in and edit it” a maintainable strategy, and that part now sits in your terminal.
To try it, run pi install npm:pi-regraft, then /regraft add in any repository with a clean worktree.