Reading and repairing history#
This guide shows how to read a store's history without a checkout, and how to take back a ref move that should not have happened. It names revisions, reads files and directories, looks at changesets, walks the log and one path's history, compares two revisions, lists the store's operations, undoes one, and checks the store. The last sections cover the three output formats, the envelope and the exit codes.
Every command below was run with the binary built from this repository, against a scratch
store, and every output is copied from those runs. In the outputs, each 64-digit id is cut to its
first 12 hex digits and …; the commands print them whole, and the commands shown here take
them whole where they need a full id. The examples run so, the short name; swarmops is the
same program. so <command> --help is the authority on flags, and the README's
Commands table lists them all.
Set up a store to follow along#
The examples read one store, acme, with three changesets on main. Reads need no identity;
the ref and op undo examples write, so they need one.
export SWARMOPS_REPO=acme # or --repo <store> on each command
export SWARMOPS_AGENT=agent/worker-1 # or --as <family>/<instance>
so init acme --name acme
cat > c1.json <<'EOF'
[{"path": "src/lib.rs", "op": "put", "content": {"inline": "pub fn f() {}\n"}},
{"path": "README.md", "op": "put", "content": {"inline": "# acme\n"}}]
EOF
cat > c2.json <<'EOF'
[{"path": "src/lib.rs", "op": "put", "content": {"inline": "pub fn f() {}\n\npub fn g() -> u32 {\n 1\n}\n"}}]
EOF
cat > c3.json <<'EOF'
[{"path": "docs/guide.md", "op": "put", "content": {"inline": "Call f, then g.\n"}},
{"path": "README.md", "op": "put", "content": {"inline": "# acme\n\nSee docs/guide.md.\n"}}]
EOF
so commit -m first --changes c1.json --advance main
so commit -m 'add g' --changes c2.json --advance main
so commit -m 'add a guide' --changes c3.json --advance main
# Two change lists the undo examples use later.
cat > c4.json <<'EOF'
[{"path": "NOTES.md", "op": "put", "content": {"inline": "Release notes.\n"}}]
EOF
cat > c5.json <<'EOF'
[{"path": "README.md", "op": "delete"}]
EOFThe three commits made these changesets; your ids differ, because a changeset's id covers its author and its time:
| Changeset | Message | Revision |
|---|---|---|
3ba35c44081b… | first | main~2 |
ce8c4ae35795… | add g | main~1 |
48b8a4cf3ca9… | add a guide | main |
Name a revision#
Every read takes a revision: a name for one changeset. A revision is one of these, then any
number of ~N suffixes:
| Form | Example | Names |
|---|---|---|
| A ref's name | main, release/1.0 | The changeset the ref points at. |
| A full changeset id | 64 hex digits | That changeset. |
| An id prefix | ce8c4ae35795 | The one changeset whose id starts with it: 12 to 63 hex digits, either case. |
~N after any of these | main~2, main~1~1 | The changeset N first parents back. The suffixes add up: main~1~1 is main~2. |
so resolve says what a revision names. kind is ref when the revision is exactly a ref's
name, and changeset otherwise:
so resolve main --humanid: 48b8a4cf3ca9…
kind: refso resolve main~1~1 --humanid: 3ba35c44081b…
kind: changesetA prefix reads in either case, and ~N works after an id as well as after a ref:
so resolve CE8C4AE35795 --humanid: ce8c4ae35795…
kind: changesetso resolve 48b8a4cf3ca95a01caec61568a22e7f4f0ad89ef71b7d1c9ac85e57544b3b641~2 --humanid: 3ba35c44081b…
kind: changesetThere is no ^, no HEAD, no main~ without a number, and no prefix shorter than 12 digits.
Each of them is E_INVALID_ARG (exit 2), and the hint gives the grammar:
so resolve main^ --humanerror: E_INVALID_ARG: "main^" is not a revision
details.rev: main^
hint: a revision is main, release/<name>, a changeset id or 12 or more of its first hex digits, then optionally ~N for the N-th first parentA revision in the right form that names nothing is E_REV_NOT_FOUND (exit 6): a ref that does
not exist, an id or a prefix of no changeset, or a ~N past the first changeset. The hint is
the command that shows what it could have named:
so resolve main~3 --humanerror: E_REV_NOT_FOUND: main~3: not found
details.rev: main~3
hint: swarmops log mainA prefix that more than one changeset starts with is E_AMBIGUOUS_ID (exit 1): give more
digits.
Every command that resolves a revision lists it in its envelope's meta.resolved, with the
full id it named, so a script can pin the id it read:
so resolve main~1{"ok":true,"op":"resolve_rev","api":1,"result":{"id":"ce8c4ae35795…","kind":"changeset"},"meta":{"repo":"acme","elapsed_ms":0,"resolved":{"main~1":"ce8c4ae35795…"},"auth":"trust"}}A ref name or a relative revision resolves when the command runs. Where a command compares a
ref with what you read (ref set --expect, ref delete --expect), give the full id you read,
not main: the ref's own name always matches, so the compare-and-swap would protect nothing.
Read a file#
so cat REV:PATH prints one file at a revision. The revision and the path split at the first
:.
so cat main:src/lib.rs --humanblob: a15b2af67626…
size: 43
kind: file
encoding: utf8
content: |
pub fn f() {}
pub fn g() -> u32 {
1
}
truncated: falseThe same path two changesets back holds the first version:
so cat main~2:src/lib.rs --humanblob: f5f5220aa3d3…
size: 14
kind: file
encoding: utf8
content: |
pub fn f() {}
truncated: falsecontent is the text when the bytes are UTF-8 (encoding: utf8), and base64 otherwise
(encoding: base64). kind is file, exec or symlink.
Read part of a file#
--lines A-B prints lines A to B, numbered from 1, both included. offset says where in the
file they start:
so cat main:src/lib.rs --lines 3-5 --humanblob: a15b2af67626…
size: 43
kind: file
encoding: utf8
content: |
pub fn g() -> u32 {
1
}
truncated: false
offset: 15
lines.first: 3
lines.last: 5--max-bytes N caps the content (1 MiB by default). A read cut short says truncated: true
and gives next_offset, where --offset reads on:
so cat main:src/lib.rs --max-bytes 10 --humanblob: a15b2af67626…
size: 43
kind: file
encoding: utf8
content: pub fn f()
truncated: true
next_offset: 10so cat main:src/lib.rs --offset 10 --max-bytes 10 --humanblob: a15b2af67626…
size: 43
kind: file
encoding: utf8
content: |
{}
pub f
truncated: true
offset: 10
next_offset: 20--offset and --lines do not go together.
When the path is not a file#
A directory is E_IS_DIR (exit 1), and nothing at the path is E_PATH_NOT_FOUND (exit 6). Each
hint is the ls to run instead:
so cat main:src --humanerror: E_IS_DIR: "src" is a directory at main
details.path: src
details.rev: main
hint: swarmops ls main:srcso cat main:nope.txt --humanerror: E_PATH_NOT_FOUND: nothing is at "nope.txt" in main
details.path: nope.txt
details.rev: main
hint: swarmops ls mainList a directory#
so ls REV lists the root at a revision, and so ls REV:PATH one directory. Each file comes
with its size and blob id, each directory with its tree id:
so ls main --humanentries[0].name: README.md
entries[0].kind: file
entries[0].size: 27
entries[0].id: a7a064662799…
entries[1].name: docs
entries[1].kind: dir
entries[1].id: cde13afef0d0…
entries[2].name: src
entries[2].kind: dir
entries[2].id: 9ceb78ad12e6…
truncated: false--depth N lists the directories below too, up to 64 levels, each entry with its path.
--limit N (250 by default) cuts the page; a cut page says truncated: true and gives a
cursor:
so ls main --depth 2 --limit 3 --humanentries[0].name: README.md
entries[0].path: README.md
entries[0].kind: file
entries[0].size: 27
entries[0].id: a7a064662799…
entries[1].name: docs
entries[1].path: docs
entries[1].kind: dir
entries[1].id: cde13afef0d0…
entries[2].name: guide.md
entries[2].path: docs/guide.md
entries[2].kind: file
entries[2].size: 16
entries[2].id: 5ef3ee5b733f…
truncated: true
cursor: docs/guide.mdPass the cursor back, exactly as it was returned, for the next page:
so ls main --depth 2 --limit 3 --cursor docs/guide.md --humanentries[0].name: src
entries[0].path: src
entries[0].kind: dir
entries[0].id: 9ceb78ad12e6…
entries[1].name: lib.rs
entries[1].path: src/lib.rs
entries[1].kind: file
entries[1].size: 43
entries[1].id: a15b2af67626…
truncated: falseLook at one changeset#
so show REV prints one changeset: its parents, root tree, author, time, message, the changes
it made, its metadata and its signatures. created_at is in Unix milliseconds, the signatures'
secs in Unix seconds, and seq is the changeset's place in the store's index, in commit order.
so show main --humanid: 48b8a4cf3ca9…
seq: 2
parents[0]: ce8c4ae35795…
root: 4ca6750dfec7…
author.agent: agent/worker-1
created_at: 1792626460000
message: add a guide
changes[0].path: README.md
changes[0].op: put
changes[0].kind: file
changes[0].blob: a7a064662799…
changes[1].path: docs/guide.md
changes[1].op: put
changes[1].kind: file
changes[1].blob: 5ef3ee5b733f…
files_changed: 2
changes_truncated: false
metadata.agent: agent/worker-1
signatures.author.name: agent/worker-1
signatures.author.email: worker-1@agent.swarmops.local
signatures.author.secs: 1792626460
signatures.author.offset_secs: 0
signatures.committer.name: agent/worker-1
signatures.committer.email: worker-1@agent.swarmops.local
signatures.committer.secs: 1792626460
signatures.committer.offset_secs: 0--max-changes N (400 by default) caps the list of changes. files_changed still counts them
all, and changes_truncated says the list was cut:
so show main~2 --max-changes 1 --humanid: 3ba35c44081b…
seq: 0
parents: []
root: 519154f76a1f…
author.agent: agent/worker-1
created_at: 1792626460000
message: first
changes[0].path: README.md
changes[0].op: put
changes[0].kind: file
changes[0].blob: 745ffc98a5d4…
files_changed: 2
changes_truncated: true
metadata.agent: agent/worker-1
signatures.author.name: agent/worker-1
signatures.author.email: worker-1@agent.swarmops.local
signatures.author.secs: 1792626460
signatures.author.offset_secs: 0
signatures.committer.name: agent/worker-1
signatures.committer.email: worker-1@agent.swarmops.local
signatures.committer.secs: 1792626460
signatures.committer.offset_secs: 0parents: [] marks the first changeset of a history.
Walk the history#
so log lists history along first parents, newest first, from main by default. Each entry
gives the id, the parents, the agent, the time and the message's first line:
so log --humanentries[0].id: 48b8a4cf3ca9…
entries[0].parents[0]: ce8c4ae35795…
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626460000
entries[0].title: add a guide
entries[1].id: ce8c4ae35795…
entries[1].parents[0]: 3ba35c44081b…
entries[1].author_agent: agent/worker-1
entries[1].created_at: 1792626460000
entries[1].title: add g
entries[2].id: 3ba35c44081b…
entries[2].parents: []
entries[2].author_agent: agent/worker-1
entries[2].created_at: 1792626460000
entries[2].title: first
truncated: falseStart somewhere else#
Give a revision to start from it:
so log main~1 --humanentries[0].id: ce8c4ae35795…
entries[0].parents[0]: 3ba35c44081b…
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626460000
entries[0].title: add g
entries[1].id: 3ba35c44081b…
entries[1].parents: []
entries[1].author_agent: agent/worker-1
entries[1].created_at: 1792626460000
entries[1].title: first
truncated: falseRead it a page at a time#
-n N (50 by default) cuts the page. A cut page gives a cursor: the last id it listed.
so log -n 1 --humanentries[0].id: 48b8a4cf3ca9…
entries[0].parents[0]: ce8c4ae35795…
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626460000
entries[0].title: add a guide
truncated: true
cursor: 48b8a4cf3ca9…so log -n 1 --cursor 48b8a4cf3ca95a01caec61568a22e7f4f0ad89ef71b7d1c9ac85e57544b3b641 --humanentries[0].id: ce8c4ae35795…
entries[0].parents[0]: 3ba35c44081b…
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626460000
entries[0].title: add g
truncated: true
cursor: ce8c4ae35795…A cursor is a full changeset id; anything else is E_INVALID_ARG (exit 2).
Only the changesets that touch some paths#
--path P keeps the changesets that change P or anything below it. Give it again for more
paths; a changeset that changes any of them is listed:
so log --path src --humanentries[0].id: ce8c4ae35795…
entries[0].parents[0]: 3ba35c44081b…
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626460000
entries[0].title: add g
entries[1].id: 3ba35c44081b…
entries[1].parents: []
entries[1].author_agent: agent/worker-1
entries[1].created_at: 1792626460000
entries[1].title: first
truncated: falseso log --path docs --path README.md --humanentries[0].id: 48b8a4cf3ca9…
entries[0].parents[0]: ce8c4ae35795…
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626460000
entries[0].title: add a guide
entries[1].id: 3ba35c44081b…
entries[1].parents: []
entries[1].author_agent: agent/worker-1
entries[1].created_at: 1792626460000
entries[1].title: first
truncated: falseFollow one path#
so history REV:PATH lists the changesets that changed a file, or anything below a directory,
newest first, along first parents. Each entry says what the path holds after that changeset:
op (put or delete), and for a put its kind and its blob or tree id.
so history main:src/lib.rs --humanpath: src/lib.rs
entries[0].changeset: ce8c4ae35795…
entries[0].op: put
entries[0].kind: file
entries[0].id: a15b2af67626…
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626460000
entries[0].title: add g
entries[1].changeset: 3ba35c44081b…
entries[1].op: put
entries[1].kind: file
entries[1].id: f5f5220aa3d3…
entries[1].author_agent: agent/worker-1
entries[1].created_at: 1792626460000
entries[1].title: first
truncated: falseA directory's history gives the directory's tree after each change below it:
so history main:docs --humanpath: docs
entries[0].changeset: 48b8a4cf3ca9…
entries[0].op: put
entries[0].kind: dir
entries[0].id: cde13afef0d0…
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626460000
entries[0].title: add a guide
truncated: falseA deletion is an entry too. Here 610647075821 is a changeset made later in this guide, which
deletes README.md (see Undo an operation):
so history 610647075821:README.md --humanpath: README.md
entries[0].changeset: 610647075821…
entries[0].op: delete
entries[0].author_agent: agent/worker-1
entries[0].created_at: 1792626486000
entries[0].title: drop the readme
entries[1].changeset: 3ba35c44081b…
entries[1].op: put
entries[1].kind: file
entries[1].id: 745ffc98a5d4…
entries[1].author_agent: agent/worker-1
entries[1].created_at: 1792626460000
entries[1].title: first
truncated: false-n N (50 by default) and --cursor page it as they page log. A path no changeset in that
history touched is E_PATH_NOT_FOUND (exit 6):
so history main:nope --humanerror: E_PATH_NOT_FOUND: no changeset in the first-parent history of main touched "nope"
details.path: nope
details.rev: main
hint: swarmops ls mainCompare two revisions#
so diff FROM TO lists the files that differ between two revisions, in path order. Each has a
status: added, deleted, modified or type_changed (a symlink on one side, a file on the
other).
so diff main~2 main --humanfrom: 3ba35c44081b…
to: 48b8a4cf3ca9…
files[0].path: README.md
files[0].status: modified
files[1].path: docs/guide.md
files[1].status: added
files[2].path: src/lib.rs
files[2].status: modified
truncated: falseSay more of each file#
--format picks how much: names (the default), stat, which adds whether each file is binary
and its inserted and deleted lines, and unified, which adds its kinds, its blobs and its hunks.
so diff main~2 main --format stat --humanfrom: 3ba35c44081b…
to: 48b8a4cf3ca9…
files[0].path: README.md
files[0].status: modified
files[0].binary: false
files[0].insertions: 2
files[0].deletions: 0
files[1].path: docs/guide.md
files[1].status: added
files[1].binary: false
files[1].insertions: 1
files[1].deletions: 0
files[2].path: src/lib.rs
files[2].status: modified
files[2].binary: false
files[2].insertions: 4
files[2].deletions: 0
truncated: false--path P keeps the files at or below P; give it again for more. --context N sets the
unchanged lines around each change in a hunk (3 by default):
so diff main~1 main --format unified --path README.md --humanfrom: ce8c4ae35795…
to: 48b8a4cf3ca9…
files[0].path: README.md
files[0].status: modified
files[0].old_kind: file
files[0].new_kind: file
files[0].old_blob: 745ffc98a5d4…
files[0].new_blob: a7a064662799…
files[0].binary: false
files[0].insertions: 2
files[0].deletions: 0
files[0].hunks[0].old_start: 1
files[0].hunks[0].old_lines: 1
files[0].hunks[0].new_start: 1
files[0].hunks[0].new_lines: 3
files[0].hunks[0].lines[0].op: context
files[0].hunks[0].lines[0].text: |
# acme
files[0].hunks[0].lines[1].op: add
files[0].hunks[0].lines[1].text: |
files[0].hunks[0].lines[2].op: add
files[0].hunks[0].lines[2].text: |
See docs/guide.md.
truncated: falseA unified page holds at most --max-bytes of JSON (32 KiB by default); the first file comes
all the same, its hunks cut to fit.
Page through a large diff#
--limit N (500 by default) cuts the page, and --cursor reads on:
so diff main~2 main --limit 1 --humanfrom: 3ba35c44081b…
to: 48b8a4cf3ca9…
files[0].path: README.md
files[0].status: modified
truncated: true
cursor: README.mdso diff main~2 main --limit 1 --cursor README.md --humanfrom: 3ba35c44081b…
to: 48b8a4cf3ca9…
files[0].path: docs/guide.md
files[0].status: added
truncated: true
cursor: docs/guide.mdSee what one changeset changed#
Diff the changeset against its first parent, X~1:
so diff 610647075821~1 610647075821 --humanfrom: e1ff8dd6a805…
to: 610647075821…
files[0].path: README.md
files[0].status: deleted
truncated: falseThe first changeset of a history has no parent to diff against; so show lists its changes.
List the refs#
so ref list gives every ref, its target and its version: how many committed moves changed
its target.
so ref set release/1.0 main~1 --create --humanname: release/1.0
new: ce8c4ae35795…
already_applied: falseso ref list --humanrefs[0].name: main
refs[0].target: 48b8a4cf3ca9…
refs[0].version: 3
refs[1].name: release/1.0
refs[1].target: ce8c4ae35795…
refs[1].version: 1A ref is main or release/<name>.
See every write#
so op log lists the store's operations, newest first. An operation is one write transaction:
a frame of the store's journal, numbered from 0 in commit order. Each gives its kind, the
changesets it recorded and the ref moves it made, with who made each move and when (at, in
Unix seconds).
Here an agent moves main back two changesets by mistake, and the op log shows it as
operation 4:
so ref set main main~2 --expect 48b8a4cf3ca95a01caec61568a22e7f4f0ad89ef71b7d1c9ac85e57544b3b641 --humanname: main
old: 48b8a4cf3ca9…
new: 3ba35c44081b…
already_applied: falseso op log -n 2 --humanops[0].op: 4
ops[0].kind: ref_move
ops[0].changesets: []
ops[0].changesets_total: 0
ops[0].ref_moves[0].name: main
ops[0].ref_moves[0].old: 48b8a4cf3ca9…
ops[0].ref_moves[0].new: 3ba35c44081b…
ops[0].ref_moves[0].actor: agent/worker-1
ops[0].ref_moves[0].at: 1792626486
ops[1].op: 3
ops[1].kind: ref_move
ops[1].changesets: []
ops[1].changesets_total: 0
ops[1].ref_moves[0].name: release/1.0
ops[1].ref_moves[0].new: ce8c4ae35795…
ops[1].ref_moves[0].actor: agent/worker-1
ops[1].ref_moves[0].at: 1792626486
truncated: trueA move without old created the ref; one without new deleted it. The kinds are:
| Kind | The operation |
|---|---|
commit | Recorded changesets and moved no ref (commit without --advance). |
publish | Recorded one changeset and moved a ref to it (commit --advance). |
land | Recorded one changeset that names a lander and moved a ref to it (land). |
ref_move | Moved refs and recorded no changeset (ref set, ref delete, op undo). |
compaction | Recorded a compaction of the changeset index, and nothing else. |
mixed | Any other mix of changesets, ref moves and compactions. |
-n N (50 by default) cuts the page. A cut page says truncated: true; pass the last op it
listed as --before for the next one:
so op log --before 3 -n 2 --humanops[0].op: 2
ops[0].kind: publish
ops[0].changesets[0].id: 48b8a4cf3ca9…
ops[0].changesets[0].agent: agent/worker-1
ops[0].changesets[0].created_at: 1792626460000
ops[0].changesets[0].title: add a guide
ops[0].changesets_total: 1
ops[0].ref_moves[0].name: main
ops[0].ref_moves[0].old: ce8c4ae35795…
ops[0].ref_moves[0].new: 48b8a4cf3ca9…
ops[0].ref_moves[0].actor: agent/worker-1
ops[0].ref_moves[0].at: 1792626460
ops[1].op: 1
ops[1].kind: publish
ops[1].changesets[0].id: ce8c4ae35795…
ops[1].changesets[0].agent: agent/worker-1
ops[1].changesets[0].created_at: 1792626460000
ops[1].changesets[0].title: add g
ops[1].changesets_total: 1
ops[1].ref_moves[0].name: main
ops[1].ref_moves[0].old: 3ba35c44081b…
ops[1].ref_moves[0].new: ce8c4ae35795…
ops[1].ref_moves[0].actor: agent/worker-1
ops[1].ref_moves[0].at: 1792626460
truncated: true--max-changesets N (20 by default) caps the changesets listed for each operation;
changesets_total still counts them all.
Undo an operation#
so op undo OP moves every ref operation OP moved back to what it named before, in one
transaction: all of them or none. It is a write, so it needs an identity. It never removes a
changeset: what the ref named stays in the store, readable by id.
Take back a mistaken ref move#
Operation 4 moved main from 48b8a4cf3ca9… to 3ba35c44081b…. Undo it:
so op undo 4 --humanundone: 4
op: 5
ref_moves[0].name: main
ref_moves[0].old: 3ba35c44081b…
ref_moves[0].new: 48b8a4cf3ca9…
ref_moves[0].actor: agent/worker-1
ref_moves[0].at: 1792626486
already_applied: falsemain names 48b8a4cf3ca9… again. The undo is an operation of its own, operation 5.
Run an undo again#
An undo whose refs already name what they named before writes nothing, and says
already_applied: true. Retrying an undo is safe:
so op undo 4 --humanundone: 4
ref_moves: []
already_applied: trueUndo an undo#
An undo is an operation like any other, so another undo takes it back. Undoing operation 5
moves main to 3ba35c44081b… again:
so op undo 5 --humanundone: 5
op: 6
ref_moves[0].name: main
ref_moves[0].old: 48b8a4cf3ca9…
ref_moves[0].new: 3ba35c44081b…
ref_moves[0].actor: agent/worker-1
ref_moves[0].at: 1792626486
already_applied: falseWhen a ref moved since#
Each ref must still name what the operation set it to. Here an agent publishes on main
(operation 7), and then operation 6 is undone:
so commit -m 'add notes' --changes c4.json --advance main --humanchangeset: e1ff8dd6a805…
parent: 3ba35c44081b…
root: 56bce18a5de6…
files_changed: 1
blobs_written: 1
advanced.name: main
advanced.old: 3ba35c44081b…
advanced.new: e1ff8dd6a805…
advanced.already_applied: false
replayed: falsec4.json puts one file, NOTES.md. main no longer names what operation 6 set it to, so the
undo refuses with E_CAS_MISMATCH (exit 4) and writes nothing. details.actual is what the ref
names now:
so op undo 6 --humanerror: E_CAS_MISMATCH: ref main has moved since operation 6 set it to 3ba35c44081b…: it names e1ff8dd6a805… now, so this undo wrote nothing; undo the operations that moved it since, newest first, then this one again, or land a change that reverses operation 6
details.actual: e1ff8dd6a805…
details.name: main
details.op: 6
retryable: true
hint: swarmops op logThe message says the two ways on: undo the later moves first, or land a change. Recover from a refusal follows the first, after the next examples.
What an undo refuses#
An operation that moved no ref has nothing to undo. Operation 8 was a commit without
--advance, the one that deleted README.md:
so commit -m 'drop the readme' --changes c5.json --parent main --humanchangeset: 610647075821…
parent: e1ff8dd6a805…
root: 1a5912881d21…
files_changed: 1
blobs_written: 0
replayed: falseso op undo 8 --humanerror: E_NOTHING_TO_UNDO: operation 8 moved no ref, so there is nothing to undo
details.op: 8
hint: swarmops op logA number past the last operation is E_OP_NOT_FOUND (exit 6):
so op undo 99 --humanerror: E_OP_NOT_FOUND: there is no operation 99 (the last is 8)
details.last: 8
details.op: 99
hint: swarmops op logWithout an identity, an undo is E_IDENTITY_REQUIRED (exit 5). An empty variable counts as
unset:
SWARMOPS_AGENT= so op undo 4 --humanerror: E_IDENTITY_REQUIRED: this operation changes the store, so it needs the caller's identity
hint: pass --as <family>/<instance> or set SWARMOPS_AGENT, e.g. --as agent/worker-1Undo a ref's deletion#
An undo of a deletion creates the ref again. Operation 9 deleted release/1.0:
so ref delete release/1.0 --expect ce8c4ae3579542fb110b8bec045e1b1fb0655de9389d7a2256b72e16daabb8cb --humanname: release/1.0
old: ce8c4ae35795…
already_applied: falseso op undo 9 --humanundone: 9
op: 10
ref_moves[0].name: release/1.0
ref_moves[0].new: ce8c4ae35795…
ref_moves[0].actor: agent/worker-1
ref_moves[0].at: 1792626486
already_applied: falseRecover from a refusal#
Back to the refusal of operation 6: main moved since, by operation 7. Undo the later
move first, newest first, then operation 6:
so op undo 7 --humanundone: 7
op: 11
ref_moves[0].name: main
ref_moves[0].old: e1ff8dd6a805…
ref_moves[0].new: 3ba35c44081b…
ref_moves[0].actor: agent/worker-1
ref_moves[0].at: 1792626544
already_applied: falseso op undo 6 --humanundone: 6
op: 12
ref_moves[0].name: main
ref_moves[0].old: 3ba35c44081b…
ref_moves[0].new: 48b8a4cf3ca9…
ref_moves[0].actor: agent/worker-1
ref_moves[0].at: 1792626544
already_applied: falseThat takes main off the changeset operation 7 published, and the changeset stays in the
store:
so cat e1ff8dd6a805:NOTES.md --humanblob: 8a45e3b185d6…
size: 15
kind: file
encoding: utf8
content: |
Release notes.
truncated: falseWhen the later moves must stay, land a change that reverses what the operation did instead.
Undo a ref's creation#
An undo of a creation deletes the ref. Operation 3 created release/1.0:
so op undo 3 --humanundone: 3
op: 13
ref_moves[0].name: release/1.0
ref_moves[0].old: ce8c4ae35795…
ref_moves[0].actor: agent/worker-1
ref_moves[0].at: 1792626544
already_applied: falseso ref list --humanrefs[0].name: main
refs[0].target: 48b8a4cf3ca9…
refs[0].version: 9Check the store#
so verify checks the structure: parents, that every object a changeset references exists, the
index against the journal, and the refs. so verify --deep also re-hashes every object, and
checks the derived values against the objects. A clean store succeeds:
so verify --humanobjects_checked: 19
changesets_checked: 5
problems: []
findings: []A store with damage fails with E_STORE_CORRUPT (exit 1). details.problems gives one line
for each problem, and details.findings the same problems for a program, by kind and id. On a
copy of the store, broken, with one bit of one object file flipped, the structural check
passes and the deep one finds the damage:
so --repo broken verify --humanobjects_checked: 19
changesets_checked: 5
problems: []
findings: []so --repo broken verify --deep --humanerror: E_STORE_CORRUPT: verify found a problem: object a7a064662799… is corrupt
details.changesets_checked: 5
details.findings[0].kind: corrupt_object
details.findings[0].object: a7a064662799…
details.objects_checked: 19
details.problems[0]: object a7a064662799… is corrupt
hint: details.findings names each damaged object, record or ref by kind and id: restore them from a backup of the storeA read that meets the damaged object fails too, because every object read is checked against its id:
so --repo broken cat 48b8a4cf3ca9:README.md --humanerror: E_STORE_CORRUPT: object a7a064662799… is corrupt: its content does not match its id
details.object: a7a064662799…
hint: swarmops verify --deepThere is no repair tool yet. Checking a store says when to
run verify --deep, and Backup and restore how to
restore.
Choose an output format#
Every command here prints one JSON envelope on stdout. Three flags, given before or after the subcommand, choose how:
| Flag | Prints |
|---|---|
--json | The envelope as JSON: the default. |
--pretty[=BOOL] | The JSON indented. The default is pretty when stdout is a terminal, compact otherwise. |
--human | A short text rendering instead of JSON. |
Without --json or --human, SWARMOPS_FORMAT decides: json or human. Of --json and
--human, the one given last wins. The exit code is the same in every format. When the
command line itself is invalid, a format flag after the bad value can go unread:
so log -n many --human prints JSON.
Compact JSON, as a script reads it:
so resolve main~1{"ok":true,"op":"resolve_rev","api":1,"result":{"id":"ce8c4ae35795…","kind":"changeset"},"meta":{"repo":"acme","elapsed_ms":0,"resolved":{"main~1":"ce8c4ae35795…"},"auth":"trust"}}Pretty JSON:
so resolve main~1 --pretty{
"ok": true,
"op": "resolve_rev",
"api": 1,
"result": {
"id": "ce8c4ae35795…",
"kind": "changeset"
},
"meta": {
"repo": "acme",
"elapsed_ms": 0,
"resolved": {
"main~1": "ce8c4ae35795…"
},
"auth": "trust"
}
}Text, from the variable, and --json overriding it:
SWARMOPS_FORMAT=human so resolve main~1id: ce8c4ae35795…
kind: changesetSWARMOPS_FORMAT=human so resolve main~1 --json{"ok":true,"op":"resolve_rev","api":1,"result":{"id":"ce8c4ae35795…","kind":"changeset"},"meta":{"repo":"acme","elapsed_ms":0,"resolved":{"main~1":"ce8c4ae35795…"},"auth":"trust"}}Any other value of the variable is E_INVALID_ARG (exit 2), said of the variable:
SWARMOPS_FORMAT=yaml so resolve main~1{"ok":false,"op":"resolve_rev","api":1,"error":{"code":"E_INVALID_ARG","message":"SWARMOPS_FORMAT: \"yaml\" is not a format: json or human","retryable":false,"exit":2,"details":{"source":"SWARMOPS_FORMAT"},"hint":"set SWARMOPS_FORMAT to json or human, or unset it"},"meta":{"elapsed_ms":0,"resolved":{},"auth":"trust"}}Read the envelope#
The JSON keeps one order: ok, op, api, then result on success or error on failure,
never both, then meta.
opis the operation's name, which is not always the command's:catisread_file. The table below lists them.apiis the API version, 1.meta.repois the store as you named it,meta.elapsed_mshow long the command took,meta.resolvedeach revision it resolved with the full id it named, andmeta.auththe store's authentication mode,trust.errorcarries a stablecode, amessage,retryable(whether running it again can succeed),exit, machine-readabledetailsand ahint, often the next command to run.
A failure as JSON, a command line the program refuses:
so log -n many{"ok":false,"op":"log","api":1,"error":{"code":"E_INVALID_ARG","message":"invalid value 'many' for '--limit <N>': invalid digit found in string","retryable":false,"exit":2,"details":{},"hint":"Usage: so log [OPTIONS] [REV]\nFor more information, try '--help'."},"meta":{"elapsed_ms":0,"resolved":{},"auth":"trust"}}--human prints a result as key: value lines in the same order: a nested key as
outer.inner, an array item as key[i], an empty array as [], and a multi-line string as
key: | with its lines indented by two spaces. A failure prints error: <code>: <message>,
then its details as details.<key> lines, retryable: true when it is, and the hint. The text
is for a person to read; a program reads the JSON.
Of the commands here, only --help, --version and the help subcommand print plain text. A run that meets one of
the store's four documented aborts prints no envelope: the process aborts, saying why on stderr.
Commands and their operation names#
| Command | op | MCP tool | Needs an identity |
|---|---|---|---|
resolve | resolve_rev | resolve_rev | no |
cat | read_file | read_file | no |
ls | read_tree | read_tree | no |
show | show | show | no |
log | log | log | no |
history | path_history | path_history | no |
diff | diff | diff | no |
ref list | ref_list | ref_list | no |
ref set | ref_set | ref_set | yes |
ref delete | ref_delete | ref_delete | yes |
op log | op_log | op_log | no |
op undo | undo | undo | yes |
verify | verify | verify | no |
An agent on MCP calls the tool of the same name with the operation's request, and gets the same envelope back.
Exit codes#
The exit code is the envelope's error.exit, and 0 on success.
| Exit | Meaning | From the commands in this guide |
|---|---|---|
| 0 | Success, including an undo that was already applied. | |
| 1 | The operation failed. | E_IS_DIR, E_AMBIGUOUS_ID, E_NOTHING_TO_UNDO, E_STORE_CORRUPT |
| 2 | A malformed request or command line. | E_INVALID_ARG: a bad revision, cursor, flag value or SWARMOPS_FORMAT |
| 3 | A conflict with what a change lands on. | None here: land, git import and git export raise it. |
| 4 | Retryable: running it again can succeed, after re-reading what the error names. | E_CAS_MISMATCH |
| 5 | An identity or policy requirement refused it. | E_IDENTITY_REQUIRED |
| 6 | Something it names does not exist. | E_REV_NOT_FOUND, E_PATH_NOT_FOUND, E_OP_NOT_FOUND, E_NOT_A_REPO |
so schema gives every request and response type, and the README's
Output and exit codes lists the codes of every command.
Keep git: import, land and export
SwarmOps: a git replacement for coding-agent swarms. Git stays at both ends: git import brings a repository's history into a store, agents commit and land…
MCP integration
swarmops mcp serves a store's operations to an agent as an MCP server on stdin and stdout. An agent harness that speaks the Model Context Protocol, such as…