Repository navigation
tether 0.1.0a10
Pre-release
Pre-release
0.1.0a9 was tagged in history but never published; a10 is the first release
carrying both sets of changes. Datasets created with a8 need tether upgrade
(v3 and v4 migrations; working tree only, no history rewrite).
Added
- The op log is a journal. Store-writing commands (
commit,pull,
new, the lazy fork,gc,promote,restore,repair) write their
entry -- plan and whatundoneeds -- and sync it before the first side
effect, then a completion mark with the result. An interrupted run leaves an
INCOMPLETEentry:opsflags it,undoskips it (and says why when
named),repair --dry-runlists it with whatgcwill collect
(Repo.incomplete_ops()). - One writer per checkout. Writing commands hold
.tether/lock(flock,
re-entrant perRepo) so twotetherprocesses cannot interleave journal
entries andworkspace.tomlwrites. - Branch scope.
ObjectBackend.branch_scope(locator)names the resource
that owns branches andref_namespace(locator)the one whose pins
list_pinsreturns (both default to the canonical identity; Neon: the
project, Iceberg: the table).newforks one branch per scope under a
bookmark -- the first member forks, later membersshareit, a member that
pins a different state of the same branch is refused -- andgccollects
the pins every object references per namespace. Pin.created(runtime-only): whetherpin()made the ref or found it
already carrying the state. The conformance suite checks both answers.DiffEntry.why: which ofstate,pin,locator,policydiffer. A
locator- or policy-only change ischanged(CLI:[policy changed; same state];--jsoncarrieswhy).tether backendslists kinds with maturity, tier, and capabilities;
tether --version. Backends declareMATURITY(stable: full lifecycle
against the real system in CI;experimental: tested against a fake of a
network service -- neon, lakefs, ducklake, dolt);addnotes an
experimental kind.ObjectBackend.LOCAL_PATH_KEYS: locator keys that may hold a local path.
The git backend runs the shared conformance suite.ObjectBackend.state_addressable(locator, state): whether a particular
recorded state can be reopened (thefilebackend says no for a remote
object without a version id);commitrecords such a state
recoverable = falseand says why in the plan.VcsAdapter.history_digest(): a digest of every visible commit id, across
workspaces and bookmarks;gcplans bind to it.- A repository-wide lock.
commit,pull,gc,undo, andabandon
holdtether.lockin the store every checkout shares
(VcsAdapter.shared_dir(): git's common dir, jj's repo dir), waiting up to
Repo.REPO_LOCK_TIMEOUT, so a gc in one workspace cannot race a commit in
another between deciding a pin is unreferenced and releasing it. - Progress records. Every side effect of a journaled operation appends a
record (mark_progress;OpEntry.progress): each pin and the VCS commit of
acommit, each fork ofnew/restore, each unpin and deletion ofgc,
each system apromotelands, each repin/refork ofrepair.repair --dry-runlists what an incomplete operation got done, how many actions
were planned, and the re-run contract (running the command again finishes
what is left).
Changed
- Config v4 (
tether upgrade; working tree only, no history rewrite).
Manifest paths append.tomlto the key's last segment instead of
replacing its suffix, sofooandfoo.barno longer share
objects/foo.toml(the migration moves misplaced files; a collision that
already destroyed a manifest is reported). Keys are validated: no empty
segments,./.., leading slash, or control characters. Relative local
paths in locators are resolved against the dataset root (the migration
rewrites them);addandimportresolve a relative path against the
caller's directory and store it absolute. - A pin is verified before it is read or forked.
open --rev,newfrom
a commit, andpromote --revcheck the pin against the manifest's state:
a deleted pin falls back to the recorded state where the backend can
address it; a moved pin raisesPinDriftError(arefusein a promote
plan).repairnever overwrites a drifted pin. - Destructive steps re-check the ref they act on. Plans record the head
of every branch they delete or reset; apply reads it again immediately
before the step and stops withStalePlanErrorwhen it moved:gc
delete-branch(gc plans are also bound to the VCS head and manifest
hash),new's reuse/reset (plus a re-run of the bookmark-holder guard and
a same-workspace check),restore's reset,promote's source,repair's
refork (the branch must still be missing). A lazy fork resets an existing
branch only onto the headnewreviewed (workspace.toml
pending_resets), reuses a branch already at the pin or one a scope
sibling writes through, and otherwise refuses.gcplans are bound to the
digest of all visible history (a commit made in another workspace can
reference a pin the plan would release) and check every branch head before
the first action, so a stale plan does nothing at all;promote --rev
verifies a pin source still names the reviewed state; a forknewplanned
as fresh is re-checked for absence at apply, andnewrefuses outright
when the backend cannot list branches (unknown is not absent). commitcompensates as a unit. A failure after the pins -- manifest
write, listing, VCS commit -- releases only the pins this commit created
(never a reused one), restores the manifests and listings it wrote, and
journals the attempt as failed and rolled back. If the VCS commit landed
before the adapter raised, nothing is rolled back: history names the pins,
so the operation completes as a commit that succeeded and the trailing
error is surfaced (failed_after_commit).restoreandpromoteare closed over the branch scope. Restoring one
of several keys that write through one branch is refused (the message names
the siblings to include); with all named, the branch is reset once and the
siblingsshareit.promotefast-forwards a shared branch once instead of
once per key.- The writer lock also covers
add,remove,set,import,abandon,
forget-workspace, andupgrade;undojournals before it acts (a refused
undo is recorded as failed). Taking the lock re-readsworkspace.tomland
the manifests, so a long-livedReponever writes the state it loaded at
construction over what another process wrote since;snapshotwrites its
cache under the lock. undocompletes atomically: the undone mark rides in the done record (one
append), and a handler refusal that touched nothing ends the entry as a
failed attempt rather than leaving it started.promote KEY...refuses a subset that leaves unnamed siblings whose base
branch the write would move, whatever the source (a working ref, or a pin
by--rev), asrestoredoes.promotefast-forwards and merges from the state the plan reviewed, not
the source ref's current head: what lands is what was shown, whatever the
timing.ObjectBackend.mergetakesstr | Pin | Statelikepromote
(git, lakeFS, Dolt, and memory resolve a state to its commit). The inline
convenience methods (commit,new,promote,restore,gc,import)
plan and apply under one checkout lock, andnew/promote/restore/
importre-verify at apply (commitdoes not need to: a pin names the
captured state). The CLI's immediatetether commitgoes through
Repo.committoo; only--dry-run/--planbuild a separate plan.gcplans take the history digest before walking history, so a commit
landing during the walk stales the plan instead of slipping between the
references and the digest.restorechecks every head before the first reset and writes the
workspace after each one (with the siblings sharing the branch), so a kill
between two resets leaves each reset branch described as such.apply_committurns a planned key that is no longer registered into
StalePlanError(rolling back the pins it made before reaching it) rather
than aKeyError.promote --rev's scope closure and base-head check work from the kind and
locator the plan captured, so an object removed from the working tree since
the revision still refuses an unnamed sibling and a base that moved.snapshotandpullrun whole under the checkout lock: which refs to read
is decided from the workspace as it is on disk, not from the one a
long-livedRepoloaded.newwritesworkspace.toml-- bookmark set, every fork pending with the
reset it agreed to -- right after moving the VCS and before the first store
write, so a process killed in the fork fan-out leaves exactly a lazynew;
a fork that fails in the fan-out stays pending instead of being forgotten.gc: a branch that moved after the preflight (a race, not a stale plan)
is kept and reported while the rest of the plan finishes; a--force-prune
plan that could not read a head refuses at apply if the head reads now.- Pre-v4 manifests read from history resolve relative local paths against
the dataset root, the rule the migration applies to the working tree. promote's guarantee is stated as it is: a bookmark is planned whole or
not at all; once applying, each system's fast-forward stands on its own.set --pin recordon a pinned object takes effect at the next commit (the
pin is dropped;gcreleases the tag once no commit names it);--pin nativecreates one again. Before, the "unchanged" shortcut kept the pin.file:--file versionedmakes only a single remote object Addressable;
a recorded object state without a version id (unversioned bucket) is
refused byopenand reported byverify. The content-hash cache keys on
ctime_nstoo and re-reads entries hashed within the same second on
filesystems with whole-second mtimes.- Backends:
unpinanddelete_working_ref(git, lance, lakefs, icechunk,
neon) raise when the ref is still there afterwards instead of reporting
success; Lance says why a tagged branch stays.delta/icebergread-only
openof an object registeredata version sits there, not at the head;
icebergopen(Pin)raises for a missing tag.dolt.ancestor_ofreturns
unknown when the log is truncated.lakefsforkresets a branch with
staged uncommitted objects.memory.historywalks the head's parents.
neonfollows branch-list pagination, lifts the protection before deleting
a pin, and no longer calls a pin drifted for the read-only endpoint tether
attaches to serveopen. publish.ymlruns lint, ty, and the suite, checks the tag against the
package version, and smoke-tests the built wheel beforeuv publish.
Fixed
commitrolled back pins it had not created:pin()is idempotent, so a
pin an earlier commit (or a sibling key on the same system) made was
released when a later pin in the same commit failed.open --rev,new, andpromote --revtrusted a pin's native ref; a tag
moved by hand returned the wrong data under a commit's name.- Two keys on one native branch space (a Neon project, one memory system)
each forked the shared bookmark branch from their own pin: opening the
second reset the first's writes.gcgrouped references per identity while
list_pinslists a whole project, so one object's sweep released its
neighbours' pins. - A saved
gcplan deleted a branch that had gained writes since planning;
apromotelanded a source that moved after review. Repo.diffreported an object unchanged when only its locator or policy
differed.- Relative paths in locators were resolved against each command's working
directory, so the same manifest addressed different files from different
directories. The git backend left the CLI's positionalurirelative. - The v4 migration rewrote a relative locator before moving a misplaced
manifest, leaving two files for one key; it recorded v4 after reporting a
manifest collision (it now stops, like v3 on a failed re-fingerprint); and
a collision found half-way left earlier moves in a dirty tree that blocked
the rerun (every destination is checked before anything moves). snapshotfrom a staleRepocached one bookmark's states under another's
name: it chose which refs to read before the lock refreshed the workspace.snapshotfrom aRepoconstructed before another process moved the
checkout to a bookmark rewroteworkspace.tomlwith the old bookmark and
refs.undo's two-append completion could leave a finished undo whose target
still counted as undoable.--file versionedrecorded a state with no version id as recoverable
althoughopencould not read it back.- The wheel smoke test in
publish.ymlinstalled the wheel without thecli
extra the entry point needs.
[0.1.0a9] - 2026-09-11
Added
- Bookmark-shaped branches. The dataset's jj/git bookmarks and the
stores' branches are now one shape. The trunk bookmark (main;
[vcs] trunk) stands for every object's upstream branch (locator.branch):
working on it writes there. Any other bookmark stands for one branch per
Forkable system, named after it --tether.ws.<dataset>.<bookmark>--
forked from the pins of the commit it started at. A working copy on no
bookmark is read-only. In detail:tether new -b NAME [REV]creates a bookmark and its branches (lazily, as
before);tether new NAMEjoins one;tether new REVtakes the bookmark
at that commit or goes read-only. A bookmark another live checkout works
on is refused unless--shared.initcreates the trunk bookmark (jj) or
adopts HEAD's branch (git) and starts there.WorkspaceState.bookmark.commitmoves the bookmark onto the new commit -- in the same jj
operation, sojj undotakes both back -- and refuses when the VCS
working copy has left the bookmark, or (jj) when the bookmark is behind
the working copy's parent, the one position from which jj can carry it
along in that operation;tether new NAME --keepputs the working copy
back without touching branches.undoof anew -bdeletes the
bookmark it made.abandonmoves a bookmark off a dropped commit to the
nearest kept one instead of losing it (jj deletes them).tether pull [BOOKMARK]is the fetch: it reads the heads of the bookmark's
branches -- on the trunk every upstream branch and every branch-less
object -- pins what moved, and commits it on the bookmark, which moves.
Nothing moved: no commit.PullReport(bookmark,committed,
unchanged,skipped,vcs_commit,pinned). Replaces the held
[pulled]workspace state, thepulledstatus label,commit --pull,
and[commit] pull.promotealso moves the trunk bookmark to the bookmark's commit when
every object fast-forwarded and nothing was refused
(PromoteReport.trunk_moved); after a merge, commit and promote again.gc --prune-bookmarks(was--prune-workspaces) judges the branches of
bookmarks the VCS no longer has and no live checkout works on, legacy
per-workspace branches, and this bookmark's unused ones, with the same
verdicts;--keep-bookmark NAME.forget-workspaceremoves state files
and forgets the checkout only: branches belong to bookmarks.statusnames the bookmark (on trunk bookmark main;on no bookmark: read-only) and warns when the VCS deleted, renamed, moved, or left it,
with what to do (Repo.bookmark_drift,StatusReport.bookmark,
.trunk,.bookmark_drift).VcsAdaptergainsbookmarks,bookmark_set,bookmark_delete,
current_bookmarks,new_bookmark,is_ancestor, and
commit(advance=);ObjectBackend.base_branch;working_ref_name(dataset, bookmark),working_ref_bookmark,bookmark_slug.
tether set KEY... | --all [--file] [--pin]changes a registered
object's policy in place (Repo.set_policy,SetReport): manifest-only,
logged, undoable. Until now this tookremove+add(losing the
committed state) or animportfrom a registry.- A Caveats and Performance guide, holding what the README used to: the
limits of the model (per-system promotion, no cross-system atomicity, what
undocan and cannot do, pin-then-commit ordering, lazy forks, storage
cost, secrets), the per-backend caveats, and the performance notes. The
backends guide gains the content-diff table. The README is a third of its
former length: capability table, prior art, install, a short quickstart,
layout, non-goals, development. - A Use Cases guide: seven scenarios -- reproduce an analysis months
later; reprocess on a branch then land or discard it; A/B two candidates
and keep one; catch drift nightly without a watcher; publish history to a
registry; keep the data bill down; recover -- each as the commands you run,
what they guarantee, and the guide that explains the mechanics. The README
lists them. Later guides are renumbered (07-cli...11-extending).
The scenarios are one running story, withjj logafter each step, and
the whole page is executed, not written:tests/test_use_cases.py
runs every command against local Icechunk, Lance, file, and git objects
(and the ephemeral Postgres the publish tests use; without one the story
stops before section 7) and writes the command and output blocks the guide
includes (user_guide/_generated/06/), with per-run ids replaced by stable
stand-ins and the operation log on a fixed clock; the test fails when a
fresh run differs, andTETHER_UPDATE_DOCS=1rewrites the files. Running
it found the fixes below.
Changed
promotelands a bookmark whole or not at all. It used to fast-forward
the systems it could while refusing the others -- the executed guide showed
Icechunk'smainmoving while Lance was refused, so readers ofmainsaw
new labels with old features, and the trunk bookmark could not follow.
Now, when any object is refused and no keys were named, the rest are
held(PromoteReport.held, printed with what each would have done) and
nothing is written.tether promote KEY...lands a subset on purpose; the
trunk bookmark never moves for a subset, since the rest has not landed.
Exit status is still1on refusals.[new] auto_forkis gone. It re-rannewafter every commit to give
jj's "fresh working copy" rhythm; sincenewreuses a branch that already
sits at the pin, it had stopped doing anything.commitleaves working
branches where they are, and the docs now say so (Concepts: "Where tether
is not jj").- The
writepolicy is gone. Whether writes fork a branch or land on
the upstream branch waswrite = fork | direct(formerlytrack) per
object; it is now which bookmark the working copy is on.Policyisfile
andpin;--write,set --write,[defaults] write, thepolicy_write
registry column, andWriteModeare removed. A manifest carryingwrite
is read and ignored, and the v3 migration (tether upgrade) drops the line
from the working tree alongside the content-hash re-fingerprint. Neon
databases that must receive writes onmainare written on the trunk
bookmark. tether statusis local by default. It shows the last snapshot of each
object's state with its age (states as fingerprinted 2h ago; --snapshot to refresh) and contacts nothing, so it is cheap enough to run as often as
jj status.--snapshotfans out and fingerprints first; a workspace with
no snapshot yet always does.[snapshot] autonow defaults tofalse
(truerestores fingerprinting on everystatus;--no-snapshotwins).
verifyalways fingerprints regardless of the setting;commitfingerprints
the bookmark's branches.
StatusReportgainsfreshandsnapshot_at, and the JSON output the same.- Local files are fingerprinted by content hash, not mtime. A file's state
is{size, sha256}and a directory's digest is over its files' sha256s, so
touch,cp, a fresh checkout, or an rsync no longer read as drift (an
error under the defaultimmutablepolicy) or mint a new state. Hashes are
cached by(size, mtime_ns, inode)in the untracked
.tether/cache/file-hashes.json-- the git/DVC pattern -- so the first
fingerprint of a tree reads every file and later ones read only what
changed.ObjectBackend.configure_cache(dir)is the hook the engine calls
so a backend can keep such scratch state. Listing tokens for local
directories are sha256s (weresize:mtime_ns). Breaking: manifests
from earlier alphas hold mtime-based states that would read as drift -- an
error under the defaultimmutablepolicy -- so[tether] versionis now
3 andtether upgrade(v3 migration) re-fingerprints every localfile
object in the working tree and rewrites its manifest; remote objects and
history are untouched.
Fixed
ForgetWorkspaceReportwas listed intether.__all__but never imported,
which broke the docs build; a test now checks__all__against the module.tether diff REV(one revision) compared REV with nothing and reported
every objectremoved; it now compares REV with the working tree, as its
help always said.tether diff --contentbetween two Icechunk snapshots on different
branches failed with Icechunk's "ancestry doesn't include" error, since it
diffs along one line of history. The backend now finds the snapshot the two
diverged from and reports what either side changed since it (a node only
one side touched keeps that side's change, inverted for thefromside;
one both touched ismodified), with a note naming the base.- Fingerprinting several local
fileobjects at once (commit,status --snapshot,pullfan out concurrently) could fail withNo such file or directory: .file-hashes.json.tmp: the shared content-hash cache was written
by two threads through one temp file. The cache is now locked around its
table and its save; hashing itself still runs in parallel. - A deleted Icechunk pin could never come back: Icechunk keeps a tombstone
for every deleted tag and never lets the name be reused, and pin ids are
content-addressed, sorepaircrashed withRefNotFoundErrorand any later
commit recording that same snapshot for that object failed too. The pin id
stays content-addressed; the ref moves on: the Icechunk backend walks
generations --tether.<id>,tether.<id>.2,.3, ... -- past tombstones
(a name carrying a different snapshot is still an error), andverify,
open,fork, andpromoteresolve a pin through the newest live
generation when the manifest's ref is gone, so manifests and history stay
as they are (verifysayspinned as tether.<id>.2).list_pinsfolds
generations onto their id andunpinclears them all, sogccounts one
pin. Until a repair,Repo.open(at a revision or at the object's
position) andnew's fork fall back to the recorded state when the pin's
native ref is gone and the backend is Addressable. tether new -b NEWfrom a commit on bookmarkOLDhandedNEWthe store
branches ofOLDwherever they sat exactly at the pin ("already at pin;
kept"), so two bookmarks shared a branch andNEW's writes landed on
OLD's -- a leftover of the per-workspace naming.newnow looks only at
the branch named after the bookmark it is starting: kept if it exists at
the pin, reset (recorded forundo) if it exists elsewhere, refused if it
holds writes since the last commit -- or if its head cannot be read, since
a fork would then reset it blind -- and forked otherwise. Another
bookmark's branch is never touched.