CLI Guide
This page is the reference for what --help cannot say: exit codes, revisions, versions and environment, plans, what each command does in the VCS and in the stores, and the JSON shapes. Arguments and flags are generated from the Typer app under Reference → CLI. The tutorials show the commands in use: Pinning, Branching and Writing and Reclaiming Storage.
tether init [PATH] [--dataset-id ID]
tether backends [--json]
tether add [KEY] [LOCATOR] --kind KIND [--branch BRANCH] [--at STATE | --pick] [locator options] [--set k=v]... [--file immutable|versioned] [--pin native|record] [--create]
tether set KEY... | --all [--file immutable|versioned] [--pin native|record]
tether remove KEY
tether status [KEY...] [--snapshot | --no-snapshot]
tether snapshot [KEY...]
tether pull [BOOKMARK] [--key KEY]... [-m MESSAGE]
tether commit [KEY...] -m MESSAGE [--no-vcs] [--strict] [--force] [--no-snapshot] [--dry-run | --plan FILE]
tether commit --from-plan FILE [KEY...] [-m MESSAGE] [--no-vcs]
tether new [REV] [-b NAME] [--shared] [--keep] [--eager | --lazy] [--defer-record | --no-defer-record] [--discard | --adopt] [--dry-run | --plan FILE | --from-plan FILE]
tether open KEY [-r REV] [--read-only | --writable] [--with-password]
tether restore KEY... (--from REV | --at REF) [--discard] [--dry-run | --plan FILE | --from-plan FILE]
tether promote [KEY]... [-r REV] [--strategy auto|ff|merge] [-m MESSAGE] [--dry-run | --plan FILE | --from-plan FILE]
tether verify [KEY...] [-r REV] [--all-history] [--deep]
tether diff [REV_A] [REV_B] [--key KEY]... [--content] [--limit N]
tether log KEY [--ref REF] [-n N] # or: tether log LOCATOR --kind KIND [--branch B] [--set k=v]
tether gc [--dry-run | --no-dry-run] [--release-foreign] [--prune-bookmarks [--keep-bookmark NAME]... [--force-prune]] [--delete-stores] [--store KIND=LOCATOR]... [--plan FILE | --from-plan FILE]
tether abandon REV... [--gc]
tether drop BOOKMARK [--to BOOKMARK] [--delete-stores] [--force-prune] [--dry-run | --no-dry-run | --plan FILE | --from-plan FILE]
tether forget-workspace [WORKSPACE_ID] [--dry-run | --plan FILE | --from-plan FILE]
tether ops [-n N]
tether undo [OP_ID] [--discard]
tether repair [KEY...] [--all-history] [--dry-run | --plan FILE | --from-plan FILE]
tether recover [KEY...]
tether upgrade [--ignore-immutable] [--dry-run | --plan FILE | --from-plan FILE]
tether export PATH [--rev REV]... [--all-history | --no-all-history] [--format sqlite|parquet|csv|jsonl] [--listings] [--workspace] [--append]
tether publish [--to DSN] [--schema NAME] [--rev REV]... [--listings] [--dry-run]
tether import SOURCE [--table NAME | --query SQL] [--sync] [--dry-run | --plan FILE | --from-plan FILE]
Commands run from anywhere inside a dataset: tether walks up to the nearest tether.toml. Commands that contact the stores fan out across objects concurrently. Every command that prints a result takes --json, except add and remove. export, publish and import are experimental and print a note saying so.
Exit codes and errors
| Code | When |
|---|---|
0 |
Success. |
1 |
A TetherError, printed to stderr as error: .... Also verify when a report is not ok, status when an object is error, and gc or promote when something failed or was refused and nothing was applied. |
2 |
A usage error, such as an unknown option or a missing argument, as in every Click program. |
3 |
Part of the work was done. undo, repair, upgrade and forget-workspace print each part they could not do as IRREVERSIBLE or FAILED. gc and promote print what they applied, then the failures, and add failed to their JSON. |
verify counts unknown as not ok; a Delta, DuckLake or --pin record state reports it without --deep. status exits 0 for modified and behind, because a moved upstream is news rather than an error. promote --dry-run exits 0 whatever the plan says. A backend failure in a fan-out names every failing object in one MultiObjectError. Each error class and its way out: Troubleshooting.
Revisions
REV goes to the enclosing VCS untouched: jj revsets (@, @-, main, change ids), or git revisions (HEAD~2, tags, shas). A REV that names a bookmark (jj) or branch (git) is one tether works on. tether new REV for any other revision gives a read-only working copy. Under git that commit is checked out on a tether/<rev12> branch, so dataset commits made there stay reachable; a detached HEAD would let gc treat them as gone. tether never deletes these parking branches: git branch -d the ones you are done with.
Selecting objects
KEY..., and the repeatable --key of diff, name objects in two ways: an exact key (zarr/imaging), or a prefix ending in / (zarr/ is every key under zarr/, but not zarrish). Selectors may overlap, and each object counts once. A selector that matches nothing is an error that names it. Naming none means every object.
status, snapshot, verify and diff then contact and report only the objects named, with the same exit codes and JSON shapes: status exits 1 only for an error among them, and snapshot leaves the cached states of the rest as they were. verify --rev and diff select from the manifests they read, so a key only one side of a diff has can be named. verify --all-history and repair --all-history select from every key history has had. restore, promote and repair select the same way. In Python, Repo.select_keys resolves selectors, and these methods take them as keys=.
commit KEY... and pull --key KEY write a subset, as git commit PATH does. Only the objects named are fingerprinted and pinned, and only their manifests and listings are written and go into the VCS commit. Every other manifest stays as last committed, with its workspace.toml entries, and any other uncommitted edit under .tether/ stays uncommitted: another object’s add or remove, a tether.toml change. A later commit without keys picks them up. Objects in one branch scope (two databases of a Neon project, two keys on one Icechunk store) share a pin and a working branch, so they are named together. Naming only some of them is refused, and the error lists the ones to add. A saved plan records its selection in context.keys, under the plan’s digest, and --from-plan applies exactly that. KEY... given with --from-plan must be the same selection.
Versions and environment
tether needs jj 0.43 or newer and git 2.38 or newer. Each is checked once per command and refused below the minimum, with the version found. git 2.38 introduced safe.bareRepository, which the git backend relies on.
What of your own configuration reaches tether’s VCS calls:
- jj. A layer built from your user config keeps your
user,signing,snapshot,working-copy,fsmonitor,coreandgitsettings, andimmutable_heads()with the aliases it names. Colour, the pager, templates and other aliases stay out. Repo-level config still loads, so tether’s templates call keywords as methods, and the revsets ofgc,dropand promote hold only operators and commit ids. A repo-level alias ofif(), of a lambda parameter or of files() still applies. - jj snapshots. A new file you have not snapshotted stays in its change when tether moves the working copy. tether’s snapshots leave out
.tether/**and track its own paths by name. - git. Colour,
log.showSignatureandcore.quotePathare off, and every untracked file is listed whateverstatus.showUntrackedFilessays. Your identity, ignore rules and hooks apply: a refusing pre-commit hook stops the dataset commit and leaves nothing staged. Thegitbackend runs with hooks off (security model).
Every environment variable tether reads, in one table:
| Variable | Effect |
|---|---|
TETHER_REV |
Default --rev for open and Repo.open: handles resolve read-only at that commit’s pinned state. Set it in reproducible jobs. |
PATH, TETHER_JJ, TETHER_GIT |
Which jj and git tether runs: [vcs] jj_path / git_path in .tether/secrets.toml, then TETHER_JJ / TETHER_GIT, then PATH. The committed tether.toml may not name one. secrets.toml is read only after a VCS found through the environment says the per-checkout files are untracked, so a jj reachable only through jj_path also needs TETHER_JJ. |
TETHER_NATIVE |
0 keeps the file backend on its Python scanner when tether-vcs-native is installed (large trees). |
TETHER_PUBLISH_DSN |
The Postgres DSN tether publish writes to when --to is not given. |
NEON_API_KEY |
The Neon API key, unless [backends.neon] api_key_env in secrets.toml names another variable. |
the variables a Dolt server’s user_env / password_env name |
Dolt credentials, only for the server whose [uris."mysql://host:port"] entry names them. |
| AWS / GCP / Azure credential chains | file (obstore), icechunk, delta and lance, unless a secrets.toml entry gives the object a profile, role_arn or literal keys. |
| pyiceberg catalog properties and environment | iceberg. |
GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_CONFIG_*, … |
Ignored: tether clears what git rev-parse --local-env-vars lists, so a git hook running tether still commits to the dataset’s repository. GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM are honoured. |
Credentials are never stored in manifests; each backend’s sources are in Backends.
Plans
Ten commands plan before they write: commit, new, restore, promote, gc, drop, forget-workspace, import, repair and upgrade. --dry-run prints the plan and --plan FILE saves it. --from-plan FILE applies a saved plan, so it refuses --dry-run and --plan. gc and drop are dry runs unless given --no-dry-run or --from-plan. pull, undo, abandon --gc, add --create and a writable open’s lazy fork write with no plan; publish has --dry-run but no --plan.
Apply checks the plan’s preconditions again, and refuses with StalePlanError when anything moved. A saved plan is valid only for the tether version that made it: any other version, older or newer, refuses it with “re-run the plan”, so plan again after upgrading tether-vcs. Its digest makes an edited plan fail. What each plan checks, and which bind to their checkout: What a plan checks.
plan: commit (2 write(s))
pin zarr/imaging [icechunk] tether.0a1b2c3d.deb4861eb388e5f6 native ref at snapshot_id=K3F9M2A1B7C8D4E5…
vcs-commit jj commit: 'Relabel plate1'
db/metrics: unchanged
Action verbs, by command:
| Command | Verbs |
|---|---|
commit, pull |
pin, record, vcs-commit |
| new | fork, defer-fork, reuse, adopt, share, trunk, refuse |
restore |
fork, share, refuse |
| promote | fast-forward, merge, share, hold, refuse |
gc |
unpin, keep-pin, delete-branch, keep-branch, forget-working-ref, delete-listing; with --delete-stores: delete-store, keep-store, forget-store, forget-touched |
drop |
leave-bookmark, abandon-commit, delete-bookmark, then gc’s verbs |
forget-workspace |
delete-file, forget-vcs-workspace |
import |
add, update, remove |
publish |
upsert |
repair |
repin, refork |
| upgrade | rename-pin, rename-branch, rewrite-history, rewrite-manifest, refingerprint, rename-manifest, rewrite-locator, rewrite-index, copy-listing, rewrite-state, vcs-commit |
trunk, defer-fork, reuse, adopt, share, hold, refuse, keep-branch, keep-pin and keep-store write nothing, and a plan holding only those is empty.
share gives objects in one branch scope (two databases of a Neon project, two keys on one Icechunk store) one branch per bookmark: the first member forks it and the others share it. A member pinned at another state of that branch is refused. restore and promote refuse to name only some of a branch’s objects, since moving the branch moves them all.
Conditional moves
The heads a plan reviewed are what the stores move from. fork, promote and merge take the expected head (or “absent”), and raise RefMovedError, moving nothing, when the ref holds something else:
| Backend | The check and the move |
|---|---|
git, memory |
One atomic step (CONDITIONAL_REF): update-ref with the old value. A git merge, or a promote onto a checked-out base, compares first and then merges in the checkout; a commit landing in between is merged or refused, never discarded. git refuses to move a branch checked out in any worktree. |
icechunk on s3:// |
One atomic step (CONDITIONAL_REF) on the object store’s conditional writes: reset_branch(from_snapshot_id=), and create_branch for “absent”. icechunk 1.x compares first. |
icechunk on local storage |
The same calls, but not atomic: racing processes can each win. |
lance |
Compared, then the branch is deleted and created again, which leaves a small window. “Absent” is atomic. |
iceberg, dolt, neon |
Compared just before the move. |
tether backends lists conditional_ref for git and memory; Icechunk declares it per object. A backend whose methods take no expected moves unconditionally, with a warning.
init
tether init [PATH] writes tether.toml and .tether/ into an existing jj or git repository and puts the working copy on the trunk bookmark. It generates the dataset id, unless --dataset-id ID gives one: 8 lowercase hex characters, or it is refused. Reusing a lost dataset’s id takes back what its stores still hold (Recovering a lost dataset repo).
add, set, remove
The positional LOCATOR becomes the uri field. Named options map to locator fields, and --set key=value (repeatable) covers the rest. Which fields each backend reads: Backends.
KEY may be left out for kinds whose locator is a path: file, icechunk, lance, delta and git. The key is then the path’s last segment, less the store suffix, so tether add s3://lab/greetings.icechunk --kind icechunk registers greetings. A data file keeps its extension. A lone positional is the LOCATOR for those kinds, unless --set uri= or --set path= gives the path, and the KEY for all others. A derived key that is already taken is refused; give that object a key of its own.
| Option | Locator field | Typical backends |
|---|---|---|
LOCATOR (positional) |
uri | file, icechunk, delta, lance, ducklake (metadata) |
--branch |
branch | icechunk, neon, iceberg, lance, dolt: the object’s upstream (default main), which the trunk bookmark stands for. The first commit pins it, a trunk pull reads it, and promote lands on it. |
--project-id, --database, --role |
same | neon; --database also dolt |
--remote |
remote |
git (push pins to a remote) |
--region |
region |
file (object stores), icechunk, delta |
--host, --port |
same | dolt |
--table |
table | ducklake (handle scope), iceberg (alias of identifier) |
--at ID / --pick |
at |
The initial position that the first commit pins instead of the branch head: a snapshot id, version, commit or tag (all but file and neon); an empty or blank one is refused, while 0 is a version like any other. --pick chooses from log interactively. |
--set k=v |
k |
path=, identifier=, data_path=, allow_dirty=1, … |
--create |
– | Experimental: make an empty store there first (icechunk, git, a local file directory), marked as this dataset’s; refused if anything exists there. See Stores tether created. |
--file and --pin set the policy. tether set KEY... --file|--pin (or --all) changes it in place: manifest-only, logged and undoable; commit to record it. tether remove KEY deletes the manifest and nothing else. A plain gc later forgets its working ref, and gc --prune-bookmarks judges its branch.
status
dataset 3f9c2a1b7d0e (STALE: zarr/imaging); on bookmark relabel
new raw/plate1 [file/observed] unrecoverable
modified zarr/imaging [icechunk/forkable]
clean db/metrics [neon/forkable] verify=ok
- Header. The hash of the working tree’s manifests now (manifest_hash), not the dataset’s id; then the bookmark, or
on no bookmark: read-only. Awarning:line follows when the VCS moved, renamed or deleted the bookmark behind tether’s back. - States. new, modified (the branch differs from the commit),
clean, and behind: on the trunk, a branch-less or--atobject whose system moved past its pin. error means the fingerprint failed, now or at the last snapshot; the reason goes to stderr and status exits 1. - Marks. unrecoverable (Observed),
(created)(made byadd --create),verify=(with[verify] on_status = true), and(STALE: ...).
status is local by default, like jj status: it shows the last snapshot and its age, contacting nothing. --snapshot fingerprints every object first, in parallel. A workspace with no snapshot yet always takes one, and [snapshot] auto = true makes every status do so (unless --no-snapshot). A commit refreshes the cache only for the objects it fingerprinted.
commit
- Refuse if the VCS working copy has left the bookmark. Under jj the bookmark must be on
@or@-; after ajj newpast an empty change,tether new NAME --keepbrings the working copy back. - Snapshot the bookmark’s branches (not with
--no-snapshot), and check quiescence where a backend needs it (Neon;--forceskips). - Per changed object, create the native ref
tether.<pin_id>, or record the state (Addressable,--pin record). Observed states are recordedrecoverable = false, or refused with--strict. - Write the manifests and listings, then
jj commit/git commitscoped to them (--no-vcsskips). WithKEY..., steps 2 to 4 cover only the objects named (Selecting objects). - Move the bookmark onto the commit, in the same jj operation.
A failure after the pins rolls the commit back as a unit (Pin-then-commit ordering).
pull
The dataset’s fetch, for the bookmark this working copy is on (Positions). What moved is pinned and committed on the bookmark; when nothing moved, nothing is committed. Objects with no commit or no branch yet are skipped with a reason. A changed file = "immutable" object and uncommitted manifest edits are refused. --key pulls only the objects named, and then only their own uncommitted edits are refused.
new
new decides which bookmark this working copy works on, then records a working ref per Forkable object:
new -b NAME [REV]: createNAMEatREV(default: here); refused if it exists.new NAME: join an existing bookmark.new REV: take the bookmark at that commit (this workspace’s, else the trunk, else the only one); with none there, the working copy is read-only.- new: stay on the current bookmark.
A bookmark another live checkout works on is refused unless --shared. On the trunk each working ref is the upstream branch (trunk); elsewhere it is tether.ws.<dataset>.<bookmark>, created by the first writable open (defer-fork) or now with --eager. A pin = "record" object’s is created now either way, unless --defer-record (Lazy or eager).
A branch already at the pin is kept (reuse); another existing branch is reset onto it. A branch holding writes no commit pins stops new (refuse), and the refusal says whose writes they are. Your own: commit them, or pass --discard. A peer’s: --shared adopts the branch as it is (adopt) when it builds on the bookmark’s pin. Adopting moves nothing, so a saved plan binds to the branch still building on that pin, not to its head: a branch a service keeps writing can still be joined.
--adopt takes the bookmark’s existing branch in each store as it is, whatever it holds (adopt): no reset, no copy, no --discard. Where a store made siblings (.2, .3), the highest generation is taken, and the plan says which. This is how the branches of a dataset whose repository was lost come back (Recovering a lost dataset repo). An object with no such branch forks as usual. A saved plan binds to each adopted branch still existing and still being the highest generation, not to its head: writes landing before the apply are adopted too. status then shows the adopted writes as modified, and commit pins them. --adopt is refused on the trunk and with --keep or --discard, and a bookmark another live checkout holds still needs --shared.
open
| Handle | Address |
|---|---|
| FileHandle | uri[#version_id] |
| NeonHandle | connection URL, password redacted (--with-password prints it; --json never does) |
| GitHandle | path@sha |
| IcechunkHandle | uri#<tag or branch or snapshot_id> |
| IcebergHandle | identifier#<ref or snapshot_id> |
| DeltaHandle | uri@vN |
| LanceHandle | uri#tag or uri#branch@vN |
| DuckLakeHandle | metadata#snapshot=N |
| DoltHandle | mysql://user@host:port/db/ref |
Without -r (or TETHER_REV), Forkable objects open writable at their working ref, creating it first when new deferred it, and everything else read-only. With -r REV the handle is read-only at that commit’s pinned or recorded state. On Windows a default open is read-only.
Each open re-reads workspace.toml and the manifests, so a long-lived Repo follows the bookmark the checkout is on now; a read-only open takes no lock. A handle already open stays on its branch: open again after another process runs new.
restore
tether restore KEY... --from REV is the per-object jj restore --from. It resets (or creates) KEY’s working branch at what REV pinned, and touches nothing else. The next commit pins what you restored, and promote treats the branch as forked from REV. It reads each branch as the store has it, a fork new deferred included. Writes nobody committed are refused unless --discard, and an unreadable head is refused outright.
tether restore KEY... --at REF starts the branch from a native ref or state in each object’s store instead: a branch, tag or state id, as add --at takes it for the backend. The plan resolves REF and records it with the state, under its digest. Apply forks the bookmark’s branch from that recorded state, moving it only from the head the plan saw, and REF itself is never moved or written. The object then reads as modified until the next commit pins it. The --discard rule is the same. --at is refused on the trunk (re-register the object --at, or pull), for objects that cannot fork, together with --from, and when REF is empty or blank (--at "$UNSET" would otherwise start the branch from the upstream head).
promote
Per Forkable object, promote plans fast-forward, merge or refuse from the fork point and the backend’s capabilities. --strategy ff refuses merges and --strategy merge fast-forwards. Without keys, one refusal holds the rest (hold), and the trunk bookmark moves only when everything landed. The rules: Landing it.
drop
tether drop BOOKMARK leaves the bookmark, abandons the commits only it reaches, deletes it, and releases what it held in the stores. It is a dry run by default and not undoable by tether: Throwing it away.
abandon
tether abandon REV... drops commits from a line you keep, keeping later commits’ manifests exactly as they were, then plans gc for what only they pinned (--gc applies it). Under git the commits must be on the current branch and the tree clean, and a conflict outside .tether/ aborts. It is not undoable by tether: Lever 2.
gc
A plain gc releases unreferenced pins this clone created, forgets removed objects’ working refs, and deletes unreferenced listings; it never deletes a branch. --prune-bookmarks judges stray tether.ws.* branches, and --delete-stores (experimental) the stores this dataset created. The reference set and every verdict: Lever 3.
forget-workspace
tether forget-workspace [ID] (default: this one) is jj workspace forget or git worktree remove, plus removing that checkout’s workspace.toml and ops.jsonl. No store branch is touched. Forgetting the current workspace leaves its directory without a working copy under jj. The plan binds to nothing, and it is not undoable.
ops, undo, repair
tether ops prints this checkout’s newest operation-log entries: id, time, command and what it did. Flags mark an entry (undone by ID), (vcs commit gone), (step of ID) (the new and gc a drop ran) or INCOMPLETE.
tether undo reverses the newest entry where the store allows it, and prints what it could not. Undos and what they reversed are skipped, but a newest entry that cannot be undone (a drop, an INCOMPLETE one) is refused, not skipped. tether undo ID names an older entry: of the workspace.toml fields it changed, only those still holding what it left come back, and the rest are listed as skipped. undo and jj undo: Undo.
$ tether undo
undid 3f1c9a2b7d4e (new: forked db, zarr/imaging)
restored db: deleted tether.ws.0a1b2c3d.relabel
restored zarr/imaging: deleted tether.ws.0a1b2c3d.relabel
restored workspace.toml restored
| Undoing a… | Does | Refused when |
|---|---|---|
commit, pull |
Uncommits it (jj: squash into @; git: reset --soft); the pins stay. |
It is no longer the working copy’s parent, or (jj) other commits build on it. |
new, lazy fork, restore |
Deletes the branches it created, restores the workspace.toml fields it changed, returns the VCS working copy if it has not moved, and deletes a bookmark -b made. A branch it reset is reported with its old head (IRREVERSIBLE); one it adopted stays as it is. |
A created branch gained writes, or holds a pin-less recorded state (--discard overrides). A new whose bookmark this checkout still works on, under a working copy that moved on since. |
gc |
Restores forgotten working refs and deleted listings. Deleted branches and pins are IRREVERSIBLE. |
– |
add, remove, set, import |
Restores the manifests; undo add also removes a still-empty store --create made. |
– |
| promote | – | Always. It prints each base branch’s head before the move: tether never moves a base backwards. |
repair, upgrade, abandon, drop, forget-workspace, and a drop’s steps |
– | Always. For an interrupted drop, the refusal names the command that finishes it. |
tether repair goes the other way: it rebuilds what the manifests promise. It recreates a pin whose native ref is gone, while the store still has the state, and forks again a working branch the store lost, only while it is still absent. --all-history checks every commit’s pins. A pin that points elsewhere is reported, never overwritten. repair --dry-run also says how far each interrupted operation got. repair KEY... checks and rebuilds only those objects’ pins and branches, and contacts only their stores. A saved plan records the selection in context.keys, under its digest, and KEY... given with --from-plan must be the same selection.
Icechunk never reuses a deleted tag’s name, so a repinned pin lives under the next generation of its id (tether.<id>.2, .3, …). The pin id and the manifests stay, verify reports ok pinned as tether.<id>.2, and readers resolve a pin through its live generation.
recover
tether recover [KEY...] is for a dataset whose repository was lost. It lists tether’s refs in each object’s store, grouped by the dataset id they carry: the pin count, and the bookmarks with working branches, with the generation where there are several. It lists other datasets’ refs too (gc leaves those alone), and marks the id tether.toml names. Then it prints the next steps, each a command with its values quoted for the shell (or none) and a # note:
tether repair -- KEY...first, for the objects whose manifest names a pin their store lacks (commit would not recreate it: the state is unchanged). WithKEY...it names only the selected ones, so no store left out is contacted;- the id to set in
tether.toml, when the refs carry one other id and nothing is pinned under this dataset’s yet. WithKEY...it suggests no id and no commit: the id applies to every object, and a commit pins every object under the current one, after which it can no longer change. The last step is thentether recoverwithout keys; tether commiton the trunk;tether new --adopt -- BOOKMARKandtether commit, per bookmark: the VCS bookmark whose escaped name (feat/xisfeat-x-1a2b3cin a branch name) the branch carries, ornew -b BOOKMARK --adopt -- TRUNKto make one. An escaped name no bookmark matches cannot be turned back into the original, so it is taken under the escaped one;- per legacy branch,
tether new -b recover-<workspace> -- TRUNK(tether new -- recover-<workspace>once an earlier recovery made it;-2,-3when a bookmark of that name is not a recovery’s),tether restore --at REF -- KEY...andtether commit: a per-workspace branchtether.ws.<id>.<workspace>.<key>from before bookmarks, listed under its workspace id, which may hold writes nobody committed.restorerefuses the trunk, so its copy goes onto one bookmark per workspace. Objects sharing a branch space are named together. Each step commits its copy: the next step’s new would reset a restore not yet committed.
Each command ends its options with -- before its keys and bookmark names, which may start with - (tether restore --help is the help flag, however it is quoted).
Refs named like tether’s that carry no readable dataset id are listed too. It writes nothing, and exits 1 when a store could not be listed. The whole procedure: Recovering a lost dataset repo.
upgrade
tether.toml carries [tether] version, now 5. Every command but upgrade refuses an older dataset, and says so. One migration brings any older dataset forward, alpha or 0.1.0b1-b3 (version 4). Its parts run on what the dataset shows, the version is written once at the end, and one VCS commit lands it. An interrupted upgrade re-runs the same plan, skipping the renames already made. 0.1.0b3 refuses a version 5 dataset, so clones on the two releases cannot take turns.
The alpha upgrade path lives in tether.upgrade and ships with the 0.1.0 betas only. The first non-pre-release drops it: a dataset at an alpha version then fails at open with a message naming the last beta. To bring an alpha dataset forward after that, pip install "tether-vcs==0.1.0b7" (or the newest beta), run tether upgrade there, and reinstall the release. Datasets already at the current version are unaffected.
$ tether upgrade --dry-run
plan: upgrade (5 write(s))
rename-pin db [icechunk] tether.0a1b2c3d.7d1e0c9a2b34e5f6 from tether.7d1e0c9a2b34
rename-pin db [icechunk] tether.0a1b2c3d.c48b2f6e1d07a9b0 from tether.c48b2f6e1d07
rename-branch db [icechunk] tether.ws.0a1b2c3d.7c1e0a4d.db-61a22c from tether.ws.7c1e0a4d.db
rewrite-history 3 commit(s) manifests with old-format pins get the new names; commit ids change, every clone must re-sync
vcs-commit tether upgrade: v1 -> v5
dataset id: 0a1b2c3d
| Part | Runs on | What it does |
|---|---|---|
| dataset-namespaced refs | refs without a dataset id (alpha) | Gives the dataset an id and renames every pin and working branch into its namespace (stores and history); the old per-workspace branch names are judged as legacy by gc --prune-bookmarks. |
| content-hashed file states; no write policy | mtime file states, a write policy, no bookmark (alpha) | Local file states become content hashes, the write line goes, and the workspace adopts the trunk. |
| one manifest file per key; absolute local paths | a misplaced manifest, a relative local path | foo.bar moves to foo.bar.toml; relative paths resolve against the dataset root. |
| 0.1.0b3 identities and states | version 4 | Gives the stores in the touched and created indexes their new identities, stores listings under new names, records Lance, Neon and directory (symlink) states in the new form where the data is unchanged, and makes DuckLake paths absolute. |
Renaming native refs rewrites history too, since gc compares what history references with what the stores hold. Commit ids change (jj keeps change ids), so every other clone re-syncs: jj git fetch, or a force-push and reset. Under jj, immutable commits are rewritten only with --ignore-immutable. It fails closed: the dataset id is written first, and the first failed rename stops the upgrade while history still names the old refs. A re-run continues under the same id.
log
tether log KEY lists an object’s native history, newest first, with the branches, tags and pins at each entry. It starts at the working ref (or at), or at --ref. With --kind, the positional is the locator of an unregistered object. Ids are valid --at values.
diff
With no arguments, diff compares the last commit (HEAD, or jj’s @-) with the working tree; a jj merge working copy is refused, naming its parents. One revision compares it with the working tree, two compare REV_A with REV_B. A changed locator or policy with the same state is changed too (why in --json). --content adds native diffs, and --limit caps the entries printed per object.
export, publish, import
Experimental; see Registries and SQL.
JSON output
| Command | Shape |
|---|---|
init |
{"root": "<absolute path>", "dataset_id": "<8 hex>"} |
backends |
[{"kind", "installed", "maturity", "tier", "capabilities": [names], "note": str or null}] (an uninstalled extra: installed false, maturity and tier null, capabilities [], note the import error) |
| status | {"manifest_hash", "stale", "stale_keys", "fresh", "snapshot_at", "bookmark", "trunk", "bookmark_drift": [str], "vcs_drift": [{"op", "commit", "referenced": {key: bool}}], "objects": [{"key", "kind", "tier", "state", "pinned", "recoverable", "origin", "verify", "error": str or null}]}; fresh is false when the cached snapshot from snapshot_at was shown |
set |
{"changed": {key: {field: {"from", "to"}}}, "unchanged": [keys]} |
pull |
{"bookmark", "committed": {key: {"from", "to"}}, "unchanged": [keys], "skipped": {key: reason}, "vcs_commit", "pinned": {key: pin_id or null}} |
snapshot |
{key: state} |
| commit | {"vcs_commit": sha or null, "pinned": {key: ref or null}, "unrecoverable": [keys], "unchanged": [keys]}; with --dry-run: the plan |
| new | {"bookmark", "working_refs": {key: ref}, "pending_forks": {key: ref}, "fork_points": {key: state}, "adopted": {key: ref}}; with --dry-run: the plan |
| promote | {"fast_forwarded": {key: state}, "merged": {key: state}, "skipped": [keys], "refused": {key: reason}, "held": {key: reason}, "conflicts": {key: [units]}, "kept_forks": {key: reason}, "trunk_moved": commit or null, "trunk_held": reason or null, "failed": {key: error}}; with --dry-run: the plan |
| open | {"key", "read_only", "address", "type"} |
restore |
{"from": rev or null, "at": ref or null, "working_refs": {key: ref}}; with --dry-run: the plan |
| verify | {label: {"status", "message"}} with labels key or <commit12>:key |
| diff | [{"key", "change", "why": [of "state", "pin", "locator", "policy"], "a": pin_id or null, "b": pin_id or null, "detail": ObjectDiff or null, "detail_error": str or null}] where ObjectDiff is {"summary", "unit", "added", "removed", "modified", "truncated", "note", "entries": [{"path", "change", "detail"}]} |
log |
[{"id", "when", "message", "refs": [names]}] |
gc |
dry run (default): the plan; after applying: {"dry_run": false, "unpinned": {kind: [pin_ids]}, "kept_pins": {kind: [pin_ids]}, "deleted_working_refs": {key: [refs]}, "kept_working_refs": {key: [refs]}, "forgotten_working_refs": {key: [refs]}, "deleted_listings": [names], "deleted_stores": {key: where}, "kept_stores": {key: reason}, "forgotten_stores": [keys], "failed": {target: error}} |
| abandon | {"abandoned": [commit ids], "gc_plan": plan, "gc_applied": bool} |
drop |
{"bookmark", "left_for": bookmark or null, "abandoned": [commit ids], "gc": {"unpinned", "deleted_working_refs", "kept_working_refs", "forgotten_working_refs", "deleted_listings", "deleted_stores", "kept_stores", "forgotten_stores"} or null}; dry run (default): the plan |
forget-workspace |
{"workspace": id8, "removed_files": [paths], "vcs": str or null, "failed": {target: reason}}; with --dry-run: the plan |
ops |
[{"id", "at", "command", "plan", "result", "pre", "undoes", "undone_by", "status", "parent", "progress", "vcs_commit_gone"}], newest first; parent names the drop an entry is a step of |
undo |
{"undone": op id, "command", "undo_id", "restored": [str], "irreversible": [str], "skipped": [str]} |
repair |
{"repinned": {key: pin_id}, "reforked": {key: ref}, "failed": {target: reason}, "notes": [str]}; with --dry-run: the plan |
recover |
{"dataset_id", "trunk", "pinned": bool, "suggested_dataset_id": id or null, "scoped": bool, "objects": [{"key", "kind", "holds_refs": bool, "error": str or null, "missing_pin": pin_id or null, "unrecognized": [refs], "datasets": [D]}], "datasets": [D], "steps": [{"command": str or null, "note": str}]} where D is {"dataset_id", "current": bool, "pins": count, "bookmarks": [{"bookmark", "branch", "generation": n or null, "branches": [refs]}], "legacy": [{"workspace", "branches": [refs]}]}; the top-level datasets merges every object’s; a step’s command is a shell command line with every value quoted, null where there is nothing to run; scoped (keys given) means no id and nothing that pins is suggested |
| upgrade | {"from_version", "to_version", "renamed_pins": {old ref: new ref}, "renamed_branches": {old: new}, "rewritten_commits": {old id: new id}, "refingerprinted": [keys], "rewritten_manifests": [keys], "rewritten_indexes": {file: records}, "copied_listings": [names], "failed": {target: reason}, "vcs_commit"}; with --dry-run: the plan |
| plan | {"format": 3, "tether_version", "command", "created_at", "context": {...}, "notes": [str], "preconditions": [{"kind", "expected", "key", "params": {...}, "detail"}], "actions": [{"op", "key", "kind", "target", "detail", "params": {...}}], "digest"}: what --plan FILE writes and --from-plan reads, only under the same tether_version. digest is the SHA-256 of the rest; precondition kinds are tether.plan.PRECONDITION_KINDS. |
export |
{"path", "format", "head", "rows": {table: count}} |
publish |
{"schema", "head", "upserted": {table: count}, "skipped_commits"}; with --dry-run: the plan (one upsert action per table) |
import |
{"added": [keys], "updated": [keys], "removed": [keys], "unchanged": [keys]}; with --dry-run: the plan |