pm-vcs
A general version control system written from scratch on the pm SDK for arbitrary files and structured records, with stable file and change identities, native PM attribution, its own object store, refs, merge, operation log and bundles, and no Git dependency.
Install
This package has not been released, so it cannot be installed yet.
README
A version control system written from scratch, in TypeScript, on the pm SDK — for projects whose most important content is structured, not textual.
Not a git wrapper. Not a helper around git merge. pm-vcs has its own content-addressed
object store, its own refs, its own index, its own diff, its own three-way merge, its own
operation log and its own distribution format. No code path in the engine shells out to git,
and nothing it writes is git-compatible.
The scope is the whole system — the storage and history model of git, the rewriting and operation-log model of jujutsu, and the conviction Fossil, Forgejo and lore share that a project's own metadata and its changes under review belong inside the repository rather than in a service beside it. All of it written from scratch on the pm SDK, around one primitive git does not have: the record.
ARCHITECTURE.md is the design document — every decision, why it was made, and an honest per-capability statement of what is shipped and what is still ahead. Read it before the command table if you want to know what this actually is. LORE.md is the first-party-source research mapping Epic Games' general, binary-first Lore VCS into pm-vcs. It is not about the kernel's email archive of the same name.
npm install --save-dev pm-vcs # or: bun add -d pm-vcs
pm package install pm-vcs
pm vcs init
Why write another version control system
Git is superb at what it was built for. It was built for source files, and its merge is a merge of lines.
Almost everything a team's context actually lives in is not lines. A tracker item is a record: a status, a priority, a set of tags, an assignee, an append-only history. Two agents working the same project on two branches routinely touch two different fields of one item. That is not a conflict in any meaningful sense — but whether git can merge it depends on how many lines apart the fields happen to be serialized, which is an accident of the file format rather than a property of the change.
The ecosystem's existing answer is to bolt merge drivers onto git so that .toon items and
.jsonl history get field-aware handling. That works, and pm-cli ships it. But it is a patch
over a mismatch: the storage layer still thinks in lines, and every tool downstream has to be
told not to.
pm-vcs removes the mismatch instead of patching it. A record is a first-class object kind alongside blobs, trees and commits. Merging two revisions of a record compares fields, not lines. Two agents editing two different fields converge, always, with no line-merge hazard anywhere in the pipeline — because there is no line merge in the pipeline.
This is the philosophy the rest of the ecosystem is built on — project management = context management — pushed down into the storage layer. If context is the thing worth preserving, the system that versions it should understand its shape.
Where it sits among the systems it learns from
| versions | merge unit | pm-vcs takes | |
|---|---|---|---|
| git | file trees | lines | content addressing, the commit DAG, the object model |
| Subversion | file trees | lines | nothing structural; a cautionary tale about central state |
| Jujutsu | file trees | lines | the operation log, and undo as a first-class verb |
| Fossil / Forgejo | files + project metadata | lines / a database | the conviction that project metadata belongs inside the VCS |
| Epic Games Lore | arbitrary binary files and repository-scale fragments | storage/view dependent | binary-first storage, stable identity, sparse instances and resumable publication |
| pm-vcs | file trees and records | fields, then lines | — |
Jujutsu's operation log is the best idea in modern version control for agents: every command is recorded, so "put it back" is one verb rather than a reasoning problem about which id was the old tip. pm-vcs has it. Fossil is right that issues and project state belong in the repository rather than beside it — pm-vcs goes further and makes them a native object kind rather than a table in an attached database.
Designed for many agents on one project
Every decision below exists because the expected user is several autonomous agents working concurrently, not one person at a keyboard.
Ref updates are compare-and-swap under an exclusive lock. Two agents that read the same branch tip and both commit on top of it cannot both win. The second write fails, loudly, and says to re-read and retry. A last-write-wins ref update is precisely how one agent's commit disappears with nothing anywhere reporting it.
Identical concurrent edits are agreement, not conflict. Two agents reaching the same conclusion independently is the most common way an automated merge wastes attention. pm-vcs merges it cleanly.
A conflict is scoped to the thing that conflicted. A genuine scalar disagreement on one
field conflicts on that field, and every other field of that record still merges. A
document does not become unreadable because one value disagreed. File-identity disagreements
are reported the same way: independently added paths and divergent renames produce an
identity conflict with a deterministic, structurally valid merged tree instead of aborting.
Merge bases are computed, not assumed. Two branches that have already merged each other once have several minimal common ancestors. pm-vcs finds all of them and builds a virtual base. Picking one arbitrarily is how a criss-cross merge silently reintroduces a change that was already reverted.
Nothing is destroyed before it is known to succeed. A switch that would overwrite an uncommitted edit refuses before writing anything. A half-applied switch leaves an agent with a tree matching no commit and no way to describe what it has.
Objects are never removed, so undo is always possible. Rewinding a ref makes a commit
unreachable, not absent.
Commands
The repository
pm vcs init --record-path '.agents/pm/**/*.toon' --set-field 'tags:set,notes:sequence,updated_at:timestamp'
pm vcs add
pm vcs commit --message "Close the deployment item"
pm vcs log --limit 10
pm vcs diff main feature
| command | what it does |
|---|---|
pm vcs init |
Create a repository. --record-path declares which paths hold structured records; --set-field declares how their fields merge. |
pm vcs status |
The three-way difference between HEAD, the index and the working tree. Stable indexed paths are checked from stat metadata without re-reading content; racy timestamp-window entries are always hashed. |
pm vcs add [paths…] |
Stage paths, or everything. A path that no longer exists stages as a deletion. |
pm vcs commit --message |
Record the index. --item id[,id...] stores validated PM work associations. Refuses an empty commit unless --allow-empty. |
pm vcs log [rev] |
First-parent history, newest first. |
pm vcs diff [from] [to] |
Unified diff between two revisions' trees. |
pm vcs branch [name] |
List, create (--at) or delete (--delete) branches. --remotes adds each remote-tracking branch with its ahead/behind counts against HEAD. |
pm vcs switch <rev> |
Move HEAD and update the working tree. Refuses rather than overwrite uncommitted work. |
pm vcs merge <rev> |
Three-way merge. --fail-on-conflict to gate CI. |
pm vcs tag [name] |
List or create tags. |
pm vcs undo |
Reverse a recorded operation, refs and working tree together. |
pm vcs oplog |
Every operation, with the refs it moved and where from. |
pm vcs export <file> |
Write refs and their history to a bundle. |
pm vcs import <file> |
Import a bundle, verifying every object against its own id. |
pm vcs remote [name] [url] |
List remotes, add one, or remove one (--remove). |
pm vcs clone <url> [dir] |
Create a repository from another one, adopting its record configuration. |
pm vcs fetch [remote] |
Bring a remote's branches onto refs/remotes/<remote>/. Touches no local branch. |
pm vcs push [remote] |
Send branches (--branch). Refuses a non-fast-forward unless --force. |
pm vcs verify |
Re-read every reachable object and check it against its id. |
pm vcs trace <path-or-file-id> |
Trace one logical file across edits, moves, copies and deletion. |
pm vcs files <item-id> |
Resolve a PM item's linked arbitrary files to stable identities and changes. |
pm vcs changes <item-id> |
Report explicit and file-derived stable ChangeIds for a PM item. |
pm vcs items [from..to] |
Report PM items explicitly associated with, or linked to files changed by, native revisions. |
Git interoperability
pm items today mostly live in git repositories, and pm-vcs can reason about that without being git. These three are the only commands that touch git:
| command | what it does |
|---|---|
pm vcs git preflight |
Can this git checkout merge tracker data field-aware? |
pm vcs git preview <ref> |
What would merging <ref> do to tracker data, per item and per field? |
pm vcs git items <range> |
Which pm items did this commit range create, modify and close? |
The part that matters: per-field merge
Declare which paths hold records and how their fields reconcile:
pm vcs init --record-path 'items/*.json' --set-field 'tags:set,history:sequence,updated_at:timestamp'
Two agents, on two branches, edit the same item:
// base
{ "id": "pm-1", "title": "Ship the thing", "status": "open",
"priority": 3, "tags": ["area:vcs"], "history": ["created"] }
// agent A closes it
{ …, "status": "closed", "history": ["created", "closed by A"] }
// agent B retitles and reprioritises it
{ …, "title": "Ship the thing (revised)", "priority": 1,
"tags": ["area:vcs", "urgent"], "history": ["created", "retitled by B"] }
$ pm vcs merge agent-b
merge:
kind: "merged"
clean: true
conflicts: []
{ "history": ["created", "closed by A", "retitled by B"],
"id": "pm-1", "priority": 1, "status": "closed",
"tags": ["area:vcs", "urgent"], "title": "Ship the thing (revised)" }
Every change survived. No conflict markers exist anywhere, because no line merge ran.
Field strategies
| strategy | rule |
|---|---|
scalar (default) |
One side changed it, that side wins. Both changed it differently, it conflicts — alone. |
set |
Both sides' members survive, duplicates collapse, order normalised. |
sequence |
Append-only. Both sides' additions survive in deterministic order. |
timestamp |
Both sides must provide valid timestamps; the chronologically latest value wins. |
A genuine disagreement still conflicts, and says exactly what disagreed:
$ pm vcs merge y --fail-on-conflict
Error: Merging y left 1 conflict(s): items/pm-1.json (status)
priority, tags and history merged. Only status is unresolved.
Records are canonicalised on the way in
A configured record path is parsed and re-encoded canonically when staged, so two agents whose editors disagree about key order or indentation produce one object id. A file whose formatting moved does not register as changed. This is not cosmetic: it is what keeps a reformat from presenting as a conflict.
How it is built
engine/objects.ts SHA-256 content addressing over `<type> <byteLength>\0<payload>`.
zlib loose objects, temp-file-and-rename writes. Reads re-hash rather
than trusting the filename, so silent corruption is detectable.
engine/model.ts Canonical encodings for trees, commits and records. Trees sort by byte
order, never locale collation — a tree must not hash two ways under two
LANG settings.
engine/refs.ts Branches, tags, HEAD (symbolic or detached), compare-and-swap updates.
engine/diff.ts Myers O(ND) line diff, hunk grouping, unified rendering.
engine/merge.ts Commit-DAG reachability, minimal merge bases, diff3 content merge.
engine/records.ts Per-field record merge; append-only log union.
engine/attribution.ts Stable file identities and PM item ↔ file/change history joins.
engine/worktree.ts Versioned index with racy-clean-safe stat caching, working-tree scan,
untracked-safe tree materialization, and three-way status.
engine/ignore.ts An always-ignored set plus `.pmvcsignore`.
engine/config.ts Which paths hold records and how their fields merge — per repository,
so two agents cannot disagree about it.
engine/oplog.ts Append-only operation log; `undo`.
engine/bundle.ts Export and import, verifying every object against its own id.
engine/repo.ts The porcelain.
Repository layout
.pmvcs/
format repository format version
HEAD "ref: refs/heads/main", or a raw object id when detached
config.json record paths and field strategies
index the staging area; updated atomically under index.lock
refs/heads/* branch tips
refs/tags/*
objects/ab/cd… zlib-deflated, content-addressed by SHA-256
oplog.jsonl append-only operation log
The four object kinds
| kind | holds |
|---|---|
blob |
raw bytes |
tree |
sorted entries with mode, object id, stable file identity and copy provenance |
commit |
a tree, parents, author, committer, message, stable change id and PM item associations |
record |
a structured document as canonically ordered fields |
record is the one git does not have, and the reason this system exists.
Safety
pm-vcs is usually initialised inside an existing checkout, so it treats the working tree as something it shares rather than owns.
.git, .hg, .svn, .bzr, _darcs, CVS and node_modules are always ignored and
cannot be re-included — not by .pmvcsignore, and not by a commit whose tree names a path
inside them. Materialization filters the target tree, so history recorded before the rules
existed still cannot write over another tool's state. This is pinned by a test that builds a
deliberately hostile commit naming .git/HEAD and asserts it materializes to nothing.
.pmvcsignore adds project patterns with gitignore-like semantics: # comments, a trailing
slash for a directory, a pattern without / matching by basename at any depth, and ! to
re-include. Staging an ignored path by name is refused rather than skipped — staging
nothing while reporting success is how a commit ends up missing a file.
Distribution
pm vcs clone /srv/project work # adopts the source's record configuration
pm vcs remote upstream ../other-checkout
pm vcs fetch upstream # lands on refs/remotes/upstream/*
pm vcs branch --remotes # what it fetched, and how far HEAD has moved
pm vcs merge upstream/main # the shorthand resolves
pm vcs push --branch feature
A fetched branch is reachable two ways: by its full refs/remotes/upstream/main, and by the
upstream/main shorthand any command taking a revision accepts. A local branch or tag of
the same name always wins that shorthand — the remote-tracking namespace is searched last — so
adding it cannot retarget a name a repository already uses.
branch --remotes reports each tracking branch with how many commits HEAD is ahead of it and
behind it. Both numbers are needed to act: behind 0 is a push that will be accepted, ahead 0
is a fast-forward, and two non-zero counts are a divergence to merge first. An agent that skips
this learns the same fact from a push refusal instead — one round trip to a remote to discover
something both sides already knew. An unborn HEAD omits both counts rather than reporting zero.
Three properties hold, and each exists because the alternative loses an agent's work:
- A fetch cannot move a local branch. Branches it learns land under
refs/remotes/. Importing a bundle wholesale would move the receiver'smainonto the sender's, because a bundle names refs as the sender knows them — so the receiving agent's commits would become reachable from nothing, with nothing in the output saying so. Tags are the exception and keep their own names underrefs/tags, since a tag identifies a point in history rather than one repository's view of a branch; one the receiver already uses at a different value is reported inconflictingTagsrather than moved. - A push cannot discard a commit the remote has. The receiving side requires its current
tip to be an ancestor of what is being pushed, and
--forceis the only way past it. - That check is atomic. Every ref lands as a compare-and-swap against the value the pusher observed, so a push decided against a stale advertisement fails instead of landing on top of whatever arrived in between.
Only missing objects move. The fetching side offers every commit it holds — its branches, its tags, and the tracking refs of every remote — the receiving side keeps the ones it recognises, and everything reachable from that agreed set is excluded from the transfer and declared as a prerequisite instead.
clone adopts the source's record configuration before it writes a single object. A clone
that started from the defaults would store the same paths as blobs rather than records and
merge them line by line: two repositories sharing commit ids while disagreeing about what
those commits mean, each internally consistent and therefore undetectable.
The transport is an interface. The implementation that ships reaches a repository through the filesystem, which is the case that occurs today — several agents, several working trees, one host. A served implementation lands with the forge in Phase 5, when there is a repository service for it to speak to.
Bundles remain, for the times a file is the transport you have:
pm vcs export /tmp/work.bundle --ref refs/heads/feature
pm vcs import /tmp/work.bundle # in another repository
Import reproduces identical commit ids, verifies every object against its own hash before storing it, and fails whole — naming the missing ids — when a bundle depends on history the receiver does not have.
Roadmap
Everything below is tracked as an epic in this repository's own tracker, under
pm-vcs-tr2a. The per-capability status table lives in
ARCHITECTURE.md §11.
| phase | what it adds | epic |
|---|---|---|
| 2 | Change identities that survive rewriting, plus describe, rebase, squash, split, cherry-pick, revert, reset, restore, and automatic descendant rebase |
pm-vcs-ijj7 |
| 3 ✅ | Named remotes, remote-tracking refs, and clone/fetch/push over a transport with reachability-based negotiation |
pm-vcs-wm40 |
| 4 | pm-vcs versioning its own source, with a CI gate proving the tracked history matches the source tree byte for byte | pm-vcs-390t |
| 5 | The forge: a patch series as an object kind, review state as records, and a served repository | pm-vcs-5h6j |
| 6 | Scale: packed storage, a reachability index, shallow and partial history, and garbage collection bounded by the operation log | pm-vcs-b7cb |
Requirements
- Node.js ≥ 22.18 (
engines), tested on 22 and 26 @unbrained/pm-cli≥ 2026.8.1 (peer dependency and host-bound SDK runtime)- Works under
npm/npxandbun/bunx - No runtime dependencies beyond the Node standard library
Development
npm ci
npm run check # typecheck, strict lint, and zero-duplication gate
npm run docstring # every production API declaration is documented
npm run coverage # all production TypeScript at 100/100/100/100
npm run release:check
The c8 coverage gate instruments every production TypeScript file with --all and requires
100% statements, branches, functions, and lines. A never-loaded module therefore fails instead
of disappearing from the report. ESLint rejects unsafe or non-erasable TypeScript syntax, the
docstring gate covers production declarations, and jscpd permits no production clone.
Tests run against real repositories and real filesystems. There are no mocks and no
hand-built api doubles: this is filesystem and merge code, and a fake of either would only
assert against the suite's own assumptions.
Known upstream issues this package works around
- unbraind/pm-cli#825 —
FlagDefinition's index signature defeats excess-property checking, so a misnamed flag field type-checks and then aborts activation, dropping every later sibling command. Flags here uselong/value_name/value_type. - unbraind/pm-cli#826 — an extension command cannot both return a structured report and exit non-zero, and a thrown handler error's remediation is replaced by a generic line. Gate commands therefore throw, with the remediation folded into the message.
- unbraind/pm-cli#832 — pm-cli bundles a
private
pm-vcsexemplar that claims thevcsalias and registersvcs log/vcs merge, with no way for a package author to detect the collision. Enabling both in one workspace is not supported.
License
MIT
From the package's README on GitHub. View on GitHub