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 initGit 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.
| 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.
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.
Ordinary operations retain objects, so rewinding a ref preserves history. Explicit authorized
obliterate permanently removes selected payloads; undo can restore a pointer but cannot
recover obliterated bytes.
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 a file or HTTP(S) remote, or remove one (--remove). remote add <name> <url> is also accepted. |
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) with explicit live FileId leases. Refuses a non-fast-forward unless --force. |
pm vcs lease <acquire|renew|release|break|inspect> <FileId> [remote] |
Manage receiver-owned fenced publication leases (--refs, --ttl-ms, --fence). |
pm vcs serve |
Serve one repository or a directory of repositories over HTTP. --listen, --root and --auth configure the address, root and bearer grants. |
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. |
A committed link is a typed descriptor, distinct from vcs instance and its shared
local store. Obtain the target's Repository.identity() and an exact commit ID, then
write a JSON spec (pretty printing and a trailing newline are accepted):
{"version":1,"repository":"0123456789abcdef0123456789abcdef","revision":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","mappings":[{"source":"assets/model.bin","destination":"vendor/model.bin"}]}The command validates every field using encodeLink and stores its canonical bytes.
Stored link objects still require an exact canonical representation. Invalid JSON,
root types and fields refuse before descriptor or index publication; unreadable spec
files retain their filesystem errors. The descriptor commits metadata only. Exact file mappings declare the subset;
branch movement cannot change the pin. Resolution requires the separately configured
target read credential and creates an instance-private layer. Local target bindings
and credentials are supplied at resolution and never appear in enclosing bundles.
This implementation resolves local target repositories; it does not resolve HTTP
pins or recursively follow nested descriptors.
| command | behavior |
|---|---|
pm vcs authority --principal operator --read-token-file read.token --erase-token-file erase.token |
Initially configure two distinct clone-local version 2 scrypt grants only when the grant is absent. Token files should live outside tracked content. |
pm vcs authority --principal operator --read-token-file new-read.token --erase-token-file new-erase.token --current-erase-token-file current-erase.token |
Replace a valid v2 grant using its separate current erase credential. Proposed new credentials cannot authorize replacement. |
pm vcs authority --principal operator --read-token-file read.token --erase-token-file erase.token --regenerate-legacy |
Explicitly regenerate a recognized historical unversioned SHA-256 grant. This flag cannot replace v2, unknown, corrupt or unreadable grants. |
pm vcs link dependency.link --spec link.json |
Stage a canonical typed descriptor; commit normally. |
pm vcs link --list |
List descriptors without accessing target repositories. |
pm vcs link resolve dependency.link --target ../target --read-token-file read.token --layer dependency |
Verify separate authority, target identity and exact revision, then overlay the mapped files privately. |
pm vcs layer local destination.bin source.bin |
Snapshot one file privately; engine addLayer accepts multiple exact paths. --executable records private mode. |
pm vcs layer --list |
Show names and excluded paths without printing private bytes. |
pm vcs layer local --remove |
Restore the current underlying index; edited or missing overlays require --discard-edits. |
pm vcs gc [--apply] |
Report unreachable objects by default; apply retains refs, oplog, leases, uploads and sparse instances. |
pm vcs obliterate <FileId-or-indexed-path> --erase-token-file erase.token --reason incident |
Erase every historical payload and fragment of the stable identity, with a durable typed audit. Authorized retry resumes pending cleanup. |
pm vcs recover-lock |
Recover a confirmed dead writer or aged empty legacy lock without erase authority. Pending erasure remains denied until authorized cleanup. |
One-sided link changes retain their typed descriptor during merge. Competing pins
raise link_merge_conflict; explicitly stage and commit an agreed descriptor, then
retry the merge. Resolution never silently selects a pin or changes a private layer.
status reports excludedLayers and obliterated separately. Bulk staging skips
layers; explicit staging refuses them. Checkout preserves edited layer bytes while
changing the underlying index. Sparse-view changes and individual restore operations
refuse while layers exist. Removing a layer restores its underlying staged bytes
or removes overlay-only paths. To commit desired overlay changes, remove the layer,
reapply those changes and stage them deliberately.
Erasure inventories unreachable trees, indexes, fragments, valid loose temporary
copies and every registered or previously unlinked shared instance. It refuses
ambiguous copies, affected layers, deduplicated payloads owned by another identity,
missing inventory and unsupported pack/cache artefacts before mutation. Pending denial
is fsynced before deletion and blocks writes until cleanup completes. A crashed
writer's lock can be recovered with pm vcs recover-lock without erase authority.
New locks publish complete PID metadata atomically; empty legacy locks require a
one-minute grace period, and a live or unknown owner refuses. Coordinate recovery
without concurrent recovery commands; pending erasure still needs authorized retry.
Fresh bundles/clones reproduce intentional absence. Remote tombstones cannot erase
held local bytes without local authorization. Stale bundles and novel payloads under
a denied FileId fail preflight before storing bytes or moving refs.
The guarantee covers application-visible loose storage and known shared worktrees;
it excludes external backups, independently owned clones, filesystem snapshots and
physical-media recovery. The filesystem operator must exclude concurrent direct edits
outside pm-vcs. There is no genuine pack backend: unknown storage is a refusal, never
a successful erasure. Payload-only resumable upload batches refuse after erasure
because that protocol carries no FileId provenance. Direct ObjectStore.write calls
must then supply a non-denied FileId for new blob/record/manifest payloads.
Physical erasure inspects retained loose-object copies, including raw frames, zlib and base64 wrappers, before publishing denial. Supported encodings exceeding bounded inspection cause refusal. Windows erasure refuses before deletion because Node cannot provide directory fsync; ordinary metadata uses fsynced atomic replacement with that directory-durability limit. See the shipped composition and erasure design for exact bounds.
Engine APIs live in engine/repo.ts: identity, setAuthority, stageLink, links,
resolveLink, addLayer, layers, removeLayer, obliterate, obliteratedPaths,
readFileState and verify. ObjectStore.state returns the discriminated states
present, obliterated, missing and corrupt; an erased read throws
ObliteratedObjectError with its tombstone ID. See the
shipped design for format and security boundaries.
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? |
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:
$ 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.
| 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.
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.
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/transport.ts Async transport interface, capability negotiation and filesystem receiver policy.
engine/http-protocol.ts Shared HTTP envelopes, validation, denial bytes and transfer bounds.
engine/http-transport.ts HTTP(S) client implementing the same transport.
engine/serve.ts node:http server delegating authorized operations to FileTransport.
engine/repo.ts The porcelain.
.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
| 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 |
manifest |
fixed or content-defined fragment addresses and byte lengths |
series |
portable patch-series metadata |
link |
immutable target repository identity, commit ID and exact file mappings |
tombstone |
terminal FileId, affected addresses and bounded audit metadata |
record is the one git does not have, and the reason this system exists.
pm-vcs is usually initialised inside an existing checkout, so it treats the working tree as something it shares rather than owns.
.pmvcs, .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.
The .pmvcs name is reserved at every depth, including a leaf entry, and in every
case spelling (.PMVCS, .PmVcS). A parent repository tracks a child repository's source
and PM records, while keeping the child's credentials, objects, refs and private state
out of staging, commits and status. Add removes legacy control entries from the index,
including sparse entries; ordinary commits and merge continuation refuse such entries
until they are removed. Stored trees cannot write or remove control paths through
checkout, restore or sparse-view changes, and empty control directories survive pruning.
Filesystem aliases are fenced before content access: ancestor symlinks are never
traversed, and materialization and restore never overwrite a leaf symlink. Staging can
record an ordinary direct leaf link as target text; control targets, dangling control
aliases and link chains are excluded. These protections cannot be negated. For a
protected explicit add or restore, the command refuses with path_ignored.
On Linux with available procfs descriptor paths, materialization, restore and pruning
pin directories across concurrent ancestor replacement. Other platforms, and Linux
without usable procfs, use a portable fallback that verifies every captured ancestor's
device/inode identity with lstat immediately before and after each mutation. A detected
directory or leaf replacement refuses with worktree_path_changed. Exclusive file
creation uses O_NOFOLLOW where available; leaf checks and descriptor identity checks
also apply on Windows. Writes and chmod use fresh file descriptors, and deletions are
non-recursive. The portable fallback narrows but cannot close the race between the
final verification and a pathname syscall, because Node exposes no openat; a refusal
can occur after a redirected mutation. A refused operation can leave ordinary worktree
files partially changed; its replacement index is not published. See
working-tree mutation protection for the exact limits.
.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.
Served pushes re-hash every arriving object and verify the complete reachable closure before
publishing refs. The same fast-forward and compare-and-swap rules apply to file and HTTP
remotes. Native .agents/pm/history/*.jsonl histories that preserve their base prefix merge
through an append-only multiset union: each byte-identical event keeps the base count
plus max(left_extra, right_extra). Cherry-picked or doubly merged events replay once;
two identical appends on one branch remain two. Base bytes and timestamp ordering are
preserved, and whitespace-distinct lines remain distinct. Rewritten prefixes retain
conflict handling. See merge semantics for the
line boundaries, ordering and separate PM hash-chain reconciliation contract.
Authorization runs before repository access. Missing repositories, invalid tokens, wrong repository scopes and unsafe names return identical HTTP response bytes, including headers. A read-only token receives 403 on write endpoints only when its repository exists and is readable. Traversal spellings, symlink repository/control aliases and linked instances are refused. Operators own the served filesystem: this is a repository authorization boundary, not protection against an operator changing the filesystem during a request.
Requests are limited to 512 MiB on write endpoints and 16 MiB on read endpoints, 10,000 fetch refs or ref updates, 100,000 haves or objects,
and 4,096 upload sessions per repository. Old sessions are evicted; objects remain available
for retry, but an evicted session loses its delivery receipt. Upload sessions are also bounded
cumulatively — 1,000,000 accepted objects and 4 GiB of decoded object bytes between publications,
configurable through the server's limits — because a reused session keeps every receipt it has
accepted until it publishes. Past either cumulative bound the upload is refused with
limit_exceeded, the session's receipts are released with it, and a publication under that
session id afterwards reports a fresh session's empty receipt. A publication clears the charge
with the receipts, refused publications included. Tokens load at startup and
require a restart to rotate. Without --auth, served repositories are read-only unless
--allow-unauthenticated-writes is passed.
Keep the default loopback binding for local use; use TLS termination when carrying bearer
tokens beyond loopback. The TLS-terminating proxy must forward Host as the bound server
address and port, for example nginx proxy_set_header Host 127.0.0.1:<port>; for a loopback
bind. Replace <port> with the server's listening port; forwarding the public hostname
instead fails host validation and returns the fixed denial. Token-bearing remote URLs are
sensitive local configuration; do not publish them. Client connection errors redact
credentials and redirects are refused.
Serve an existing repository in the foreground:
pm vcs serve --root project --listen 127.0.0.1:0 --auth tokens.jsonThe command reports the chosen ephemeral port and runs until stopped. A repository passed
as --root is available at the base URL. A parent root exposes its repositories by relative
name, including nested names such as tenant/project. At startup the server enumerates
repository directories into a catalogue of canonical names and real paths. Requests resolve
only by exact catalogue lookup; request text is never joined into a filesystem path. With
authentication enabled, requests without any candidate repository in scope are denied before
catalogue access. A request
path that also reads as an endpoint — tenant/objects/fetch is either the repository
tenant/objects with endpoint fetch or the repository tenant with endpoint objects/fetch —
resolves among the candidates the token is in scope for (every candidate without
--auth), and the deepest catalogued one wins. A repository whose name shares a segment
with an endpoint suffix is therefore reachable under its own name, and an out-of-scope
nested repository never changes the answer to an in-scope parent. An
authorized lookup of an unknown name triggers at most one rescan per second. Each scan
examines at most 10,000 directory entries and descends at most 32 levels; repositories beyond
those bounds receive the same denial as unknown names. Use a narrower root for larger trees.
Symlink aliases, escaping symlinks and linked instances are refused, including aliases
introduced after discovery. The tokens file is a JSON array:
[
{ "token": "example-read-token", "repository": "", "access": "read" },
{ "token": "example-write-token", "repository": "", "access": "write" },
{ "token": "example-tenant-token", "repository": "tenant/project", "access": "write" }
]The empty repository name grants the root repository. A write grant includes read access; all other repositories are denied. Replace the example tokens with private random secrets.
Without --auth, every repository is served readable: fetch and clone work for anyone who
can reach the port, and every mutating endpoint refuses until the operator passes
--allow-unauthenticated-writes — the explicit override for a port fenced another way. The
server is an agent transport, not a web service, so it also refuses requests a browser would
send: any request carrying an Origin header, any request whose Host is not the bound
address and port (which is how a DNS-rebound page would read an answer it is not allowed to read), and
any mutating request whose content type is not JSON (the one shape a cross-site page can send
without a preflight this server never answers). A wildcard bind accepts address literals
in Host at the bound port; reach such a server by its address, or bind to the name you use.
For a reported port of 43210, another agent uses the usual commands:
PM_VCS_TOKEN="$WRITE_TOKEN" pm vcs clone http://127.0.0.1:43210 agent-a
pm vcs remote add upstream http://127.0.0.1:43210
PM_VCS_TOKEN_UPSTREAM="$READ_TOKEN" pm vcs fetch upstream
pm vcs merge upstream/main
PM_VCS_TOKEN="$WRITE_TOKEN" pm vcs push origin
pm vcs verifyUse environment variables populated from your secret manager. PM_VCS_TOKEN_<REMOTE>
(uppercase remote name, punctuation replaced with underscores) overrides PM_VCS_TOKEN,
which overrides the stored credential. Environment credentials are never persisted.
A non-empty PM_VCS_TOKEN is not host-scoped: a clone sends it to the host in the supplied URL
(it takes precedence over URL userinfo), so set it only when every clone target is trusted; prefer
the per-remote PM_VCS_TOKEN_<REMOTE> form.
A non-empty bearer token is only ever sent to https:// remotes, or to a loopback http://
remote — a token on plain http:// to any other host is refused with an error naming the
fix, because it would be a credential handed to every observer on the wire.
For compatibility, URL userinfo supplied to clone or remote add is moved into the local
.pmvcs/credentials.json; .pmvcs/remotes.json, receipts and diagnostics contain clean URLs.
Existing credential-bearing remote maps migrate on first read. The credentials file is
created with mode 0600 and existing permissions are repaired on read. If repair fails with
EPERM or EROFS, reading is allowed only when no group or world permission bits are set;
otherwise the error is raised. File fetch/push does not read bearer credentials. Windows does not
enforce POSIX mode bits; restrict access with filesystem ACLs or use environment tokens.
Removing a remote also removes its stored credential. Rotate any previously exposed tokens;
migration cannot erase old transcripts or operation logs.
The native VCS always excludes the PM tracker's runtime caches, search indexes, locks,
transactions and checkpoints using the SDK's runtime fence and resolved tracker root.
search/eval-queries.json remains eligible for tracking. These exclusions apply to status,
staging and materialization and cannot be overridden by .pmvcsignore negations; the next
add removes runtime entries left in an older index. Item records and history remain tracked.
Clone adopts the same record configuration as a file clone. Ref advertisement, bundle fetch,
verified object upload and publication use the existing transport capabilities. The optional
object-fetch capability adds Transport.fetchObjects(ids), returning a verified bundle of
individually named objects, including standalone patch series. Review records travel with
ordinary trees and commits. A series consumer fetches its base and patch history as well as
its series object before importing the series bundle.
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 featureA 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 with two shipped implementations: a filesystem transport, which
is the case that occurs today — several agents, several working trees, one host — and HttpTransport, an HTTP
transport that speaks to pm vcs serve (see Serving a repository).
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 repositoryImport 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.
Depth-limited and blob-filtered clone/fetch, demand hydration, and conservative GC are documented in scale history and collection.
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 |
- 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
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:checkThe c8 coverage gate instruments every production TypeScript file at the root and under
src/, engine/ and scripts/ with --all. It requires exactly 100% statements, branches,
functions and lines for each file, including self-hosting, bundle handling and the gates
themselves. A never-loaded module fails instead of disappearing from the report. The command
selects package.json explicitly so an ambient c8 configuration cannot narrow the inventory.
Collection uses the project's own coverage/tmp directory: a nested c8 process must not
inherit and clear its parent's raw records. A real marker-file regression checks that boundary.
ESLint uses the strict policy published by the pinned pm-ops package across production
code and tests. It rejects explicit any, dynamic and inline type imports, non-erasable
syntax and repository convention violations. The docstring gate uses pm-ops/docstrings.
The duplication launcher uses pm-ops/duplication with the pinned jscpd 5 binary, a zero
threshold, a 50-token floor and a one-line floor. It enumerates all production sources,
reconciles the detector's scan count at a one-token floor, and rejects empty or incomplete
scans. Detector subprocesses run outside consumer configuration. The optional fast-glob
peer required by this pinned fleet analyzer is installed explicitly.
test/exact-gates.test.ts runs real processes against disposable projects to prove omitted
sources, untested branches, forbidden syntax, short clones and hostile configuration fail
closed. It also invokes the operational scripts directly. release:check includes lint,
duplication, docstrings and coverage; prepublishOnly requires that complete release gate.
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.
- 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.
MIT
Publication requires explicit stable FileId leases, including renamed, deleted and historical files. Large fragmented content uses bounded manifest hierarchies; legacy flat manifests remain readable through verified bounded streams. See publication lease workflows and manifest API contracts.