# swarmfile

`swarmfile` drives Swarmfile version-control operations against a running engine: commit, switch/restore, branches, merge, and the rest of the day-to-day project commands. It talks to the engine over its control socket - every subcommand mirrors an operation the Desktop App can do, and every subcommand requires a running engine except `completions`, [`demo offline`](#demo-offline) (it changes the host firewall, not the engine, and must work exactly when the engine is what is being cut off), the `git` helper verbs ([`git url`](#git-url), [`git clone`](#git-clone) and [`git import`](#git-import), which answer before the engine connection is needed), and the pure Git-habit shortcuts (`push`/`pull`/`clone`/`rebase`/`cherry-pick`/`rm`/`mv`/`cp`/`mkdir`/`touch`/`worktree`/`grep`/`init`/`remote`/`clean`/`reflog`/`mergetool`/`difftool`/`shortlog`/`submodule`/`bisect`/`gc`/`prune`/`fsck`/`archive`/`notes`), which work standalone. `add`, `stash`, `reset`, and the executable `clone <id> <path>` / `clone … --checkout` / `clone … --into` forms are the exception among the shortcuts - they map onto real commands, so they need a running engine too (see the Git-habit shortcuts note below).

If the engine isn't running, start it (or the Desktop App) first, or use the standalone [`swarmfile-doctor`](https://swarmfile.com/docs/cli/swarmfile-doctor) binary to diagnose connectivity without one.

`swarmfile` comes with the desktop app on every platform: `/usr/bin/swarmfile` from the Linux `.deb`, `/usr/local/bin/swarmfile` on macOS (a symlink into `Swarmfile.app`, so desktop updates carry it too), and `swarmfile.exe` in the Windows install folder (`C:\Program Files\Swarmfile`), which the installer adds to the machine `PATH`. There is no separate CLI download - install the desktop app and open a new terminal. An install from before the tools shipped (or a macOS install that came from the `.dmg` rather than the `.pkg`) needs one full installer run to get them and the `PATH` entry - **Diagnostics → Reinstall** does that from the tray; on macOS the tray also repairs the `/usr/local/bin` symlinks at launch where the directory is writable.

`swarmfile` doesn't include a `search` subcommand - search needs its own hub-authenticated round trip independent of the control socket, so it lives in the standalone [`swarmfile-search`](https://swarmfile.com/docs/cli/swarmfile-search) binary instead.

## Global flags

These apply to every subcommand.

| Flag | Description |
|---|---|
| `--cache-dir <path>` | Engine cache dir (where `control.json` lives). Respects `$SWARMFILE_CACHE_DIR`. Default `~/.cache/swarmfile` (macOS/Linux), `%LOCALAPPDATA%\swarmfile` (Windows). |
| `--json` | Print raw JSON instead of a human-readable summary. |
| `--mount <id>` | Target this mount instead of whichever one is `current` - see [`mounts`](#mounts) below. Has no effect on `mounts` subcommands themselves, which address by their own `id` argument instead, nor on `workspace switch`, which changes the whole engine's active workspace rather than one mount. |
| `--no-wait` | Don't wait for a [long operation](#op): print its id and exit `3` at once. |

> These are global flags, so they work on either side of the subcommand: `swarmfile --json status` and `swarmfile status --json` are equivalent, as are `swarmfile --mount m2 status` and `swarmfile status --mount m2`. That holds for the git-habit shims too - `swarmfile rm foo --json` is read as the flag, not a path.

## Exit codes

Scriptable commands separate "you need to do something" from "something failed", so a CI or pre-flight script can branch on it:

| Code | Meaning |
|---|---|
| `0` | Success - including the Git-habit shortcuts whose intent the product already satisfies (`push`, `pull`): nothing to do, so nothing failed. |
| `1` | Any other failure: an unreachable engine, a hub transport error, and the shortcuts whose operation has no equivalent here. |
| `3` | A [long operation](#op) is still running - after `--no-wait`, or when you pressed Ctrl-C to detach. It carries on in the engine; follow it with `op status` / `op wait`. |
| `2` | A refusal a script can act on, **or a CLI usage error** (an unknown command, flag, or argument). With `--json`, refusals print their response body as before, and usage errors print `{"code":"usage_error","kind":"…","error":"…"}` instead of clap's prose - so a script can tell a typo apart from an actionable refusal. |

A command page can assign `3` its own meaning where there is no long operation to follow - [`git index`](#git-index) exits `3` when the hub couldn't be reached, for example.

The translated shortcuts (`add`, `stash`, `reset --hard <rev>`, `clone <id> <path>`) return the exit code of the command they map to, not a shortcut code.

Git-habit shortcuts honor `--json`: instead of the sentence they print `{"code":"already_satisfied"|"not_applicable","exitCode":N,"hint":"…"}`.

The confirm-gated verbs all take `-y`/`--yes`, and most also accept `-f`/`--force` as a synonym - except `git enable`, and the verbs with a separate safety-gate `--force` below - `stash drop`/`clear`, `changelist discard`, `branch prune`, `trash purge`, `project delete`, `sync discard`, `git enable` (one-way, so it never proceeds without it), `ignore-clean`, and (unless `--dry-run`) `cache reclaim`/`scratch gc`. Without it, a non-interactive stdin or `--json` refuses (`{"code":"confirmation_required"}`, exit 2) rather than destroying work; the estimate-only verbs (`ignore-clean`, `cache reclaim`, `scratch gc`) take `--dry-run` to preview without confirming or deleting. Two prompt-gated verbs are the exception and proceed without `--yes` in the right context: `hydrate start` and `workspace switch` both proceed under `--json` or a non-interactive stdin (an interactive terminal still prompts). Both are resumable/reversible operations rather than destructive ones, and `--yes` only skips the interactive prompt.

A few verbs have a separate `-f`/`--force` meaning "proceed despite a safety gate" - `mounts close` (open changelists), `scratch clear` (project still open), and `ignore-clean` (`-y` skips its prompt, `-f` also overrides its recent-foreign-edit gate).

Commands that currently use exit **2**: `mr status` (open, not yet approved - the CI review gate), `conflicts resolve --auto` / `--all --auto` (a human must choose - a request that *fails* mid-sweep is exit 1 and takes precedence), `lock` (already held), `mounts close` (open changelists, or the boot mount), `acl mode set` / `acl entry …` (`forbidden` / `not_found` / `plan_restricted`), `unlock-request respond`/`cancel` (already resolved, or not yours), `workspace switch` (another switch running, a changelist open, or a drive open on another project - `cross_project_mount_open`), `checkout` with no target, `ignore-clean --yes` when a candidate was recently edited by someone else (unless `--force`) or when any delete failed, and `hydrate start` / `fetch` when a job is already running, and any confirm-gated verb without `--yes` (the `confirmation_required` refusal above; `ignore-clean --yes` keeps its own cross-machine gate).

## Version control

See [Version Control](https://swarmfile.com/docs/guides/version-control) and [Branches & Merging](https://swarmfile.com/docs/guides/branches-and-merging) for the conceptual walkthrough - this section is the flag-by-flag reference. If you're arriving from Git, [Coming from Git](https://swarmfile.com/docs/guides/coming-from-git) maps your habits onto these commands.

> **Git-habit shortcuts.** Typing a Git command that has no Swarmfile equivalent - `push`, `pull`, `clone`, `rebase`, `cherry-pick`, `rm`, `mv`, `cp`, `mkdir`, `touch`, `worktree`, `grep`, `init`, `remote`, `clean`, `reflog`, `mergetool`, `difftool`, `shortlog`, `submodule`, `bisect`, `gc`, `prune`, `fsck`, `archive`, `notes` - prints a one-line pointer to the real workflow instead of an error (e.g. `push` explains that saves sync automatically, `rm`/`mv` point at doing it on the mounted drive, `worktree` points at `swarmfile mounts open`, `grep` points at `swarmfile-search`, `mergetool` points at `conflicts resolve --tool`, and `clone` is argument-aware - a public-project URL, a `/s/<token>` share link, a `swarmfile://mount?project=…` deep link, or a lone project id each get the right next step; `clone <project-id> <path>` actually opens a mount - add `-b <branch>` to pin it to that branch, exactly like `mounts open --branch`, or add `--checkout` to write a plain directory instead (see `materialize`); `clone <public-project> --into <org>/<project>` copies a public release into a project, encrypting blocks on this machine - the way to get a public release into a **private** project, since the web "Copy to my org" fork only produces public ones. Add `--tag <tag>` to pick a release, `--path <p>` (repeatable) to copy a subtree, and `--new` (with optional `--public`) to create the destination project first. It runs as a long operation (`--no-wait`, `swarmfile op status|wait|cancel`; per-file progress; a cancel resumes from what's done), and its refusals (unknown slug or tag, no write access, a protected branch, a name collision) exit `2` while hub or transport failures exit `1`. These shortcuts work even when the engine isn't running (the executable `clone` forms need it).
>
> Three shortcuts have a real destination and **run it** instead of just advising: `stash` (and `push`/`save`) → `changelist park`, `stash list`/`show` → `changelist parked`, `stash pop`/`apply` → `changelist resume`, `stash drop`/`clear` → `changelist discard` (`stash list` numbers entries `stash@{n}`; `stash drop stash@{n}` or `drop n` discards just that one); `add` → opens a changelist if none is open (idempotent - saves then stage into it automatically, and this mount goes into staged mode until you `commit` or `changelist cancel`); and `reset --hard <rev>` → `restore <rev>` (one undoable commit). `add <path>` exits 1 if the path doesn't resolve, so a typo doesn't silently pass. Parked work is a **set, not a stack**: a bare `pop`/`apply` resumes all parked work for the branch, and `pop <n>` (or `pop stash@{n}`) resumes just that entry and leaves the rest parked - an entry parked on another branch is refused, since parked work resumes on its own branch (switch to it instead). If an entry's changeset was closed on the hub while it sat parked, resuming re-creates it and its staged files under a new id, and the result names the new id. A bare `drop` or `clear` abandons all of it - so discards ask first (or take `--yes`). On success these print a sentence, not raw JSON. Bare `reset --hard`, `--soft`/`--mixed`, and bare `reset` still print guidance, since there is no unstaged state to discard. These three need a running engine.

**Referring to a commit.** Every command that takes a commit (`show`, `restore`, `revert`, `describe`, `diff`, and the commit form of `checkout`) accepts any of: a commit number (`482`), the `#N` form the log prints (`#482`), `HEAD`, `HEAD~N` / `HEAD^` (N commits back in the current branch's log), a **tag name**, a **branch name** (its head), a changeset id, or a 64-hex **commit hash**. A `seq:`/`head:`/`branch:`/`tag:`/`changeset:`/`hash:` prefix forces which kind is meant. Resolved client-side to the commit number before the request.

### `commit`

Open a changelist (if none is open) and submit it in one step.

| Flag | Description |
|---|---|
| `-m, --message <String>` | Commit message |
| `--amend` | Rename the **most recent** commit instead of creating a new one (like `git commit --amend`). Message-only and non-destructive - contents and history are untouched. Requires `-m`. Equivalent to `describe` on the latest commit. |

```bash
swarmfile commit -m "Fix layout regression in the deck"
swarmfile commit --amend -m "Fix layout regression in the title deck"
```

### `changelist`

Explicit changelist control (Mode B: staged atomic submit) - for when you want to stage multiple saves before committing them atomically, rather than using `commit`'s one-step flow. A mount can hold several open changelists at once (Perforce's `-c` model) - see [Multiple changelists at once](https://swarmfile.com/docs/guides/version-control#multiple-changelists-at-once) for the conceptual walkthrough.

| Subcommand | Flags | Description |
|---|---|---|
| `open` | `-m, --message <String>` | Open a new changelist and make it current; subsequent saves stage into it until submit/cancel/switch. Never fails if one is already open - opening a second doesn't close the first. |
| `submit` | `--id <changeset>` | Atomically apply a changelist. Defaults to the current one; `--id` targets a specific one without switching to it first. |
| `cancel` | `--id <changeset>` | Abandon a changelist. Defaults to the current one; `--id` targets a specific one without switching to it first. |
| `status` | - | Show whether the current changelist is open and what's staged, plus any files blocked by an unresolved conflict - the same changelist + conflicts pair `status --changes` renders. |
| `list` | - | List every open changelist, `*` marking which one is current. |
| `current` | `<id>` | Switch which open changelist is current. |
| `reopen` | `[path]`, `--to <changeset>`, `--from <changeset>` | Move staged file(s) to a different open changelist without unstaging/restaging. With `path`, moves that one file. Without it, moves every staged file - from the current changelist, or from `--from <changeset>` if given - to `--to`. `--from` applies only without a path (a path with `--from` is a usage error). |
| `park` | - | Set aside every open changelist on this mount (kept, and kept alive on the hub) so the branch/project can be left without abandoning the work. |
| `parked` | - | List parked changelists with age + files. |
| `resume` | - | Restore parked work for the mount's current branch. |
| `discard` | `--changeset <id>`, `--yes` | Abandon parked work (one with `--changeset`, else all). Asks first (it cannot be undone); `--yes` skips the prompt. |

```bash
swarmfile changelist open -m "Reorganize asset folders"
# ...edit and save files...
swarmfile changelist status
swarmfile changelist submit
```

```bash
swarmfile changelist open -m "Swap all Act 2 plates to final grade"
swarmfile changelist open -m "Quick fix: wrong LUT on shot 12"
swarmfile changelist list
swarmfile changelist current a1b2c3d4
swarmfile changelist reopen "Renders/Act2/shot12.mov" --to e5f6a7b8
swarmfile changelist submit --id e5f6a7b8
```

### `switch`

Switch which branch this mount works on (like `git switch`) - hot and in-process, no engine restart. Same operation as `branch switch`. "This mount" is the `--mount` one, else the current mount (`mounts current`): the branch checks, `-c`'s create, and the switch itself all act on that same mount. A mount that shares another mount's branch context can't switch on its own and is refused (`shared_branch_context`) - `-c` refuses before creating anything. The success line names the branch the engine reports it switched to.

| Argument / Flag | Description |
|---|---|
| `[name]` | Branch to switch to. Omit to return to the project's default branch (usually `main`). |
| `-c, --create` | Create the branch first (forking from the current branch), then switch to it - like `git switch -c`. Errors if it already exists. |

Refused while any changelist is open on that mount (exit 2 on the default mount; on any other mount the switch fails with the same reason, exit 1); submit or cancel all of them first.

```bash
swarmfile switch look-dev-b
swarmfile switch            # back to the default branch
swarmfile switch -c feature-x   # create + switch
```

### `restore`

Restore a scope (the whole project, a folder, an entry, or a mount-relative path) to a past commit - a true tree snapshot, landed as one undoable commit (like `git restore`, but whole-project by default). Locked files → exit 2, and a file the restore would change that has an unresolved conflict refuses with `conflict_unresolved` - resolve it first.

| Argument / Flag | Description |
|---|---|
| `[seq]` | A commit to restore to - `#N`, `HEAD~N`, a tag, a changeset id, or a 64-hex hash (see "Referring to a commit"). A **branch** name restores this mount's branch to that branch's head tree (it does not switch branches - that's `switch`). |
| `--at-seq <N>` | Commit number to restore to - instead of `[seq]`, not with it (both at once is a usage error). |
| `--path <path>` | Restore only this mount-relative path. |
| `--folder-id <id>` | Restore a specific folder by ID. |
| `--entry-id <id>` | Restore a specific entry by ID. |

```bash
swarmfile restore 482                     # whole project back to commit #482
swarmfile restore 482 --path shots/seqA   # just that folder
```

`--json` prints the engine's response plus `targetSeq` - the commit number the CLI resolved your ref to; the engine's own `seq` is the NEW commit the restore landed as.

### `checkout`

A Git-habit shim mirroring `git checkout`'s overloading - prefer `switch` / `restore`, which say exactly what they do.

| You type | What happens |
|---|---|
| `checkout <branch>` | Switches to that branch (→ `switch`). |
| `checkout <N>` (a number, `HEAD`, `HEAD~N`) | Restores to that commit (→ `restore`). |
| `checkout <tag>` / `<hash>` / `<changeset-id>` / a prefixed ref (`tag:x`, `seq:5`, `hash:…`) | Restores to that commit - resolved exactly as `restore` resolves it. `branch:x` switches to `x`. |
| `checkout -b <name> [<target>]` | Creates a branch and switches to it - like `git checkout -b`. Forks from `<target>` (a branch, tag, or commit - same rules as `branch create --from`) or `--at-seq <N>`, else from the current branch. `--path`/`--folder-id`/`--entry-id` don't combine with `-b` (a branch is whole-project). |
| `checkout --at-seq <N> [--path …]` | Restores the version at that sequence number (the `--at-seq` form). Not combined with a `<target>`. |

> A bare number restores that commit **unless a branch of that name exists** - an existing branch wins even when its name is commit-shaped, as in git, and the CLI prints a note when it does. Force the branch with `switch <name>`, or force the commit with a `hash:`/`seq:` prefix.

### `show`

Show one commit: its message, author, time, and the files it changed (like `git show`). Whole-file - Swarmfile versions binaries, so there's no line-level diff.

| Argument | Description |
|---|---|
| `<rev>` | A commit - number, `#N`, `HEAD`, `HEAD~N`, or a tag (see "Referring to a commit" above). |

```bash
swarmfile show 482
swarmfile show HEAD        # the latest commit
swarmfile show HEAD~2      # two commits back
swarmfile show v1.0        # a tag
```

### `show-tree`

Fetch and print a tree object by its content-addressed cid (like `git ls-tree`) - one level, not recursive: each entry's kind (`d` directory, `f` file, `l` symlink), name, and content cid. Get a cid from `swarmfile show <rev>`'s `tree` line. A directory large enough to be stored segmented is listed a segment at a time - each segment is its own fetch, never one unbounded request for the whole directory. A **project member's** listing is filtered against the selected mount's branch view (the same ACLs a normal listing applies), so a cid taken from an old commit can come back partial or empty; owners and service callers see the object unfiltered.

| Argument | Description |
|---|---|
| `<cid>` | A tree object's content-addressed cid (64 lowercase hex characters). |

```bash
swarmfile show-tree a1b2c3...
```

### `revert`

Undo a commit by landing a **new** commit that restores the prior versions of the files it changed (like `git revert`) - non-destructive, itself undoable, and it doesn't touch newer commits. Locked files → exit 2, and a file the revert would change that has an unresolved conflict refuses with `conflict_unresolved` - resolve it first.

| Argument | Description |
|---|---|
| `<seq>` | The commit to undo - a number (`#N`), `HEAD~N`, a branch, a tag, a changeset id, or a 64-hex hash (see "Referring to a commit"). |

```bash
swarmfile revert 482
```

### `rollback`

Restore **one file** to an earlier version, keeping the current version in history - the CLI form of the Desktop App's version picker. File-scoped and non-destructive, unlike `revert`, which undoes a whole commit.

Requires the file's write lock, and refuses (exit 2) if the file has uncommitted local changes, is locked, has an unresolved conflict, or changed since the version was chosen. Get the version id from `swarmfile blame`.

| Argument / Flag | Description |
|---|---|
| `<path>` | Mount-relative (or absolute) path to the file. |
| `--at <id>` | The version's history id - the `#N` column from `swarmfile blame <path>`. |

```bash
swarmfile blame shots/seqA/plate_v3.exr   # find the version id
swarmfile rollback shots/seqA/plate_v3.exr --at 4182
```

### `describe`

Name, rename, or clear a commit's message after the fact - including an auto-saved commit that landed with no message (like `git commit --amend -m`, but for any commit and non-destructive). Author-or-admin only.

| Argument / Flag | Description |
|---|---|
| `<ref>` | The commit to describe - the full ref vocabulary (`482`/`#482`, `HEAD~N`, a branch, a tag, a changeset id, or a 64-hex hash). |
| `-m, --message <String>` | The new message. |
| `--clear` | Remove the commit's message. |

Exactly one of `-m` / `--clear` is required. Alias: `reword`.

```bash
swarmfile describe 482 -m "v2 delivery to client"
swarmfile describe 482 --clear
```

### `diff`

Show what changed. With no argument (or a branch name) it previews what merging a branch into its base would change (like `git diff main...`); with two commits it shows the files that changed between them (like `git diff A B`); with one commit (any ref that isn't a branch) it shows what changed from that commit to the current branch head - git's `diff <commit>` compares against the working tree, and since saves sync as they happen here, the head is the closest equivalent. Whole-file: there's no line-level diff for a binary. Read-only - it writes nothing.

| Argument | Description |
|---|---|
| *(none)* / `[branch]` | Branch preview - the given branch (or this mount's) vs its base. A branch preview is only meaningful for a non-main branch (main has no base → 400). |
| `<commit>` | That commit vs the current branch head - any non-branch ref (`HEAD~3`, a tag, a hash, `tag:x`, …). |
| `<A> <B>` | Two-commit diff - files changed between commit A and B. Each accepts the full ref vocabulary: a number, `#N`, `HEAD`, `HEAD~N`, a branch, a tag, a changeset id, or a 64-hex hash (prefixes force a kind). |

```bash
swarmfile diff              # this branch vs its base
swarmfile diff look-dev-b   # a named branch vs its base
swarmfile diff HEAD~3 HEAD  # what changed over the last three commits
swarmfile diff v1.0 v2.0    # between two tags
```

### `blame`

Who last changed a file, plus its version history with authors (like `git blame`, but whole-file - one row per saved version, not per line, since content is opaque).

| Argument | Description |
|---|---|
| `<path>` | Mount-relative (or absolute) path to the file. |
| `--branch <name>` | Read this path's history on another branch (read-only) instead of the selected mount's - that branch's metadata is synced on demand, and the path must exist there. |

```bash
swarmfile blame shots/seqA/plate_v3.exr
```

### `changesets list`

The commit log, newest first, scoped to the branch this mount is on (or another named branch, read-only).

| Flag | Description |
|---|---|
| `--limit <N>` | Default `50` |
| `--offset <N>` | Default `0` |
| `--branch <name>` | Read another branch's log without switching mounts. Omitted = the selected mount's branch. |

### `branch`

Branch management.

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Active branches for this mount's project. Each row carries its head commit's hash (`headCommitHash`; `null` when no head hash is recorded). |
| `create` | `<name>`, `--from <REF>`, `--from-commit <seq>`, `--from-tag <tag>` | Fork a new branch (defaults to forking from the selected mount's branch's live head, like `switch -c`). `--from` takes the full ref vocabulary: a branch forks that branch (an empty one included), a tag forks its commit, and a seq/hash/changeset id forks that commit with the source branch derived from it. `--from-commit`/`--from-tag` are the explicit forms; combining a **branch** `--from` with one of them is allowed and the hub refuses the fork if the point belongs to a different branch (`branch_mismatch`), while a second explicit point (a tag/seq `--from` plus `--from-commit`/`--from-tag`) is refused up front. |
| `switch` | `[name]` | Switch which branch this mount (`--mount`, else the current mount) works on. Omit `name` to switch back to the project's default branch (usually `main`). |
| `archive` | `<name>` | Soft-archive a branch (the project's default branch cannot be archived). Exit 2 on an actionable refusal: unknown branch, the project's default branch, a protected branch archived by a non-admin, a branch another **active** branch is based on (`branch_has_active_children`), or one still named by an **open** merge request (`branch_has_open_mr`). A merged/closed MR doesn't block, so `mr merge --delete-branch` still works. |
| `merged` | - | Branches with nothing left to land - a branch-vs-base diff with no changed files and no conflicts (the same computation `diff <branch>` shows). The project's default branch is never listed, a branch whose diff can't be computed is excluded rather than assumed merged, and a branch an open mount is working on is marked *in use*. |
| `prune` | `--yes` | Archive every prunable branch `merged` lists, after naming them and confirming. **Never archives a branch an open mount on this machine is on.** `--yes` skips the prompt; `--json` without `--yes` returns a `confirmation_required` error instead. The result lists `archived` / `refused` / `failed` by name. Exits 2 if any branch was refused (protected, etc.), 1 on a transport error. |
| `protect` | `<name>`, `--required-approvals <N>` | Refuse a direct merge into this branch **and every direct write to it** (saves/edits/renames/moves/creates/deletes, staging/committing, rollback, conflict resolution, LFS reflect) - changes land only through an approved [Merge Request](https://swarmfile.com/docs/guides/branches-and-merging#protecting-a-branch). `--required-approvals` (1-10, default 1) sets this branch's approval floor - an unprotected branch otherwise requires none; a matching protection rule's own count can raise it further (the effective count is the MAX of both). The CI-checks-required gate (`--require-checks-to-pass`) is a `protection-rule`-only flag, not available per-branch here - protect via a single-branch (no-wildcard) rule instead if you need it without a pattern. Org-admin-gated. |
| `unprotect` | `<name>` | Clear a branch's protected flag. Org-admin-gated, same as `protect`. |
| `set-commit-mode` | `<name>`, `[mode]` | Lock (or unlock) this branch to a commit mode - overrides the project's own lock, if any, while set. `mode` is `auto-commit`, `staged`, or omitted/`unset` to clear it back to unenforced-at-branch-scope (the project's lock, if any, still applies). Already-open sessions/changelists are grandfathered: this only affects the *next* `commit`/`changelist open`, never one already in progress. Org-admin-gated: exit 2 on `forbidden` (403) or an unknown branch (404); any other failure is exit 1. |

> `branch switch` is hot - no engine restart. It's refused (exit 2) while any changelist is open; submit or cancel all of them first. With the global `--mount <id>`, it retargets **that** mount only (a mount sharing another mount's branch context can't switch on its own and is refused with `shared_branch_context` - open an independent mount instead); a branch that doesn't exist can't be switched to. `branch create` never touches the running mount either, even with `--from-commit`/`--from-tag` - creating a branch doesn't change which branch this mount is checked out to. `protect`/`unprotect` exit 2 on `forbidden` (non-admin caller), `not_found` (unknown branch), or (`protect` only) an out-of-range `--required-approvals` (400) - distinguishable business states, not a crash - and exit 1 on anything else.

```bash
swarmfile branch create recover --from-commit 482
swarmfile branch create recover --from-tag v1.0
```

### `protection-rule`

Glob-pattern [branch protection](https://swarmfile.com/docs/guides/branches-and-merging#protecting-many-branches-at-once) - additive on top of `branch protect`/`unprotect`'s per-branch flag. A rule like `release/*` protects every branch whose name matches it, including ones created after the rule was added; a pattern with no wildcards is just another way to protect one exact branch.

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Protection rules for this mount's project. Org-admin-gated, like `create`/`delete`: exit 2 on `forbidden`. |
| `create` | `<pattern>`, `--required-approvals <N>`, `--require-checks-to-pass` | Create a rule. `*` matches any run of characters, `?` matches exactly one. `--required-approvals` (1-10, default 1) is this rule's contribution to the approval floor for every branch it matches - see `branch protect`'s row above. `--require-checks-to-pass` refuses a merge unless every CI check that has reported a run at the request's exact current head shows its latest run passing. A check whose latest run is at an earlier commit is not carried forward as still-required; if no check has reported at the head at all, the gate fails closed rather than passing vacuously. This flag exists only here, at the rule level; there's no per-branch equivalent on `branch protect` itself. Org-admin-gated: exit 2 on `forbidden` (403), `invalid_pattern`/`invalid_required_approvals` (400), or `pattern_taken` (409); exit 1 on anything else. |
| `delete` | `<pattern>` | Delete a rule by its exact pattern text. Exit 2 on `forbidden` or `not_found`, same convention as `create`. |
| `add-reviewer` | `<pattern>`, `<principal-id>` | Add an [auto-reviewer](https://swarmfile.com/docs/guides/branches-and-merging#assignees-requested-reviewers-and-labels) - every merge request opened against a branch this rule matches automatically requests a review from this principal. Additive to manually-requested reviewers, never a replacement. Idempotent. Org-admin-gated. |
| `remove-reviewer` | `<pattern>`, `<principal-id>` | Remove an auto-reviewer from a rule. A no-op if they weren't one. Org-admin-gated. |
| `list-reviewers` | `<pattern>` | List a rule's configured auto-reviewers. Human output shortens each id (`user 1a2b3c4d…`); `--json` carries the full ids that `add-reviewer`/`remove-reviewer` take. Org-admin-gated. |

```bash
swarmfile protection-rule create "release/*" --required-approvals 2 --require-checks-to-pass
swarmfile protection-rule list
swarmfile protection-rule add-reviewer "release/*" <principal-id>
swarmfile protection-rule list-reviewers "release/*"
swarmfile protection-rule delete "release/*"
```

### `tag`

A project-scoped, immutable named pointer to a commit - "branch from tag" is "branch from commit, resolved via a name." A tag also pins the commit it names against history retention, so checkout/restore to it keeps working while the tag exists; deleting the tag releases the pin. There is no move/repoint subcommand: to repoint a name, delete it and create it again. (With git access on the project, a lightweight tag can also be moved or deleted over the git remote - see [Cloning a Project with Git](https://swarmfile.com/docs/guides/git-clone#pushing).)

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Tags for this mount's project. Each row carries the tagged commit's hash (`commitHash`; `null` when no hash is recorded for the tagged commit). |
| `create` | `<name>`, `[REF]`, `--at <ref>`, `--at-seq <seq>`, `--on-branch <branch>` | Tag a commit. `REF` takes the full vocabulary (`N`/`#N`, `HEAD~N`, a branch, a tag, a changeset id, a 64-hex hash; prefixes force a kind); `--at-seq N` is the bare-number shorthand. Omit the ref to tag the CURRENT head of the selected mount's branch, or of `--on-branch` (which must name an existing branch - checked with every form, `--at-seq` included). `--from` remains a hidden alias of `--on-branch`. Errors if the branch has no commits yet. Needs project-wide write (see [Permissions](https://swarmfile.com/docs/admin/permissions)); exit 2 on that refusal (403). |
| `delete` | `<name>` | Delete a tag. Real delete, not soft - a tag has no archived state. Same access and exit 2 on 403 as `create`. |

```bash
swarmfile commit -m "final render pass"
swarmfile tag create v1.0
swarmfile tag create v0.9 --at-seq 482
swarmfile tag list
swarmfile tag delete v1.0
```

### `ref`

A thin read-only grouping over branches **and** tags - for the two questions that need both listings at once. `branch` and `tag` remain the full commands for acting on either.

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Every named pointer: branches (the current one marked `*`) and tags, together - each carrying its head/tagged commit hash. |
| `resolve <name>` | - | Say what a ref resolves to under the same resolver every other command uses - the kind (`branch`, `tag`, `seq`, `head`, `hash`, `changeset`) and commit number `restore`/`checkout`/`log` would act on - with a note (`alsoMatches` in `--json`) when the name also matches another kind: a branch wins over a same-named tag, and `tag:<name>` selects the tag. Exit 1 if it doesn't resolve. |

```bash
swarmfile ref list
swarmfile ref resolve v1.0
```

### `release`

Publish a tag as an immutable, public release - a static, cached snapshot of that tag's tree, served anonymously for browsing and gated behind a free account for full downloads. Requires the project to already be `plaintext`-tier and `public` (every public project is plaintext-tier, on any plan - see [Billing and plans](https://swarmfile.com/docs/admin/billing-and-plans)); publishing enqueues a combined-archive build, so a fresh release starts `pending` and moves to `ready` (or `failed`) asynchronously. `list`/`create`/`preflight` are scoped to this mount's own project; `fetch` is not - it pulls from ANY public project by org/project slug, whether or not this engine has it mounted, since a public release needs no org membership, only a real signed-in account.

A release can also carry **assets**: named binaries attached to it without living in the project tree (no manifest entry, no combined archive, no clone history). They are served anonymously at `/public/<org>/<project>/assets/<name>` on the hub, which is how build artifacts ship alongside the tagged source; `attach` uploads one, `assets` lists them, `detach` removes one, and re-attaching the same name replaces it.

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Releases published for this mount's project, each with the `id` `delete` takes, its `status` (`pending`/`ready`/`failed`), and the tagged commit's `commitHash` (`null` when no hash is recorded for the tagged commit). |
| `preflight` | `<tag>` | Report exactly what `create <tag>` would do, publishing nothing: the gates (public/`plaintext`, your authority, verified email), the file count and size that would become public, the paths `.swarmfile/publicignore.yml` keeps out, the paths `.gitignore`/`.swarmfileignore` hide from the web browser that would *still* publish, and whether the tag's policy differs from the default branch's current one (the fixed-the-policy-but-didn't-re-tag trap). Exit 0 when the tag is publishable, 2 when it isn't (the reason is printed), 1 when the check itself failed. |
| `create` | `<tag>` | Publish a tag. Re-running this on an already-published tag is a no-op unless the prior attempt failed non-permanently, in which case it retries. Exit 2 on a defined refusal: not `plaintext`+`public` (409), the tag's prior publish failed permanently (409 - publish a new tag instead; a terminal `publicignore_invalid` from a committed `.swarmfile/publicignore.yml` lands here, so fix the file and publish a new tag), the tag doesn't exist (404), the project isn't provisioned on the hub yet (501), or you lack the access to publish (403 - see [Permissions](https://swarmfile.com/docs/admin/permissions); this includes a folder in the tag you're denied read on, and an unverified email). |
| `delete` | `<id>` | Unpublish. Real delete, not soft, and needs the same access as `create` (see [Permissions](https://swarmfile.com/docs/admin/permissions)). Find `id` via `list`'s output first. Exit 2 on `404` (already unpublished, or a typo'd id) or `403` (you lack the access to publish, so you can't unpublish either). |
| `attach` | `<tag> <file>`, `--name <name>`, `--content-type <type>` | Upload a local file as an asset of the release named by `tag` (see `list`). The file's basename is the asset name unless `--name` overrides it; `--content-type` overrides the type derived from the file name. The engine computes the SHA-256 the hub displays. Re-attaching the same name replaces it. Files up to 100 MiB stream through the hub; larger files (up to 5 GiB) upload directly to the project's storage with a presigned URL. Exit 2 on a defined refusal: a bad name (400), no authority (403), a missing release (404), too many assets (409), a file over the 5 GiB cap (413), or a project bucket that cannot accept a direct upload when the file is over 100 MiB (409 `presign_unavailable`). |
| `assets` | `<tag>` | List the release's attached assets (name, size, content type, SHA-256). |
| `detach` | `<tag> <name>` | Remove one attached asset. Exit 2 on `404` (no such asset) or `403` (no authority). |
| `fetch` | `--org <slug>`, `--project <slug>`, `--tag <name>`, `--path <path>`, `--out <path>`, `--into <org>/<project>` | Fetch one file from the release's manifest, or (if `--path` is omitted) the whole release as a combined `.tar`. `--org`/`--project` are slugs, matching the public URL shape, not internal ids. With `--into`, nothing is written to disk: the release is copied straight into that project by the engine - the same client-side copy as `clone … --into` (see [Version control](#version-control)), which seals the blocks on this machine so a **private** destination works - `--out` is then not required, and `--path` narrows the copy exactly as it narrows an archive. The copy runs as a long operation (`--no-wait`, `swarmfile op status|wait|cancel`; a cancel resumes from what's done). Exit 2 on `401` (not signed in) or `404` (org/project/tag/path wrong, or the project isn't public); with `--into`, a missing write access or a protected default branch also exits 2. |

Browsing a published release anonymously (no account, no CLI) is a web-hosted page, not a CLI surface - the same split [`share`](#share) describes for share links.

```bash
swarmfile tag create v1.0
swarmfile release preflight v1.0
swarmfile release create v1.0
swarmfile release attach v1.0 dist/widgets-1.0-macos-arm64.tar.gz
swarmfile release assets v1.0
swarmfile release detach v1.0 widgets-1.0-macos-arm64.tar.gz
swarmfile release delete rel_9f2a
swarmfile release list
swarmfile release fetch --org acme --project widgets --tag v1.0 --out widgets.tar
swarmfile release fetch --org acme --project widgets --tag v1.0 --path docs/readme.md --out readme.md
swarmfile release fetch --org acme --project widgets --tag v1.0 --into myorg/private-widgets
```

### `merge`

Merge this mount's branch into the branch it was forked from - its parent (usually main, but a branch forked from a non-main branch merges back into that one). The target is implicit: a branch already knows its own parent, so there's no target flag. A merge that would change an entry with an unresolved conflict is refused with `conflict_unresolved` - resolve it first, then re-run.

| Flag | Description |
|---|---|
| `--from <branch>` | Merge FROM this branch instead of the selected mount's own - land a branch you aren't standing on. The target is still the source's parent (main, or whatever it was forked from). |
| `--record-conflicts` | Record `entry_conflicts` rows for interactive resolution instead of only listing them on conflict. |
| `--diff3` | On a merge conflict, batch-resolve every text-file content conflict with the engine's three-way merge before giving up, then retry the merge once. Never lands a partial merge on its own. Overrides a project's `.swarmfile/merge.yml` `diff3: false`. Mutually exclusive with `--no-diff3`. |
| `--no-diff3` | Force diff3 auto-resolve **off** for this invocation, even if the project's `.swarmfile/merge.yml` declares `diff3: true`. Mutually exclusive with `--diff3`. |
| `--dry-run` | Print what the merge would change - added, modified, deleted, renamed, and conflicting files, the same read-only diff `swarmfile diff <branch>` shows - and stop. Nothing is staged and no merge lock is taken. |
| `--wait` | Large merges apply in the background; the command otherwise returns as soon as the merge is accepted. Block and poll until the apply finishes, printing each phase. Gives up after ten minutes with exit 2 and explains how to resume or roll the merge back. |

```bash
swarmfile branch create feature-x
swarmfile branch switch feature-x
# ...work, commit...
swarmfile branch switch
swarmfile merge --record-conflicts
```

### `mr`

Open, list, inspect, review, and land [Merge Requests](https://swarmfile.com/docs/guides/branches-and-merging#merge-requests) - the propose → review → approve → merge gate that sits on top of `merge` above.

| Subcommand | Flags | Description |
|---|---|---|
| `create` | `--title <title>`, `--from <branch>`, `--body <text>`, `--draft` | Open a merge request from `--from` into its base branch. `--from` defaults to whatever branch this mount is currently on. Prints the branch-vs-base diff preview (same as `diff <branch>`) before opening it, so you see what a reviewer will see. `--draft` opens it as a [draft](https://swarmfile.com/docs/guides/branches-and-merging#draft-merge-requests) - reviewable and commentable, but `mr merge` refuses it, and the usual "new merge request" notification never fires for it at all (not deferred - marking it ready later doesn't retroactively send the one it skipped). |
| `list` | `--status <open\|merged\|closed\|all>` (default `open`), `--limit <N>`, `--label <label-id>`, `--assigned-to <principal-id>` | Merge requests for this mount's project. `--limit` is a hint the hub caps at 500 regardless. `--label`/`--assigned-to` filter server-side - see `label list`/[assignees, requested reviewers, and labels](https://swarmfile.com/docs/guides/branches-and-merging#assignees-requested-reviewers-and-labels) for where the ids come from. `--assigned-to` matches the `assignee` role specifically, not a requested reviewer. |
| `status` | `<number>` | Branch names, who opened it, approval state, per-reviewer verdicts, assignees/requested-reviewers/labels, and changed-file count for one merge request, by its display number (the "3" in "MR-003"). Exit code doubles as a CI gate: `0` approved, `2` open but not yet approved, `1` any other error. |
| `diff` | `<number>` | The merge request's live diff - the files it would change (A/M/D/R) plus any conflicts, rendered exactly like `diff` above. Read-only, recomputed fresh by the hub on each call. If the MR's source branch was archived/deleted the hub has no diff and this says so, rather than printing an empty list that reads as "no changes." |
| `approve` | `<number>`, `--note <text>` | Approve a merge request. Refused (`self_review`) if you opened it yourself. |
| `request-changes` | `<number>`, `--note <text>` | Request changes on a merge request - blocks `merge` below until a later review approves it instead. Same self-review restriction as `approve`. |
| `review-comment` | `<number>`, `--note <text>` | Leave a non-blocking "Comment" review - shows up in the review history but never counts toward the approval floor and never blocks `merge`. Unlike `approve`/`request-changes`, allowed on your own merge request. Distinct from `comment`/`comments` below, which post to the discussion thread, not the formal review history. |
| `ready` | `<number>` | Mark a [draft](https://swarmfile.com/docs/guides/branches-and-merging#draft-merge-requests) merge request ready for review - lifts `merge`'s draft refusal, and notifies the merge request's current assignees and requested reviewers (`mr_ready`). Opening it as a draft suppressed the one-time "new merge request" notification, which is not re-sent. One-directional: there's no way back to draft. Refused (`not_draft`) if it isn't currently a draft, or (`invalid_state`) if it isn't open. |
| `merge` | `<number>`, `--record-conflicts`, `--delete-branch`, `--diff3`, `--no-diff3` | Land an approved merge request - same conflict detection as `merge` above, including the `--diff3`/`--no-diff3` override flags. Refused with `not_approved` unless enough reviewers have approved (zero on an unprotected branch; a [protected target branch or rule](https://swarmfile.com/docs/guides/branches-and-merging#protecting-a-branch) raises the floor, 1-10), nobody's latest verdict is "request changes," and (if the target requires it) CI checks pass. Refused with `is_draft` while the merge request is still a draft - run `mr ready` first. `--delete-branch` archives the source branch once the merge has actually landed - opt-in, off by default; a failed archive (e.g. the branch got protected in the meantime) never fails the merge itself, just prints a warning. |
| `close` | `<number>` | Close a merge request without merging it. |
| `reopen` | `<number>` | Reopen a closed merge request. Refused (`invalid_state`) unless it's currently closed - a merged merge request can never be reopened. |
| `comment` | `<number>`, `--message <text>` | Post a comment to a merge request's discussion thread - the same thread the web dashboard's merge request page shows. |
| `comments` | `<number>` | List comments on a merge request's discussion thread. |
| `assign` | `<number>`, `<principal-id>` | Assign a principal (user or group id - see `admin nodes`/the web dashboard's people picker for ids) to land this merge request. Idempotent: assigning someone already assigned is a no-op, not an error. |
| `unassign` | `<number>`, `<principal-id>` | Unassign a principal. A no-op if they weren't assigned. |
| `request-review` | `<number>`, `<principal-id>` | Request a review from a principal - distinct from `assign` (who lands it). Idempotent. |
| `unrequest-review` | `<number>`, `<principal-id>` | Withdraw a review request. A no-op if none was pending. |
| `label` | `<number>`, `<label-id>` | Attach a label (from `label list` below) to this merge request. Idempotent. |
| `unlabel` | `<number>`, `<label-id>` | Detach a label. A no-op if it wasn't attached. |

```bash
swarmfile mr create --title "Warmer facade material"
swarmfile mr create --title "WIP: new roof profile" --draft
swarmfile mr ready 4
swarmfile mr list
swarmfile mr list --label <label-id> --assigned-to <principal-id>
swarmfile mr status 3
swarmfile mr diff 3
swarmfile mr approve 3
swarmfile mr request-changes 3 --note "needs another pass"
swarmfile mr merge 3
swarmfile mr close 3
swarmfile mr reopen 3
swarmfile mr comment 3 --message "Looks good once the fascia texture is swapped."
swarmfile mr assign 3 <principal-id>
swarmfile mr request-review 3 <principal-id>
swarmfile mr label 3 <label-id>
```

`approve`/`request-changes`/`merge`/`close` put every review action a terminal command away. The hub refuses a review from the merge request's own creator; an approval from a second credential counts, so treat review-capable credentials like any other approval authority in your org. `branch protect --required-approvals` raises the bar for a unilateral change.

### `label`

A project's label registry - the tags `mr label`/`mr list --label` above attach to and filter by. See [assignees, requested reviewers, and labels](https://swarmfile.com/docs/guides/branches-and-merging#assignees-requested-reviewers-and-labels).

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | This mount's project's label registry. |
| `create` | `<name>`, `--color <#RRGGBB>` | Define a new label. Org admin/owner only. |
| `update` | `<label-id>`, `--name <name>`, `--color <#RRGGBB>` | Rename and/or recolor a label in place - the alternative to delete-and-recreate, which would drop every existing merge request's attachment. Both flags optional; an omitted one is left unchanged. Org admin/owner only. |
| `delete` | `<label-id>` | Delete a label - cascades off every merge request it was attached to. Org admin/owner only. |

```bash
swarmfile label list
swarmfile label create bug --color "#FF5733"
swarmfile label update <label-id> --name defect --color "#000000"
swarmfile label delete <label-id>
```

### `rfi`

RFIs and Submittals - the AEC request-for-information / approval workflow, the same feature the dashboard's RFI panel drives. Every command here (other than `create`/`list`/`search`/`by-file`) takes an RFI, and you can name it either way: the friendly display number (`RFI-003`, case-insensitive - resolved through the mounted project) or its internal `id` from `rfi create`'s or `rfi list`'s output. Resolving a number needs a mounted project and the same read access the detail view has.

| Subcommand | Flags | Description |
|---|---|---|
| `create <path>` | `--title`, `--body` (required); `--priority <low\|normal\|high\|urgent>`; `--due-at <unix-s>`; `--assigned-to <id>`; `--reviewer <id>` (repeatable); `--kind <rfi\|submittal>` (default `rfi`); `--draft` | Open an RFI (or, `--kind submittal`, a Submittal) rooted at a mount-relative folder - same path resolution as `comment`/`watch`. Needs `write` there. `--draft` creates it unsubmitted; call `submit` when ready. |
| `list` | `--status`, `--priority`, `--ball-in-court <originator\|reviewer\|both>`, `--kind`, `--assigned-to`, `--due-before <unix-s>`, `--outstanding`, `--limit`, `--offset` | RFIs/Submittals matching the filters. `--outstanding` narrows to the same still-needs-attention set (excludes `closed`/`void`/`draft`) the file-browser's badge uses. |
| `status <id>` | - | Full detail, including decision history. Exit `0` if `answered`/`closed` (someone has responded), exit `2` for every other state (`draft`/`open`/`in_review`/`void`) - a CI/pre-merge gate can check "has this been answered." |
| `update <id>` | `--title`, `--body`, `--priority`, `--due-at`, `--assigned-to`, `--ball-in-court` | Change mutable fields - every flag optional, only the ones given change. Originator-or-owner only. |
| `submit <id>` | - | `draft → open`. Originator-or-owner only. |
| `acknowledge <id>` | - | `open → in_review` - a reviewer marking they've seen it. Assigned-reviewers only. |
| `close <id>` | - | `answered → closed`. Originator-or-owner only. |
| `void <id>` | - | `any → void` - abandon it (terminal, leaves the number gap). Originator-or-owner only. |
| `decide <id> <decision>` | `-m, --message <text>` (required); `--signature-kind <click\|drawn>`; `--signature-blob`; `--comment-id` | Record a decision - an RFI's answer, or a Submittal's review outcome. `decision` is one of `approved`, `rejected`, `revisions_requested`, `answered`, `commented`, `approved_rev_a`, `approved_rev_b`, `approved_as_noted`, `rejected_as_noted` - the last four are Submittal-only; the hub doesn't itself reject a mismatched kind, so check `status <id>`'s `kind` first. Comment-or-above ACL. |
| `decisions <id>` | `--limit`, `--offset` | The decision audit trail, oldest first. |
| `assign <id>` | `--add <principal-id[:role]>` (repeatable), `--remove <principal-id>` (repeatable) | Add/update or remove reviewer-role assignments. `role` is `reviewer` (default)/`observer`/`originator`. Admin-gated. |
| `attachments <id>` | - | Files attached under the RFI's root. |
| `search <query>` | `--project-id`, `--limit` | FTS5 search over title/body, ACL-filtered to what you can read. Defaults to the mounted project; pass `--project-id` to search a project this engine isn't mounted on. |
| `by-file <path>` | - | Resolve the RFI (if any) that owns a file - the CLI equivalent of the dashboard's right-click "Open RFI thread." |

```bash
swarmfile rfi create ./Projects/tower-a/foundations --title "Slab thickness at grid C4" --body "Drawing S-201 shows 300mm, spec calls for 350mm - which governs?" --priority high --reviewer user_88
swarmfile rfi list --outstanding
swarmfile rfi status rfi_9f2a
swarmfile rfi decide rfi_9f2a answered -m "350mm per spec - drawing will be revised"
swarmfile rfi status rfi_9f2a   # now exits 0
```

### `ec-placement`

Deliberate erasure-coded shard placement across LAN peers.

| Subcommand | Description |
|---|---|
| `status` | Whether this mount currently has deliberate LAN shard placement on. |
| `enable` | Turn on deliberate LAN shard placement. |
| `disable` | Turn off deliberate LAN shard placement. |

> `ec-placement enable`/`disable` restart the engine to take effect.

Placement spreads shards across your **LAN peers**, which are normally found by mDNS. If your network blocks mDNS multicast (common on corporate/VLAN'd networks), the office won't be discovered and every shard falls back to this machine - set `lan_from_office` (see [Engine Config File](https://swarmfile.com/docs/reference/config-file)) so same-office peers count as LAN.

### `shards <path>`

Where a file's erasure-coded shards live across the office - for each piece, the machines it's assigned to and whether this one holds it. Requires `ec-placement enable` and a live P2P fabric. The command opens with a per-file summary: how many chunks and shards the file has, whether this machine holds enough shards (k per chunk) to reconstruct it alone, and whether placement actually assigned every shard to K peers - so "is this file protected in the office, and from where" is one line instead of counting rows.

## Day-to-day

### `status`

This mount's engine, peers, and settings at a glance. It also prints the mounted branch's head commit pin when one is known (`head commit: <12-char> (<full hash>)`) so a script can capture the exact commit this workspace is on.

`pending uploads` counts this mount's project. When another project still has saves uploading (one you switched away from keeps uploading in the background), the line adds `(N more in other projects)` and one indented line per such project follows, named when the engine has seen the project's name and by id otherwise, with any saves held there by a refusal. `--json` carries the same as `pendingUploadsAllProjects` and `pendingUploadsByProject`.

`--changes` swaps that for a Git-status-like summary of what's outstanding on this mount: the current changelist's staged files and any blocked conflicts. There's no "modified but unsaved" state to show - saves either stage into the changelist or commit immediately - so "staged" plus "conflicted" is the whole picture.

```bash
swarmfile status            # engine, peers, settings
swarmfile status --changes  # staged files + conflicts
```

### `stats`

Where this machine's read bytes actually came from - per file and in total: **this computer**, **a colleague's computer**, or **the cloud** - plus first-read timings. The engine counts each block **once per file per counter window**, at the moment that file is first served it (a cache hit is "this computer"; a LAN/WAN peer transfer is "colleague"; a fetch from cloud storage is "cloud"), including read-ahead, so the cloud and colleague figures are the bytes really downloaded and re-reading or scrubbing a timeline never inflates them. Counters are per project and per engine process; `--reset` starts a fresh window (use it between takes so each recording's numbers stand alone - it zeroes both this project's breakdown and the engine's process-wide bandwidth counters), and the tray's **Data sources** panel shows the same counters live with its own reset button.

`--json` prints the full evidence report - engine version, machine, OS/arch, the mount path/branch, file sizes, per-file source split, read counts and first-read latency - one object you can paste into an evidence log or a journalist can reproduce against. `--csv` prints the same as spreadsheet rows (a header, a summary row and one row per file, each carrying date/version/machine/project) for the evidence log.

```bash
swarmfile stats              # human table
swarmfile stats --json       # the evidence report
swarmfile stats --csv        # spreadsheet rows, one take per machine
swarmfile stats --reset      # zero the counters after printing
```

### `demo reset`

`swarmfile demo reset [--project <id>] [--force] [--yes]` - start a demo take cold on **this machine**: empty the project's local cache (the same whole-project "Free up space" sweep, including "available offline" marks) and zero the `stats` counters - this project's window and the process-wide bandwidth counters, so every surface the recording shows starts at zero. Run it on every machine a recording uses; a second take that quietly read from the cache is the failure this exists to prevent.

Files with saves that have not committed yet are **refused** (`409`) - their bytes may be the only copy - unless `-f`/`--force`, which proceeds while still keeping them. Cached content re-downloads on demand and nothing is deleted at the hub. The confirmation prompt is skipped with `-y`/`--yes`.

```bash
swarmfile demo reset --yes
swarmfile demo reset --project prj_… --force
```

### `demo offline`

`swarmfile demo offline on [--include-lan] [--for <duration>]`, `swarmfile demo offline off`, `swarmfile demo offline status [--json]` - cut **this machine's** network for real, on cue, for a recording of "the internet drops mid-work". It installs host firewall rules (macOS pf, Linux nftables or iptables, Windows Firewall), so the engine sees exactly what a dead office uplink looks like; it is not a simulation, and it works with no engine running.

- **The office LAN stays up by default.** Everything is blocked except this machine itself, private addresses (10/8, 172.16/12, 192.168/16), link-local, IPv6 unique-local and multicast - so office computers keep serving each other while the cloud is out of reach. `--include-lan` blocks the LAN too (everything but this machine); a remote shell to it drops.
- **Existing connections are cut too**, including the engine's long-lived connection to the hub - not just new ones.
- **It always switches itself back off.** `on` arms an automatic `off` after 30 minutes (`--for 90s`, `--for 10m`, `--for 1h30m`; at most 24h) that survives the command, the terminal, and on Windows a restart. `off` ends it sooner and cancels the timer; running it when nothing is on is harmless.
- **Needs administrator rights** for `on` and `off`: on macOS and Linux run it with `sudo`; on Windows, from an elevated terminal. Without them it exits `2` and prints the exact command to run. `status` needs none; `--json` reports `offline`, `scope`, `lanStaysUp`, `since`, `restoreAt` and `remainingSecs` for scripts.

```bash
sudo swarmfile demo offline on --for 10m   # internet off, office LAN up
swarmfile demo offline status
sudo swarmfile demo offline off            # back online
```

### `op`

Some commands do work proportional to your data - `merge`, `commit`, `checkout`, `revert`, `materialize`, `branch create`/`archive`, `mr merge`, trash `restore`/`purge`, `project delete`, `ignore-clean`, `hydrate free-space`, and opening or closing a mount. They run as **operations** in the engine: the command waits and prints the result as usual, however long that takes, but the work no longer depends on the terminal staying open.

- **Ctrl-C detaches** instead of stopping anything: the command exits `3` and prints the operation's id, and the work carries on.
- **`--no-wait`** returns the id at once (exit `3`).
- An operation's outcome is kept, so you can collect it later - even after the engine restarts. One still running when the engine stopped is reported as interrupted.
- When an operation follows a hub background job - an over-cap folder delete/restore/purge or `project delete` (see [Trash, History & Rollback](https://swarmfile.com/docs/guides/trash-history-and-rollback)) - `op status` also shows its `done`/`total` progress while it runs.

| Subcommand | Description |
|---|---|
| `op list` | Recent operations, newest first. |
| `op status <id>` | One operation's state and, once finished, its result. Exits `0` succeeded, `1` failed, `2` canceled, `3` still running. |
| `op wait <id>` | Wait for it to finish and print its result; exits the way the original command would have. |
| `op cancel <id>` | Stop an operation that can safely stop part-way (`materialize`, `hydrate free-space`, `git backfill-oids`, `git index`, and `checkout`/`revert` while still staging). A cancel that arrives once the staged apply has begun is declined (`too_late`) and the operation ends with the commit's real outcome. Commits, merges and deletes complete or fail on their own and refuse (`not_cancellable`). |

```bash
swarmfile --no-wait merge --from feature-x   # prints the operation id, exit 3
swarmfile op wait 1b2c…                      # the merge's own result, when it lands
```

### `mounts`

Manage this engine process's concurrent mount points. One engine can hold several full mounts open at once - of the same project, or (same org only) a genuinely different one via `--project` - each with its own changelists, metadata, and lock manager - for running multiple isolated mounts without paying git's one-worktree-per-branch tax (the motivating case: several AI coding agents, each working against the same project without stepping on each other's saves). Most installs only ever run the one boot mount; this is for when you deliberately want more than one. Multiple live mounts are supported on every platform, with a bounded number per machine (18 on macOS): an `open` fails with `mount_failed` once that limit is reached - close a mount to free a slot. For the headless, per-agent recipe (API key, labeled mounts, shared cache), see [Coding Agents](https://swarmfile.com/docs/guides/coding-agents).

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Every mount this engine currently has open, `(current)` marking the one id-less commands (and any command run without `--mount`) target. |
| `open <mount_point>` | `--label <text>`, `--set-current`, `--branch <name>`, `--project <id>`, `--exclusive` | Open a new mount at a filesystem path, a drive letter (Windows: `P:`), or `auto` - the next free drive letter on Windows, or a new folder beside the boot mount on macOS and Linux, named after the label or branch. On Windows a folder must be empty or not exist yet (Windows creates the mount folder itself); a folder with anything in it is refused with `mount_point_not_empty`, and a drive letter something else holds with `drive_letter_in_use`, naming what holds it. The command returns once the drive has attached, or with the reason it couldn't (`mount_failed`) - a mount that fails to attach is not left registered. It is a full, symmetric mount, equally capable to the boot mount, not a lightweight second view of it. Does **not** become current unless `--set-current` is given, so opening one never retargets a command already running elsewhere against the existing mount. `--branch` is optional; omit it to mount the project's currently-active branch - but a mount opened without `--branch` shares the engine's boot branch context and can't be switched independently (`mounts branch` refuses it with `shared_branch_context`). Naming a *different* branch opens a divergent `(project, branch)` scope: the mount works that branch while every other mount stays on its own, and it can be switched later with `mounts branch`. `--project <id>` opens a mount on a DIFFERENT project than this engine's active one (same org only); it combines with `--branch`, and a cross-project mount pinned to a branch gets that branch's own live-update feed. `--exclusive` opts the mount into **exclusive-write mode**: for as long as a file is open for writing through it, a *sibling* mount opened with the same flag on the same project+branch has its write-open refused (the app sees `EAGAIN`, the same refusal another machine's lock produces) - a rename, delete, or atomic-save replace of that file is refused too - instead of both editing the file and letting whichever closes last win. A mount that does not set the flag neither takes nor honors claims, so existing multi-mount setups are unchanged; open every agent mount with it when several agents share one tree. The flag is fixed for the mount's lifetime and shown by `mounts list`/`mounts status` as `exclusive`. |
| `close <id>` | `--force` | Close a mount. Refused (exit 2, `changelists_open`) if it has open changelists, unless `--force` is given - never auto-cancels staged work. Refused (exit 2, `is_default_mount`) for the boot/default mount specifically: closing it shuts down the whole engine, not just one mount, so it isn't reachable through this command at all - quit the engine itself (the Desktop App's Quit, or stopping the `swarmfile-engine` process) if that's what you actually want. |
| `current <id>` | - | Switch which mount id-less commands target, from then on. |
| `branch <id> [branch]` | `--wait` | Switch one mount's branch independently of every other mount (omitted = the project's default branch). Only works on a mount that was opened with its own `--branch`; a mount sharing the engine's boot branch context is refused with `shared_branch_context`. `--wait` blocks until the (asynchronous) switch lands or fails. |
| `status <id>` | - | One mount's own detail - the same fields `list` shows, for just this id, without scanning the whole list. |
| `scratch-dir <id>` | - | That mount's project's machine-local scratch/dependency-cache directory, plus whatever tool-cache redirects its `.swarmfile/cache.yml` declares (see `scratch` below). Creates the directory - and every declared subdirectory - if it doesn't already exist. |

> The global `--mount <id>` flag (see "Global flags" above) is how every OTHER subcommand targets a specific mount instead of `current` - e.g. `swarmfile --mount mount-abc123 status` runs `status` against that mount regardless of which one is current. It has no effect on `mounts` subcommands themselves, which already take the id as their own argument, nor on `workspace switch` (below), which retargets the whole engine's active workspace; combining the flag with either prints a warning and is otherwise a no-op for the flag.

```bash
swarmfile mounts open ~/mount-2 --label "Agent 2" --set-current
swarmfile mounts open auto --label agent-3 --branch feature-x   # Windows: next free drive letter
swarmfile mounts open ~/agent-3 --exclusive                     # exclusive-write mode for agent fleets
swarmfile mounts open ~/src-only --sparse "src/**"              # a sparse mount - only src/ is visible
swarmfile mounts open ~/no-vendor --exclude "vendor/**"         # everything except vendor/
swarmfile mounts open ~/other-project --project proj_abc123   # different project, same org
swarmfile mounts list
swarmfile mounts status mount-abc123
swarmfile mounts current mount-abc123
swarmfile --mount mount-abc123 status       # target it without switching current
swarmfile mounts close mount-abc123
```

**Sparse mount.** `mounts open --sparse <glob>` (repeatable) exposes only the paths matching those include globs - the same dialect as `materialize --sparse` (a glob matching a directory includes its subtree, a leading `!` excludes an earlier match, a bare name matches at any depth). `--exclude <glob>` is sugar for the `!` negations; with no `--sparse` it means "everything except". An excluded path is invisible at that drive: no listing entry, an open fails as if it weren't there, and a write-create/mkdir/rename onto it is refused. Empty = the whole branch, and the scope is per mount (a sibling mount is unaffected); a scoped mount's offline (`hydrate`/Pack & Go) set matches the scope.

### `workspace`

Show or change the org/project this engine is scoped to - the CLI equivalent of the Desktop App's org and project pickers. A same-org project switch usually takes the hot path (no restart), and an org switch does too while the mount is live; switching to a project the running mount can't be re-scoped to, or switching with no live mount to work with, drains and restarts the engine, so the command may briefly drop its connection. A target project whose key hasn't been released yet doesn't force that restart - the switch waits for the grant and completes in place. A same-org switch *with creates still queued* can also take that restart fallback - queued creates survive it and land in the project they were created under. Refused (exit 2) while a changelist is open, or while another switch is already running.

| Subcommand | Flags | Description |
|---|---|---|
| `status` | - | The current org and active project for this mount. |
| `list` | - | Every org this account can reach, plus every project in the current org, with `(active)` marking the current pair. Use these ids with `switch`. |
| `switch` | `--org <id>`, `--project <id>`, `-y`/`--yes` (`--force`), `--park` | Move the engine to that org, and optionally that project in it. `--org` is required; omitting `--project` clears the stored project so the engine adopts the target org's default. A switch is refused (exit 2) while a changelist is open - `--park` sets the mount's open changelists aside first so the switch can proceed; `--yes` skips the confirmation prompt (which names staged files and queued/failed writes). |

```bash
swarmfile workspace status
swarmfile workspace list
swarmfile workspace switch --org org_abc --project proj_9f2a
swarmfile workspace switch --org org_abc        # adopt that org's default project
```

> `workspace switch` changes the whole engine's active workspace, not one mount, so the global `--mount` flag has no effect on it (the CLI prints a note if you combine them).

### `conflicts`

Lists files that are blocked because the same file was changed in two places
and the two versions haven't been reconciled yet, and resolves them.

While a file is in that state, writes to it are refused, and an application
sees a plain I/O error - a filesystem has no way to explain a cross-machine
change. This command is where the reason lives.

| Subcommand | Flags | Description |
|---|---|---|
| `list` (default - bare `conflicts` is the same) | - | Files blocked by an unresolved conflict, by path. A row on another branch (a branch-merge conflict is recorded under the merge TARGET's entry id) prints the branch name, or `(not on this mount's branch)` when the hub couldn't name it. |
| `resolve <path>` | `--winner local\|remote\|keep-both` | Accept one side non-interactively, without running any tool. |
| `resolve <path>` | `--tool [name]` | Resolve via an external mergetool speaking git's own `mergetool` protocol. Mutually exclusive with `--winner` and `--auto`. |
| `resolve <path>` | `--auto` | Run the engine's three-way diff3 auto-merge and accept the result if it's clean - no external tool, no `gitconfig` needed. Exits **2** when a real conflict remains (a text collision, or a binary). |
| `resolve --all [--auto \| --winner S]` | - | Attempt every conflicted file on this mount's branch, printing one summary at the end. Requires `--auto` (engine merge) or `--winner <local\|remote\|keep-both>` (accept that side everywhere). Rows on other branches are skipped and counted unless `--include-other-branches` is given - pass it when landing a merge (the conflict is recorded under the target branch), or use `merge --diff3`. Exits **2** if any file still needs a human (auto only), **1** if any request failed. |
| `resolve --all --include-other-branches` | - | With `--all`, also act on conflicts the hub reports for other branches. |

```
$ swarmfile conflicts
1 file(s) blocked by an unresolved conflict:

  /Projects/plan.dwg

Writes to these files are refused until the conflict is resolved.
Resolve with `swarmfile conflicts resolve <path> --auto`, `--winner
local|remote|keep-both`, or `--tool`, in the Desktop App, or in the web dashboard.
```

**`--winner`** picks a side directly - same three outcomes as the Desktop App/web picker (keep mine, keep theirs, keep both), just from the CLI:

```bash
swarmfile conflicts resolve /Projects/plan.dwg --winner local
```

For a conflict recorded by `merge --record-conflicts`, `local` is the incoming branch's version and `remote` is the version already on the branch being merged into - see [Branches & Merging](https://swarmfile.com/docs/guides/branches-and-merging#merging).

When `--winner remote` keeps a version different from yours, your edit is preserved in the file's history (it shows in `blame` and the History views) and the command prints a note saying so; `swarmfile rollback --at <id>` can bring it back.

**`--auto`** runs the engine's own three-way diff3 merge and submits it when it comes back clean. It needs nothing installed and no `gitconfig`, which makes it the resolution path to reach for from a pre-flight or CI script:

```bash
swarmfile conflicts resolve /Projects/plan.dwg --auto
```

It exits **2** (not 1) when auto-merge can't produce a clean result - a genuine three-way text collision, or a binary / non-diff3-eligible file - so a script can tell "a human must choose" apart from "the engine was unreachable" and fall back to `--winner` or `--tool`.

Add `--all` to sweep every conflicted file in the project in one pass and clear the text ones automatically, leaving only the files that genuinely need a person:

```bash
swarmfile conflicts resolve --all --auto
```

`--all` also accepts `--winner`, to accept one side across every conflicted file in one pass (useful when a whole branch should keep yours, or theirs):

```bash
swarmfile conflicts resolve --all --winner local
```

**`--tool [name]`** resolves a whole-file conflict - including binary files the Desktop App/web picker's "try automatic merge" can't touch - via an external mergetool speaking git's own protocol: `$BASE`/`$LOCAL`/`$REMOTE`/`$MERGED` temp-file substitution, same as `git mergetool`. This is the one resolution path that's CLI-only; it needs a real terminal/GUI session to run the tool in, which only the foreground CLI process has.

Resolution order is: swarmfile's own `mergetool_cmd` config (see [the config reference](https://swarmfile.com/docs/reference/config-file)) if you've set one - it wins over gitconfig without even reading it - else your git config's `[merge] tool` + `[mergetool "<name>"] cmd`, so anyone with `git mergetool` already configured and no swarmfile-specific override gets it for free. Name one explicitly to bypass both:

```bash
swarmfile conflicts resolve /Projects/plan.dwg --tool           # configured default
swarmfile conflicts resolve /Projects/plan.dwg --tool kdiff3    # a specific tool
```

A nonzero exit from the tool aborts before anything uploads - nothing is resolved, and the CLI tells you where the scratch files (`$BASE`/`$LOCAL`/`$REMOTE`/`$MERGED`) are for a manual look or retry.

### `throttle`

Bandwidth throttling - applies immediately, no restart.

| Subcommand | Flags | Description |
|---|---|---|
| `status` | - | Whether throttling is on, the rate in effect right now, and why (office or off hours, and that window's percentage of the site link). |
| `enable` | `--download-mbps <N>`, `--upload-mbps <N>`, `--office-hours-start <HH:MM>`, `--office-hours-end <HH:MM>`, `--office-hours-wan-pct <0-100>`, `--off-hours-wan-pct <0-100>` | Turn throttling on. Omit a flag to leave that value at whatever it already resolves to. |
| `disable` | - | Turn throttling off. |

The `--office-hours-*` / `--off-hours-*` flags shape a time-of-day schedule: WAN traffic is capped to a percentage of the site link during the office-hours window (`--office-hours-start`/`--office-hours-end`, `HH:MM` local time) and to a different percentage outside it, so background sync can back off while people are working and open up overnight. The window's start and end must be different times: to use one rate all day, set both percentages to the same value. These same values are file-settable as `office_hours_start`/`office_hours_end`/`office_hours_wan_pct`/`off_hours_wan_pct` (see [Engine Config File](https://swarmfile.com/docs/reference/config-file)).

```bash
swarmfile throttle enable --office-hours-start 08:00 --office-hours-end 18:00 \
  --office-hours-wan-pct 20 --off-hours-wan-pct 80
```

**What the cap covers.** Bulk background transfer: uploads, `hydrate`, and
`fetch`. A read an application is actually waiting on - opening a file through
the drive - is **not** throttled, so a cap set to protect the office link can't
turn somebody's file open into a stall. The practical consequence is that a
large `hydrate` or `fetch` is bounded by this setting: both are pre-fetches
filling the cache ahead of use, not somebody sitting in front of a spinner.

### `cache`

Local disk cache size cap - applies immediately, no restart. This is the block cache (the project's own synced content); for machine-local package-manager/build caches shared across mounts, see [`run`](#run) and [`scratch`](#scratch) below - a different, unrelated cache.

| Subcommand | Flags | Description |
|---|---|---|
| `status` | - | Current cache cap and usage. |
| `set` | `<gib: u64>` | Set the local disk cache cap, in GiB. |
| `reclaim` | `--dry-run`, `--yes` | Remove the orphaned pre-project-scoping flat block cache (its blocks re-download on demand). `--dry-run` reports without deleting; otherwise it asks first (`--yes` skips). |
| `resync` | - | Re-fetch this project's hub change feed from the beginning - a repair for a mount whose feed cursor advanced past a change it never applied (an entry missing on that machine alone while the hub serves it correctly). Applies are idempotent; local rows and cached bytes are kept, and the walk runs in the background with the engine retrying until it catches up. |

### `run`

Run a command with this project's declared tool caches (see `.swarmfile/cache.yml`, under [`scratch`](#scratch) below) redirected into environment variables - e.g. `swarmfile run -- pnpm install` points pnpm's store at a directory every mount of this project, on this machine, shares, so a second mount (or a second AI agent's mount) doesn't pay to re-download what the first one already fetched. Put `--` before the command so its own flags aren't parsed as swarmfile's.

Always safe to prefix onto anything: with no `.swarmfile/cache.yml`, a project that declares none, a config with a bad entry, or even an unreachable engine, `run` just warns on stderr and runs the command completely unmodified - it never refuses to run the command over a cache-resolution problem. The wrapped command's stdio and exit code are passed straight through. For wiring several agent mounts onto one shared cache, see [Coding Agents](https://swarmfile.com/docs/guides/coding-agents).

```bash
swarmfile run -- pnpm install
swarmfile run -- cargo build --release
```

### `scratch`

This machine's per-project scratch/dependency caches - the machine-local, non-versioned directories `run` (above) redirects tool caches into. One directory per project, shared by every mount of that project on this machine regardless of branch, the same way a developer's real `~/.cargo/registry` isn't per-checkout either. The worked example (agent mounts sharing one cache, with `gc`) is in [Coding Agents](https://swarmfile.com/docs/guides/coding-agents).

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Every project's scratch cache on this machine: size on disk, how long since it was last used, and whether that project is currently open in this engine. |
| `clear <name>` | `--force` | Delete ONE project's scratch cache outright, by the name `list` shows. Refuses if that project is currently open, unless `--force`. |
| `gc` | `--older-than-days <N>` (default `30`), `--dry-run`, `--yes` | Delete every scratch cache that's both closed and untouched for at least the given number of days. Reports what it removed - or, with `--dry-run`, what it *would* remove - and the total space reclaimed. Asks first (`--yes` skips; `--dry-run` never asks). |
| `init` | - | Write a starter `.swarmfile/cache.yml` into this mount's project root. Refuses to overwrite an existing file. |

```bash
swarmfile scratch init
swarmfile scratch list
swarmfile scratch clear <name> --force
swarmfile scratch gc --older-than-days 14 --dry-run
```

Nothing here is unrecoverable project data: everything under a scratch cache is a re-buildable tool cache (a pnpm store, a cargo registry, ...), never project content, so clearing one only costs a slower next build, not lost work.

#### `.swarmfile/cache.yml`

A versioned, project-root config file - commit it alongside the project, like `.swarmfileignore` - declaring which tool caches `run` should redirect:

```yaml
caches:
  - name: pnpm
    env: PNPM_HOME
    subdir: pnpm
  - name: cargo-registry
    env: CARGO_HOME
    subdir: cargo
```

Each entry needs a `name` (used only in messages), the `env` variable the tool reads for its cache location, and a `subdir` (relative, no `..`) under the shared scratch directory. A single bad entry - an unsafe subdir, a name collision with an earlier entry, or one of a handful of env vars this refuses to redirect on principle (`PATH`, `HOME`, `LD_LIBRARY_PATH`, and similar) - is dropped with a warning; it never takes the other, otherwise-valid entries down with it. Run `swarmfile scratch init` if you'd rather start from a commented example than write one by hand.

> Only redirect READ-MOSTLY, CONTENT-ADDRESSED download caches here - a pnpm store, `~/.cargo/registry`, a pip wheel cache. **Not** build *output* directories (`target/`, an in-progress `node_modules` install): sharing one of those across concurrent mounts risks lock contention, not just staleness.

### `changeset-idle`

Changeset session-minting idle timeout - how long the engine waits after your last write before sealing the open changeset and starting a fresh one on the next write. This groups a burst of related saves into one commit rather than one commit per save. Only applies on a single-project mount. Distinct from `changesets list` (the commit history) - this is the settings command, not a log.

| Subcommand | Flags | Description |
|---|---|---|
| `status` | - | This mount's current changeset idle timeout, in seconds. |
| `set` | `<idle_secs: u64>` | Set the idle timeout. `0` disables commit grouping (writes land ungrouped). |

> `changeset-idle set` restarts the engine to take effect (the changeset manager is only constructed at startup). Persists to the config file's `changeset_idle_secs` - see [Engine Config File](https://swarmfile.com/docs/reference/config-file).

### `attr-cache`

How long (in seconds) the mount may trust cached file/directory attributes before revalidating - this controls how quickly a teammate's newly-saved file stops showing a stale directory listing. `0` means "leave the platform's own default alone" (not "disabled").

| Subcommand | Flags | Description |
|---|---|---|
| `status` | - | This mount's current attribute-cache timeout, in seconds. |
| `set` | `<attr_cache_secs: u64>` | Set the timeout. `0` keeps the platform default. |

> `attr-cache set` restarts the engine to take effect, and applies only to macOS (`fuse-t`) / Linux (`vfs`) builds - it has no effect on a Windows/`winfsp` mount. Persists to the config file's `attr_cache_secs`.

### `hydrate`

Bulk-materialize a scope of this mount into the local cache, pinned against
eviction until released - for a machine that needs to **read** that scope with
no network, or that wants everything local before a job starts rather than
fetching lazily during it. It does not reserve anything for writing: to also
keep a scope editable with no hub, use [`offline prepare`](#offline), which
reserves the whole scope.

| Subcommand | Flags | Description |
|---|---|---|
| `start` | `[path]`, `--yes`, `--wait`, `--sparse <glob>`, `--exclude <glob>` | Download and pin every file under `path` - relative to the mount, or a full path inside it (default: the whole project on the selected mount's branch - use the global `--mount <id>` to target another mount). Prints a size estimate and a warning if it exceeds your cache cap, and asks for confirmation, unless `--yes`. `--sparse`/`--exclude` (the same globs as `mounts open`) narrow it to the matching paths; on a sparse mount the mount's own scope already applies, and a request can't widen it. Polls progress until done, except in `--json` mode, which returns as soon as the job starts unless `--wait` is given. |
| `status` | - | Progress of the current (or most recent) hydrate job. |
| `cancel` | - | Cancel a running hydrate job. A no-op if nothing is running. |
| `release` | `[path]` | Unpin everything hydrated under `path` on the selected mount's branch, making it evictable again. With no path, unpin everything this machine holds for the project - every branch, since the pin map is per machine and project-wide. |
| `free-space` | `[path]`, `--dry-run` | Free up space: delete the downloaded copy of everything under `path` so it's cloud-only on this computer, and stop keeping it offline. Files stay listed and download again when opened. Files with changes that haven't synced yet are skipped. With no path it covers the whole project and also clears older cached versions. `--dry-run` only reports what would be freed. |

```bash
swarmfile hydrate start ./Projects/shots/seqA --yes
swarmfile hydrate start ./src --sparse "src/**" --exclude "src/fixtures/**"
swarmfile hydrate status
swarmfile hydrate release ./Projects/shots/seqA
swarmfile hydrate free-space ./Projects/shots/seqA --dry-run
```

Pinned blocks are never evicted by the cache's normal LRU sweep, so a large
hydrate can push disk usage past your configured `cache` cap - `hydrate
start` warns about this up front rather than silently growing past it.
Pins are permanent until you run `hydrate release`, not time-limited.

### `sync`

Saves that aren't reaching the cloud on their own - the cloud keeps refusing
them, or their upload gave up - and a way to give one up.

| Subcommand | Flags | Description |
|---|---|---|
| `stuck` | - | List stuck saves, one per file: the file's entry id, name and path, why it's stuck (`commit_refused` or `upload_failed`) and since when. A new file refused at a protected project's root is held, not lost: ask an owner to grant project-wide write, or create a folder to put it in - the full reason sentence is in `swarmfile status` and the Desktop App's **Why did my save fail?** |
| `discard` | `<entryId>`, `--yes` | Drop the file's stuck saves and put it back to its cloud version. **The unsynced change on this computer is lost**, so this asks first unless `--yes`. |

```bash
swarmfile sync stuck
swarmfile sync discard 6f1c…e2 --yes
```

### `uploads`

Every **live in-flight upload** on this mount - your own saves and other machines' - from the hub-synced pending pointers. This is the "what is still on its way" view that the Desktop App's **Uploading** badge summarizes, across everyone, not just this computer.

| Flag | Description |
|---|---|
| `--wait` | Poll until nothing is uploading, instead of printing once. Useful before a checkout or a mirror in CI, where acting on a file that is still landing would read the old version. |

```bash
swarmfile uploads
swarmfile uploads --wait   # block until every in-flight upload has landed
```

`swarmfile uploads retract <path>` clears one file's pending pointer when the machine that was saving it is gone for good - the case where no upload will ever finish and nobody should keep waiting on it. It needs **write** access to the file, reads the live pointer first, and clears against that pointer's own manifest: if the upload has re-announced since, the retract is a no-op rather than an interruption, so it can never yank a genuinely live upload out from under its writer.

```bash
swarmfile uploads retract ./Projects/reel-04.r3d
```

A pointer nobody refreshes for 30 minutes stops blocking branch creates, merges from its branch, and copies even without this; retracting is for making that explicit and for the readers that wait on the pointer.

An upload that has **stalled** (a save this machine keeps failing to land) is a different list - see [`sync stuck`](#sync).

### `materialize`

Write a ref's tree to a plain directory - **no mount, no FUSE**, so it works
on a CI box with no kernel driver. The ref resolves through the same
vocabulary as `show`/`restore` (`N`/`#N`, `HEAD~N`, a branch, a tag, a
changeset id, or a 64-hex commit hash; a prefix like `hash:` or `tag:` forces
one kind), and it defaults to the selected mount's branch head.

A commit ref - a number, `HEAD`/`HEAD~N`, a tag, a hash, or another branch's
name - writes **that commit's tree**, exactly as it was committed. Only the
default (or the mount's own branch, e.g. `branch:main` on a main mount) writes
the branch as the mount sees it right now, including saves that haven't been
committed yet. A commit archived before it had a stored tree can't be
materialized (`no_commit_tree`); pick a newer commit or a branch.

On a **protected** project, a member's checkout of a historical ref is
filtered by the permissions that were in force at that commit - the hub
replays its ACL log at the commit's own time, so files they could read then
are written even if they're restricted now, and files they couldn't read then
are left out. If the hub has no ACL history for that commit it prints a note
that the listing was instead filtered against *current* permissions - never
silently. Owners and service/CI callers (see
[Runner](https://swarmfile.com/docs/guides/runner-as-ci)) are never filtered.

The destination must be new or empty, or a directory this tool already
wrote: it keeps a `.swarmfile-materialize.json` marker and updates in place,
pruning only paths it wrote before - so files your job created between runs
(build outputs, caches) survive, and it never overwrites or deletes a
directory it doesn't own.

A 64-hex hash is the most reproducible pin (tags can be repointed, branches
move). Executable files (see `chmod`) are written executable on macOS and
Linux. Exits non-zero if any file failed to materialize.

`--sparse <glob>` (repeatable) checks out only the paths that match - for a
huge repo where a job needs one subtree. A path is written when it matches a
glob or a directory in its ancestry does; `*.md` matches by name at any
depth, `src/**` anchors to the root, and a leading `!` excludes a path an
earlier glob included (gitignore-style). Everything else is neither fetched
nor written, and a directory with no matching file isn't created. At most 64
globs, each at most 1024 characters, at least one non-`!`. `--exclude <glob>`
is sugar for the `!` negations (with no `--sparse`, "everything except"). Omit
either for a full checkout. The `clone --checkout` form takes both flags.

```bash
swarmfile materialize v1.0 --out "$PWD/src"
swarmfile materialize hash:8f14e45fceea167a5a36dedd4bea2543ed10d0f8f9d8f9d8f9d8f9d8f9d8f9d8 --out ./checkout
swarmfile materialize HEAD~2 --out ./prev
swarmfile materialize hash:$COMMIT --out ./checkout --sparse "src/**" --sparse "*.toml"
```

See [Runner (Headless CI)](https://swarmfile.com/docs/guides/runner-as-ci#checking-out-a-ref-in-your-own-ci)
for the full external-CI recipe, including the marker's ownership rules.

### `git status`

How ready this project is to be served to git. Files at or above the git-LFS pointer threshold (default 1 MiB) would reach git as LFS pointers, and a pointer needs the file's SHA-256 object id. `git status` walks the selected mount's branch as the mount sees it and reports how many such files there are, how many still have no recorded id, and the first 50 of those with their sizes. It's the readiness check for turning on [git access](https://swarmfile.com/docs/guides/git-clone#turning-on-git-access), which refuses while any counted file is missing an id.

It reports the access state first, before the readiness counts: **enabled and frozen** (with the conversion version and pointer threshold), **not enabled** (with the command that turns it on), or **not available** on an end-to-end-encrypted project. When the hub can't be reached, that line is omitted rather than guessed at.

| Flag | Meaning |
|---|---|
| `--threshold-bytes <N>` | Count files of at least `N` bytes instead of 1 MiB. |

Files saved through the desktop app (or imported with `swarmfile-migrate`) on a managed or unencrypted project get an id as they're saved. Files added through the web dashboard's uploader or produced by resolving a conflict get one too. Files with no recorded id show as missing until `swarmfile git backfill-oids` fills them in. An end-to-end encrypted project never has ids - Swarmfile doesn't store a hash of E2E content - and says so; neither does content encrypted with a static key you supply yourself (`SWARMFILE_ENCRYPTION_KEY`).

It also reports how many commits on the branch are still **unconfirmed (unsigned)** - with how many of those are disputed and the oldest one's age - when there are any. A commit becomes servable to git as soon as it's derived, even before it's confirmed: a fetch returns the provisional hash and, from a second machine, confirms it. A teammate's app and an API key used in CI can confirm one the same way. A commit that stays unconfirmed for a long time is still worth a look; the warning never changes the exit code.

Exits `0` when every counted file has an id, `2` when some don't (or on an E2E project, or while the hub doesn't yet know the project's encryption tier). `--json` returns the counts and paths.

```bash
swarmfile git status
swarmfile git status --threshold-bytes 10485760 --json
```

### `git backfill-oids`

Records the missing ids `git status` reports. For each file at or above the threshold that has no id, it reads the file through the desktop app's normal read path (downloading it if it isn't cached), computes its SHA-256, and sends the ids to Swarmfile. It works through the files in batches and prints progress as it goes. Project owners and admins only; an end-to-end encrypted project, or one encrypted with your own `SWARMFILE_ENCRYPTION_KEY`, never records ids and is refused.

| Flag | Meaning |
|---|---|
| `--threshold-bytes <N>` | Count files of at least `N` bytes instead of 1 MiB. |

Exits `0` when nothing is left missing, `2` when some files couldn't be read (they're listed) or the project doesn't record ids. Re-running it is safe: files that already have an id are skipped.

```bash
swarmfile git backfill-oids
swarmfile git status   # now reports every counted file as having an id
```

### `git enable`

Turns on [git access](https://swarmfile.com/docs/guides/git-clone#turning-on-git-access) for the selected mount's project - the same one-way switch as the **Git access…** dialog in the web dashboard. Project owners and org admins only; needs a running engine with the project mounted. Enabling freezes the rules the project is converted to git with: the conversion version, the size above which a file becomes an LFS pointer, and where history starts. The record is immutable afterwards (changing any rule would change commit ids a clone has already seen), so the command prints exactly what was frozen and on which project.

Because the freeze cannot be undone, the command asks for confirmation first: `-y`/`--yes` skips the prompt, and a scripted run (`--json`, or no interactive terminal) must pass it rather than freeze silently.

| Flag | Meaning |
|---|---|
| `--threshold-bytes <N>` | Freeze the LFS pointer threshold at `N` bytes instead of 1 MiB. |
| `--no-backfill` | Refuse instead of recording missing object ids first. For scripts that want the fail-fast behavior; interactive runs don't need it. |
| `-y`/`--yes` | Skip the confirmation prompt. Required with `--json` and when stdin isn't a terminal. |

It refuses, with the reason, while the project doesn't keep every saved version (turn that on from the project's history settings), when an older branch or tag head can no longer be read, when history is too long to check in one pass, and on an end-to-end-encrypted project - git access isn't available there.

If any at-or-above-threshold file still has no object id, the command records the missing ids itself first (the same work as `swarmfile git backfill-oids`: it hashes each file's content through the mount, fetching what isn't cached) and retries the enable once. `--no-backfill` skips that and surfaces the coverage refusal directly.

Exits `0` when the record was frozen, `2` on any refusal (the hub's sentence says which) or when the confirmation is missing, `1` when the hub couldn't be reached.

```bash
swarmfile git status          # everything has an id? then:
swarmfile git enable
swarmfile git clone acme/website
```

### `git clone`

Clone a project with git through the `swarmfile://` remote helper - the same as `git clone swarmfile://<org>/<project> [path]`, but it finds the `git-remote-swarmfile` helper next to this binary even when it isn't on your `PATH`. `<project>` is a project id, a bare project name (qualified with your configured organization), or `<org>/<project>`; `[path]` is the destination directory (git's own default when omitted). It runs `git clone` for you, so no running engine is needed. The project must have git access turned on - see [Cloning a Project with Git](https://swarmfile.com/docs/guides/git-clone) for what a clone contains, how it signs in, how `git push` works from it, and what isn't supported.

```bash
swarmfile git clone acme/website
swarmfile git clone acme/website ./website
```

### `git import`

Bring an existing git repository - GitHub, GitLab, a local path - into a new Swarmfile project, or into an existing one with `--into`. The project is created with git access already on and every version kept, and the source's default branch becomes the project's default branch (so upstream commit ids are preserved exactly, including a history whose default branch is `master`, `trunk` or anything else). The source is fetched into a temporary bare mirror with your own `git`, so the same credentials a normal `git clone` would use - and a configured `git-lfs` - apply; each branch and tag is then pushed through the same `swarmfile://` helper [`git clone`](#git-clone) uses. It runs without a running engine, like `git clone`.

The repository name from the URL becomes the project name unless `--name` overrides it. Every branch the git view can hold is imported; one it cannot (submodules, Git-LFS, an octopus merge) is skipped and listed, while the same problem on the source's default branch refuses the import before anything is created - nothing can land without it. `--branches` narrows which branches are attempted (wildcards; the default branch is always imported) and `--no-tags` skips tags; `--submodules fail`/`--octopus fail` widen the refusal to side branches too. Annotated tags are imported as lightweight tags by default (the git view has no tag objects, so tag messages and signatures are not carried; the report says how many); `--annotated-tags skip` leaves them out and `--annotated-tags fail` stops the import before creating anything. Tags pointing at something other than a commit, or living only on a branch this import did not push, are skipped and listed. If anything fails to land, the temporary mirror is kept and the report names the retry: `swarmfile git import <url> --into <org>/<project>` resumes against what already landed; the project is resolved and its git access verified before the source is fetched, so a typo'd or disabled target is refused up front.

The import records the source it came from, so re-running the same command without `--name`/`--into` recognizes the project it made and syncs new upstream branches and commits into it instead of failing on the name. `--new` forces a fresh project anyway (a second project from one source), and an explicit `--name` always creates the named project.

The importer refuses, before creating anything, a source whose default branch uses Git LFS (LFS objects are not fetched yet), contains submodules, or has an octopus merge - each is a limit of git access itself. A project-name collision after the source is fetched is refused too; pass `--name`, or `--into` the existing project.

| Flag | Description |
|---|---|
| `--name <NAME>` | Project name (default: the repository name from the URL). |
| `--org <ORG>` | Organization slug or id (default: your configured organization). |
| `--template <ID>` | A creation template from the project-creation list (default: the `git` configuration without its starter `.gitignore` - an import needs the default branch to start empty, and the repository brings its own files, so a template that seeds files can't be imported into). |
| `--public` | Create a public plaintext project instead of the default private managed one (private projects need a plan with them). |
| `--into <ORG>/<PROJECT>` | Push into an existing project instead of creating one; the project is resolved (before the source is fetched), so an unknown name is refused up front. |
| `--keep` | Keep the temporary mirror after a successful import. |
| `--annotated-tags <convert\|skip\|fail>` | Annotated-tag handling: `convert` to lightweight (default), `skip`, or `fail` before creating anything. |
| `--new` | Create a fresh project even when this source was already imported (the default without `--name`/`--into` is to sync into the project this source made). |
| `--submodules <skip\|fail>` | A branch containing submodules: `skip` (default) lists it and imports the rest, `fail` refuses the whole import. |
| `--octopus <skip\|fail>` | Same for a branch containing an octopus merge. |
| `--branches <GLOB>` | Import only branches matching the wildcard (`*` matches any characters, `/` included); repeatable, and the source's default branch is always imported. |
| `--no-tags` | Skip every tag (branches still import). |

Exits `0` when every ref landed or was deliberately skipped (a tag pointing at a blob or tree is listed as skipped, not failed), `2` on a refusal (the sentence names the fix) or when a branch or tag push failed (the report names the `--into` retry), and `1` on a failure such as the source being unreachable. `--json` prints the structured report. The creation-only flags (`--name`, `--public`, `--template`) are refused alongside `--into`, which already names the project.

```bash
swarmfile git import https://github.com/carlini/js13k2019-yet-another-doom-clone.git
swarmfile git import git@github.com:acme/site.git --name website --org acme
swarmfile git import ./old-repo --into acme/website   # retry a partial import
```

### `git url`

Print a project's `swarmfile://` URL and exit, for wiring a remote by hand. `<project>` is a project id, a bare project name (qualified with your configured organization), or `<org>/<project>`. URL segments are printed ASCII-lowercased and percent-encoded where a character needs it (a display name with a space comes out as `%20`) - the canonical web-address form, which also repairs a caps-typed UUID (a full `swarmfile://` URL is passed through unchanged; the remote helper decodes an escaped segment before resolving it).

```bash
git remote add origin "$(swarmfile git url acme/website)"
```

### `git force-forward`

The append-only form of `git push --force`, run inside a clone. A branch's tree can't be rewritten on Swarmfile, but the end state a force push wants - "the branch's tree is my tree" - can be landed as a *new* commit on top of the current head whose tree is your tip's, with your tip as its second parent. This command fetches the branch (which also snapshots uncommitted drive saves, like any fetch), builds that commit with git's own `commit-tree` (so its id is derived on this machine), and pushes it through the normal helper path: every commit stays reachable, teammates fast-forward instead of diverging, and your tip's history rides the `merged/<sha>` side branch the helper creates and archives. Only committed work is landed (a dirty worktree is ignored, like `git push`), and `--dry-run` still fetches - the dry run skips the push, not the fetch, so a fetch's usual effects apply.

Your local branch is deliberately not moved - the new commit's id cannot match your tip's - so the command prints the `git fetch <remote> && git reset --hard <remote>/<branch>` that aligns your clone. Pass a `BRANCH` (default: the checked-out branch), `--message <MESSAGE>` for the new commit's message (default `force-forward: <your tip's subject>`), `--remote <REMOTE>` (default `origin`), or `--dry-run` to run every check and push nothing. When your tip simply continues the remote head along its first parents (an ordinary fast-forward), a normal `git push` lands the same end state, and the command says so instead of building a graft; the diverged *merge* shape (merging the fetched branch into your line) does need the graft, and the push refusal for it names this command. When the branch already carries your tree there is nothing to do either, and the command just prints the realignment.

A clone can opt in to the same translation for a plain force push: `git config swarmfile.force-forward true`. With it on, `git push --force` (and `--force-with-lease`, whose lease check still runs client-side against the advertised head) pushes the tip as-is when it is an ordinary fast-forward, else builds the same forward commit; a note names the new id and asks you to `git fetch` and reset, since git's own bookkeeping points your remote-tracking ref at the tip you sent. Default off - force pushes stay refused.

Exits `0` when the push landed (or there was nothing to do), `2` on a refusal - detached `HEAD`, an unknown local branch or branch name, a fetch that couldn't find the branch, a push the helper refused - and `1` on a transport failure. A rewritten **root** commit cannot be landed this way: the replaced history shares no commit with Swarmfile, so the push refuses naming the unrelated root - push that as a new branch instead. `--json` prints the outcome - `pushed`, `fast-forward`, `already-at-tip` or `already-carries-tree` - with the commit ids; `pushed` and `already-carries-tree` carry `align`, the realignment command for scripts.

```bash
swarmfile git force-forward                    # this clone's branch
swarmfile git force-forward release -m "keep our tree"
swarmfile git force-forward --dry-run
git config swarmfile.force-forward true        # let plain --force use the same path
```

### `git index`

Derives commits' Git-ready trees on this machine and hands them to Swarmfile, so a committed version can be served to git directly. For each commit it reads the branch state as of that commit's version, builds the tree objects (directories, symlinks and the large-file pointers, splitting very large directories into segments), uploads anything the hub doesn't already have, and claims the result. A second machine - a teammate's app, a fetch, or an API key used in CI - confirms the same value.

With no argument it works on the selected mount's branch head and every unconfirmed commit behind it (through merges too), back to the nearest commit that is already confirmed or that you claimed yourself. Each commit is derived independently and the claims go oldest first, so a chain someone else pushed is confirmed in one run. A run handles up to 1,000 commits and prints one line per commit. Given a commit's sequence number (`#42` or a bare number), it handles just that commit.

The git remote helper also does this by itself for a small unclaimed chain: when a git clone, fetch, push or `ls-remote` meets a branch head that has never been derived, it derives and claims up to 200 commits inline (a clone that set `swarmfile.confirm=false` opts out). This command is for longer chains, or a machine without the Desktop App.

Requires a running engine and a managed or unencrypted project. Exits `0` when every claim was accepted (including ones still waiting for a second machine to confirm), `2` when the hub disputed or rejected one (later commits in the run aren't claimed), and `3` when the hub couldn't be reached.

A commit printed as `rejected` (`resolved_against` in `--json`) was already settled to a different value when the hub resolved a dispute over it, so this machine's claim is kept only as evidence; compare the two with [`git disputes`](#git-disputes) `SEQ --all` and report both hashes, since it is a client bug unless the project's history changed. If the settlement left the commit provisional, an owner who believes it is wrong can override it with [`git clear-claim`](#git-clear-claim); a settlement that confirmed the commit is final.

A commit printed as `provisional (your earlier claim was kept)` (code `self_disagreement` in `--json`) means this machine derived a different value than its **own** earlier claim for the same commit. One identity deriving two values for one commit is a client bug, not a dispute: the hub keeps the first claim, records the disagreement as evidence (readable with `git disputes SEQ --all`), and does not alert owners. Report it with both hashes.

```bash
swarmfile git index                 # the branch head and every unconfirmed commit behind it
swarmfile git index '#42'           # one commit
swarmfile git index --json
swarmfile git index --verify-full   # ignore the cache; re-derive and check every object
```

`--verify-full` re-derives without the incremental/sparse caches and checks each derived object against its content address before uploading - a slow but complete self-check for a suspicious or freshly upgraded machine.

### `git clear-claim`

Owner/admin only: resets the commit claim at a sequence number on the selected mount's project. Most claims are never cleared by hand: Swarmfile's server (for a pushed commit) or a teammate's fetch confirms a provisional one, and a disputed one is settled by Swarmfile deriving it again on the server, usually within a minute or two. This is the escape hatch for a claimed commit that can't be served: a **disputed** row (two derivations disagree), a claim whose root tree is gone, or a **stranded snapshot-mint head** - `git push` mints the branch's live tree as a snapshot when it has drive saves, and if the push is then refused, that snapshot stays as the provisional head; clearing it also rolls the branch head back to the snapshot's parent, so the next fetch mints afresh from the live tree. A healthy claim - including a member's own - is never discarded: the hub answers `cleared: false` and says why.

Clearing resets the hub's record of the claim, so it is confirmation-gated: `-y`/`--yes` skips the prompt, and a scripted run (`--json`, or no interactive terminal) must pass it. Dispute evidence is kept, not deleted: an owner or admin can read both values, their trees, parents and claimants before or after a clear with [`git disputes`](#git-disputes) (add `--all` once it is cleared), or in the **Commit disputes** section of the project's Commits page on the web.

| Argument/flag | Meaning |
|---|---|
| `SEQ` | The changeset sequence number to clear. |
| `-y`/`--yes` | Skip the confirmation prompt. Required with `--json` and when stdin isn't a terminal. |

Exits `0` when the claim was cleared (an already-confirmed or archived seq prints that instead), `2` on a refusal (not an owner/admin) or when the confirmation is missing, `1` when the hub couldn't be reached.

```bash
swarmfile git index '#42'          # see what the hub holds first
swarmfile git clear-claim 42       # reset it; a stranded snapshot head rolls back
swarmfile git clear-claim 42 -y
```

### `git disputes`

Owner/admin only: lists the commit disputes on the selected mount's project, newest first. A dispute means two derivations of the same commit produced different hashes, so git access can't serve that commit until Swarmfile settles the dispute (usually within a minute or two, by deriving the commit again on the server) or an owner or admin clears its claim. Each block shows the commit's sequence number, when the dispute was recorded, the claim's current status, and both sides: the challenging claim and the one it contradicted, each with its commit hash, tree, parents and claimant. A claimant is a user, an API key, or Swarmfile's server-side derivation.

By default only open disputes are listed: not yet cleared, on a commit that is still disputed or provisional. Resolve one Swarmfile could not settle, or override a settlement that left the commit provisional, with [`git clear-claim`](#git-clear-claim); the evidence is kept, and Swarmfile derives the commit again on its own (unless its own derivation was part of the dispute), as does the next `swarmfile git index` or a teammate's fetch.

| Argument/flag | Meaning |
|---|---|
| `SEQ` | Only the disputes for this commit sequence number. |
| `--all` | Include cleared and evidence-only disputes. |

`--json` prints the hub's list unchanged. Exits `0` on success (an empty list included), `2` on a refusal (not an owner/admin), `1` when the hub couldn't be reached.

```bash
swarmfile git disputes             # open disputes on this project
swarmfile git disputes 42 --all    # every record for commit 42, cleared ones included
swarmfile git disputes --json
```

### `offline`

Pack & Go: materialize a scope **and** reserve the whole scope with one
long-lived offline reservation, so it stays editable with the hub
unreachable. `hydrate` above only makes content readable offline; this is the
form that also makes it writable. It runs as a background job, and the scope
follows the selected mount's branch (target another mount with the global
`--mount <id>`).

| Subcommand | Flags | Description |
|---|---|---|
| `prepare` | `[path]`, `--duration-secs <N>`, `--offline-days <D>`, `--no-wait` | Materialize and reserve. The lease defaults to 7 days, clamped at 30; a lease is only renewed while online, so `--offline-days`, when longer than the lease, **extends the lease to cover the trip** (an explicit shorter `--duration-secs` is never overridden - it warns instead). Prints how many files the reservation covers. If another machine holds an overlapping reservation or is editing a file inside the scope, the prepare is refused naming the holder and nothing is reserved. Then polls to completion unless `--no-wait`. |
| `status` | - | Progress of the current (or last) job - `interruptedScope` names one a restart interrupted - plus what this engine holds right now (file edit locks and folder/project reservations, with an expiring-soon and renewal-failure warning). |
| `cancel` | - | Ask a running prepare to stop - reservations already taken are kept, and the content download this prepare started is stopped too (only that job, never a newer one). |
| `resume` | - | Complete a prepare a restart interrupted; re-reserves the scope idempotently. |
| `return` | `[path]` | Release reservations: at or under one path, or with no path every reservation this engine holds (per-file locks and scope reservations alike). In-scope saves are given a few seconds to land first; any still syncing are reported. |

```bash
swarmfile offline prepare ./Projects/shots/seqA --offline-days 14
swarmfile offline status
swarmfile offline return ./Projects/shots/seqA
```

### `fetch`

Pull a byte range of one file into the local cache - including from a version
somebody else is **still uploading**.

On a slow link a large upload takes hours or days, and until it finishes the
new version isn't readable at all. This asks the uploading machine to send the
part you need *first*, then downloads it as it lands. What you get back is the
time it takes to send that range, not the time it takes to send the file.

| Flag | Description |
|---|---|
| `--tail <bytes>` | Fetch the last N bytes. The common case for media, where the part you need is at the end. Resolved against the *in-flight* size, which is why it's a flag rather than arithmetic you do yourself - see below. |
| `--start <bytes>` | First byte of the range. Default `0`. Conflicts with `--tail`. |
| `--len <bytes>` | How many bytes from `--start`. Clamped to the end of the file. Conflicts with `--tail`. |
| `--version <which>` | `newest` (default - the in-flight version if there is one, otherwise the committed one), `pending` (fail unless something is uploading), or `head` (the committed version only). |
| `--follow` | Track a moving reader instead of fetching a fixed range: keep a buffer ahead of wherever it is and re-target when it seeks. `--start` becomes the initial position; `--len` and `--tail` are rejected together with `--follow`. |

```bash
# The last 200 MB of a file a colleague is still uploading
swarmfile fetch ./Projects/reel-04.r3d --tail 209715200

# A specific window of the committed version
swarmfile fetch ./Projects/site.rvt --start 0 --len 52428800 --version head
```

The command polls until every block has landed, printing progress as it goes:

```
$ swarmfile fetch ./Projects/reel-04.r3d --tail 209715200
19/160 blocks, 25.8 MiB fetched (waiting on the upload)
...
fetch complete: 160/160 blocks, 200.0 MiB now local
```

**"waiting on the upload" is the normal state, not a stall.** The blocks you
asked for may not exist yet - the other machine is still sending them at its
uplink speed - so this command can legitimately run for a long time. It gives
up only when nothing is arriving *and* the hub reports that the upload has
stopped, and says so rather than hanging.

Why `--tail` rather than working out the offset yourself: the size to measure
back from is the **in-flight** version's, and the file's committed size is
still the old version's. A number you compute locally would measure from the
wrong end of the wrong file.

`--version pending` fails outright when nothing is uploading, rather than
quietly giving you the committed version. That is deliberate: somebody who
asked for the version in progress and silently received yesterday's bytes has
no way to tell.

Fetched blocks are pinned exactly like `hydrate`, so they aren't evicted by
the cache's LRU sweep before you open the file - and they are released the
same way, with **`swarmfile hydrate release <path>`**. One job runs at a time;
starting a second while one is running is refused rather than queued.

You can also do this without the CLI: reading the file normally through the
mount always works, and gets the committed version. `fetch` is for the case
where the version you want is still on its way. (A mount also streams a file
that has never finished uploading - see [the config
reference](https://swarmfile.com/docs/reference/config-file).)

#### Following a moving reader

A fixed range is the right shape for "give me the last 200 MB". It's the wrong
shape for watching a file as it uploads, because a viewer plays forward and
seeks. `--follow` keeps a buffer ahead of a read position you report as it
moves, and re-targets when it jumps.

| Command | Flags | Description |
|---|---|---|
| `fetch <path> --follow` | `--start <bytes>`, `--version <which>` | Start following from `--start` (default `0`). Returns once the job is running; it does not complete on its own. |
| `fetch-seek` | `<position: u64>` | Report where the reader has got to. The buffer re-targets there immediately. A no-op if nothing is following. |
| `fetch-status` | - | Progress of the current (or last) `fetch`. The way to watch a `--follow` job. |

```bash
swarmfile fetch ./Projects/reel-04.r3d --follow --version pending
swarmfile fetch-seek 5000000
swarmfile fetch-status
```

For a follow job, `fetch-status` reports `position`, `bufferedBytes` and
`readRateBps` rather than a block count. **`bufferedBytes` is the number worth
watching**: it counts contiguous bytes from the read position, so it answers
"will this play" - where a block total does not. A hole two blocks ahead stalls
a reader however full the rest of the window is.

Buffer depth is derived from how fast the reader is actually consuming, not
from a fixed byte count: 30 seconds of a 6 Mbit/s proxy and 30 seconds of a
200 Mbit/s master are very different numbers of bytes, and a single figure
would be useless for one of them. Set the seconds with
`SWARMFILE_STREAM_BUFFER_SECS` (default 30). A seek is not counted as
playback, so scrubbing doesn't inflate the estimate.

A follow job ends when you cancel it, when the upload finishes and the buffer
reaches the end of the file, or after five minutes with no `fetch-seek` - a
paused viewer reports nothing, and one job runs at a time, so an abandoned
follow would otherwise block every later fetch.

### `seed`

NAS/seed-node mode.

| Subcommand | Description |
|---|---|
| `status` | Current seed-mode state. |
| `enable` | Turn on seed mode. Requires the Pro plan or above, and is mutually exclusive with Cloud-only mode. |
| `disable` | Turn off seed mode. |

> `seed enable`/`disable` restart the engine.

> A headless cache is easiest to set up from the dashboard: **Settings → Office caches** generates the complete `seed.env` (scope and credential included) and lists the fleet's liveness and served bytes. `seed enable` is the toggle for an engine whose scope is already configured.

### `watch` / `unwatch` / `watches`

| Command | Description |
|---|---|
| `watch <path>` | Watch a file or folder. |
| `unwatch <path>` | Stop watching a file or folder. |
| `watches` | Everything you're currently watching in this project. |

### `lock` / `unlock` / `locks`

An explicit, up-to-30-day edit lock - like `git lfs lock` / Perforce's `p4 edit`. Not the same as the short-lived, automatic lock a plain write takes (from an app's first write to the file until it closes it); this one you take and release yourself.

| Command | Flags | Description |
|---|---|---|
| `lock <path>` | `--duration-secs <N>` | Reserve exclusive edit rights on a file, even offline. Defaults to 7 days, clamped to a 30-day maximum. Refused (exit 2) if someone else already holds it. Re-running `lock` on a file you already hold extends it, and a held lock is auto-renewed every 6 hours while the engine is online. |
| `unlock <path>` | - | Release a lock taken with `lock`. |
| `locks` | - | Every file currently locked - for editing, or being written somewhere - in this project's current branch. |

```bash
swarmfile lock ./Projects/tower-a.rvt
swarmfile lock ./Projects/tower-a.rvt --duration-secs 86400   # 1 day
swarmfile locks
swarmfile unlock ./Projects/tower-a.rvt
```

If someone else needs the file back before you release it, they can ask via [`unlock-request`](#unlock-request) below - there's no force-release from another user's side.

### `chmod`

Mark a file executable or not - the same bit `chmod +x` sets on the mounted drive, and the one git records as mode `100755` (a script, a build tool) versus `100644`. Swarmfile stores only this one bit: there are no separate read/write bits per user.

| Command | Description |
|---|---|
| `chmod +x <path>` | Make a file executable. |
| `chmod -x <path>` | Make it a plain, non-executable file. |

```bash
swarmfile chmod +x ./tools/build.sh
swarmfile chmod -x ./docs/notes.txt --json
```

- Files only: a folder or symlink is refused (`not_a_file`).
- Works offline. The change shows at once on this machine and is sent when the hub is reachable again; the summary line (or `"queued": true` under `--json`) says when that's still pending.
- On the macOS and Linux drive, `chmod` does the same thing: any execute bit (`u+x`, `g+x` or `o+x`) makes the file executable, and none makes it not. The drive shows executable files as `rwxr-xr-x` and every other file as `rw-r--r--`.
- Windows has no execute bit, so `swarmfile chmod` is how you set it there. Editing a file on Windows keeps its bit, and so does an app that saves through a temporary file and renames it over the original.

### `comment` / `comments`

| Command | Flags | Description |
|---|---|---|
| `comment <path>` | `-m, --message <String>` (required) | Post a comment on a file. |
| `comments <path>` | `--limit <N>`, `--offset <N>` | List comments on a file, newest first. The hub caps one page at 199; `--offset` pages through the rest. |

### `trash`

Soft-deleted entries in this mount's project - the headless equivalent of the Desktop App's Trash panel.

| Subcommand | Flags | Description |
|---|---|---|
| `list` | `--limit <N>` (default 50), `--offset <N>` | Your own soft-deleted entries, newest-deleted first. |
| `restore <entry-id>` | `--with-nearby` | Undelete back to its original location. Refused with `name_conflict`/`parent_deleted` (exit 2) if something else has since taken that name or the parent folder is itself gone - either way the response names the conflicting entry so you can resolve it by hand. A folder deleted from the desktop drive is deleted file by file, so restoring it brings back the folder and lists the items deleted inside it just before it (`nearbyDeleted` under `--json`); `--with-nearby` restores those too. |
| `purge <entry-id>` | `--preview`, `--yes` | Permanently delete ("Delete Forever") - **not** recoverable, unlike a normal delete. `--preview` reports how many descendants a folder would take with it, without removing anything. Asks first; `--yes` skips the prompt (or refuses on a non-interactive stdin / under `--json`). |

```bash
swarmfile trash list --limit 100
swarmfile trash restore 3f9a2b --with-nearby
swarmfile trash purge 3f9a2b --preview
swarmfile trash purge 3f9a2b --yes
```

### `unlock-request`

Ask someone to release a file they have locked - distinct from `lock`/`unlock` above (edit locks *you* hold); this is the "please give it back" workflow for a lock someone **else** holds, the same feature the Desktop App's request-unlock modal drives.

| Subcommand | Flags | Description |
|---|---|---|
| `create <path> [-m, --message]` | - | Ask the current holder to release `path`. If it just freed up on its own, the hub reports that directly (`noLock: true`) instead of creating a request nobody needs to answer. |
| `list [--status] [--entry-id] [--limit]` | `--status` (default `pending`) | Unlock requests you're involved in, as requester or holder. |
| `respond <entry-id> <request-id> --decision <granted\|denied>` | - | As the current **holder**, grant or deny a pending request against a file you have locked. Exit 2 if it's already resolved or isn't yours. |
| `cancel <request-id>` | - | As the **requester**, withdraw a still-pending request. Exit 2 if it's already resolved. |

```bash
swarmfile unlock-request create ./Projects/tower-a.rvt -m "need to push a fix before EOD"
swarmfile unlock-request list --status pending
swarmfile unlock-request respond 3f9a2b req_88 --decision granted
```

### `presence` (alias `who`)

Who's currently editing what in this project.

### `check-ignore <path>`

Report whether a path is excluded from sync by `.gitignore`/`.swarmfileignore`, and by which rule - the diagnostic counterpart to `ignore-clean`. Answers "why isn't this file syncing?" the way `git check-ignore -v` does.

```bash
swarmfile check-ignore ./Projects/node_modules/pkg.js
```

### `ignore-clean [path]`

A `git rm --cached` equivalent: remove already-hub-synced content that matches this machine's own `.gitignore`/`.swarmfileignore` sync-exclusion rules (see `check-ignore`) - for content that synced before the matching rule applied, or from a machine whose ignore rules differ. The hub delete is a **soft delete** (recoverable from Trash for a while), and a matched directory's whole subtree is removed in one call rather than entry by entry. Prints a size estimate and asks for confirmation first, unless `--yes`; without `--yes` a non-interactive stdin or `--json` refuses (exit 2), and `--dry-run` prints the estimate without confirming or deleting.

| Flag | Description |
|---|---|
| `[path]` | Mount-relative path to narrow the scope to a folder or file. Omit for the whole project. |
| `--dry-run` | Print the size estimate and exit without deleting anything - the preview of exactly what `--yes` would remove. |
| `--yes` | Skip the confirmation prompt. |
| `--force` | Cross-machine safety override. Alongside `--yes`, proceed even if a top-level candidate was last modified by **someone other than you** within the last 7 days. Without `--force`, `--yes` refuses in that case. Interactive mode is never gated by this - a human already sees the same who/when detail and answers the prompt regardless. |

```bash
swarmfile ignore-clean
swarmfile ignore-clean ./Projects/old-import --yes
```

### `completions <shell>`

Generate a shell completion script. Works without a running engine. Supports `bash`, `zsh`, `fish`, `elvish`, `powershell`.

```bash
swarmfile completions zsh > ~/.zsh/completions/_swarmfile
```

### `doctor`

Connectivity diagnostics - asks the running engine to run its probe suite. For a machine where the engine isn't running, use the standalone [`swarmfile-doctor`](https://swarmfile.com/docs/cli/swarmfile-doctor) binary instead.

### `log`

The commit log as a compact, colored feed (like `git log --oneline`), scoped to the branch this mount is on. Each line shows the commit's 12-char hash (the full hash is what `hash:` refs and CI pins use).

| Argument / Flag | Description |
|---|---|
| `[REF]` | Start the log at a commit and walk back from it. Full ref vocabulary (`#N`/`HEAD~N`, a branch, a tag, a changeset id, or a 64-hex hash). A branch or tag lists **that** branch's history (`log feature` on a main mount shows feature's commits); a bare number, hash, or changeset id walks the mount's branch. `--branch` wins when given. |
| `--limit <N>` | Default `20` |
| `--branch <name>` | Log another branch's history (read-only) instead of the mount's. |

### `tail`

Watch this project's activity live - comments, watched-file changes, lock conflicts. Ctrl+C to stop.

This is the engine's **local** event bus for the mounted project, not the org-wide Activity feed - for that, use [`activity`](#activity) (`list`/`export`/`tail`). The global `--mount` flag has no effect on `tail`: the stream covers every open mount's activity, not just the current one.

## Headless engines

### `api-key`

Project-scoped API keys, for an engine that runs where nobody can complete a browser sign-in - a render-farm node, a CI runner, a build box. A machine holding one authenticates without an interactive OIDC session at all.

| Subcommand | Description |
|---|---|
| `list` | Every key for this mount's org. Never shows key material. |
| `create <name> [--project <id>]` | Mint a key. `--project` defaults to this mount's own project; it takes the project **id**, not its slug (an id the org doesn't have is refused with `project_not_found`). |
| `revoke <id>` | Revoke by id (from `list`). Immediate - the key stops authenticating right away. |
| `rate-limit <id> [max] [--clear]` | Set (or, with `--clear`, clear) that one key's own request ceiling, in requests per minute. Capped at the plan's per-user maximum - use it to let a render-farm/agent key burst above the plan's flat default. |

Minting and revoking require an **admin or owner** role in this mount's org. The same mint/list/revoke lives in the dashboard, under API keys.

Minting is capped by the plan's **headless-key allowance** - Free 1, Starter 2 per seat (min 5), Pro 5 per seat (min 10), plus any committed key packs - and a mint past the cap is refused with `keys_cap`; revoke an unused key or add a pack under Settings → Billing. Revoking frees the slot immediately, and a downgrade never revokes keys you already have - it only blocks minting new ones past the new allowance.

> `create` prints the raw secret **once**. The hub never returns it again - copy it before you scroll. With `--json` the secret is in the response body's `key` field, which is what makes it scriptable; treat that output as the credential it is.

A key is walled off to exactly one project. It cannot read or write anything else in the org - not even projects the admin who minted it can see - so a compromised farm node is confined to the project it was built for, and revoking a key stops that one machine without touching anybody's own session.

**Using one.** Set it as `SWARMFILE_API_KEY` on the machine, and the engine skips sign-in entirely: no browser, no refresh token, no impersonating whoever happened to mint it. If both `SWARMFILE_API_KEY` and `SWARMFILE_OIDC_REFRESH_TOKEN` are set the API key wins, and the engine says so in its log rather than choosing quietly - a leftover personal refresh token on a shared machine is the more likely mistake of the two.

```bash
swarmfile api-key create farm-node-12 --project proj_9f2a
```

If the mount covers more than one project, `--project` is required: there is no single obvious answer, and guessing one would scope a credential to the wrong place.

See [Deployment Topologies](https://swarmfile.com/docs/guides/deployment-topologies) for how this fits with per-node config files and seed nodes.

### `project`

Project lifecycle and settings in this mount's org - the headless/scripted equivalent of the Desktop App's project settings.

| Subcommand | Flags | Description |
|---|---|---|
| `create <name> [--e2e] [--public] [--commit-mode] [--project-type] [--keep-full-history] [--description] [--git-compatible] [--acl-mode] [--hub-only] [--residency] [--protect-default-branch] [--initial-branch] [--option]` | `--e2e`; `--public`; `--commit-mode <auto-commit\|staged>`; `--project-type <revit_worksharing>`; `--keep-full-history <true\|false>`; `--description <text>`; `--git-compatible`; `--acl-mode <open\|protected>` (Open by default; `protected` needs `acls`); `--hub-only <true\|false>`; `--residency <eu\|us\|fedramp\|fedramp-high>` (pins this **project's** region at creation - per-project and independent of the org's, fixed thereafter; needs the plan's residency capability *and* provisioned storage for the region); `--protect-default-branch`; `--initial-branch <name>` (the default branch's name - most projects keep `main`; fixed at creation); `--option <key=value>` (repeatable - any create option by manifest key, e.g. `template=blank|media|git|revit|geospatial` to apply a project template preset; an explicit option wins over the preset) | Create a project, with the same client-side key generation as the Desktop App's "New Project" dialog. `--e2e` requests an **end-to-end encrypted** project: this engine generates the project key and wraps it - to its own enrolled device, and to the org recovery key if one is configured - before the hub ever sees a byte of it. Requires this device to have finished enrolling its own E2E key first (automatic at startup); a device still enrolling answers with a clear "try again in a moment" rather than a raw error. `--public` creates a **public** project: stored unencrypted and readable without an account (release/dataset hosting), which also makes its cleartext blocks shareable over the org's peer-to-peer swarm. Visibility is fixed at creation, so this is the only chance to ask for it; `--public` conflicts with `--e2e`, since a public project can never be encrypted. `--commit-mode` pins the project's commit mode at creation, leaving it unset if omitted. `--project-type` sets the project's type at creation - only `revit_worksharing` is defined today - so a worksharing project doesn't spend its first mount unenforced. `--description` stores a one-line description (trimmed; up to 2000 characters). `--keep-full-history` decides history retention at creation: omitting it takes the organization's new-project default (keep **every saved version** unless an owner changed it, in **Settings → Organization** or `admin retention set --keep-full-history-default`), while `--keep-full-history false` opts into normal retention, where individual save versions expire after the retention window. Deciding it here (rather than with a later `set-keep-full-history`) means no window of history can be pruned before the setting lands. |
| `list` | - | Every project in this mount's org, with the currently-active one marked. |
| `get <project-id>` | - | Full settings for one project by id (see `list` for ids). |
| `rename [name] [--slug]` | - | Rename this mount's project and/or its slug. |
| `archive` / `unarchive` | - | Soft-archive this mount's project, or reverse it. |
| `delete [--yes]` | `--yes` | Permanently delete this mount's project and all its files, for everyone in the org. A hub-side soft delete: the project leaves active use immediately and its files, history, git-LFS objects, and stored data are permanently purged 30 days later. Recovery isn't self-serve - contact support before the purge window closes to discuss it (it isn't guaranteed; the 30-day window is fixed, and a shorter per-user trash setting does not shorten it). Prints a warning and asks for confirmation first, unless `--yes`; refuses outright in `--json` mode without `--yes` rather than reading stdin. Consider `archive` instead if you just want to hide it. |
| `set-keep-full-history <true\|false>` | - | Opt this project's history out of retention GC (`true`, every past version stays revertable forever) or back into it (`false`). New projects default per the organization's new-project default (keep every saved version unless an owner changed it); `create --keep-full-history false` is the create-time opt-out, and this flips an existing project either way. |
| `set-type <revit_worksharing\|none>` | - | Mark this project as Revit-worksharing (native OS-file-lock enforcement, hub-cross-machine), or clear it with `none`. |
| `set-commit-mode <mode>` | - | Lock this project to a commit mode - a branch lock overrides this while set. `mode` is `auto-commit`, `staged`, or omitted/`unset` to clear. |
| `show [project-id]` | `--json` | Print this project's **effective** settings and where each value came from - the CLI view of the Desktop App's project settings. Defaults to the active project; `--json` for scripting. |
| `set <key=value>…` | - | Set one or more settings in a single call. Supported keys: `commit-mode`, `keep-full-history`, `type`, `name`, `slug`, `description`, `archived`, `hub-only` (`true`/`false`/`inherit`; `false` and `inherit` both mean "inherit the org default" - a project can only add hub-only, never force peer-to-peer over an org hub-only policy), `allow-external-collaborators` (`true`/`false`/`inherit`; the per-project guest policy, where `inherit` follows the org's), `allow-public-rfi` and `allow-public-access-requests` (`true`/`false`; the public project's RFI and access-request toggles, on by default). Refuses an unknown key rather than silently ignoring it. |
| `rotate-key` | - | Rotate **this mount's E2E project key**: mint a new generation and re-wrap it to every current device plus the org recovery key. Owner/admin and E2E only. Content written before the rotation stays sealed under the old generation and is **not** re-encrypted, so a device that already unwrapped the old key can still read it. |

`rename`/`archive`/`unarchive`/`delete`/`set`/`set-keep-full-history`/`set-type`/`set-commit-mode`/`rotate-key` are owner-or-org-owner-gated: exit 2 on `forbidden` or `not_found`, exit 1 for anything else. `rotate-key` also exits 2 on a 409 (`stale_generation` - another device rotated first - or a recipient set that kept changing).

```bash
swarmfile project create "Tower Block A"
swarmfile project create "Secret Project" --e2e
swarmfile project create "Public Dataset" --public
swarmfile project create "Lean Project" --keep-full-history false
swarmfile project create "Model" --project-type revit_worksharing --description "Central model"
swarmfile project create "Repo" --git-compatible --acl-mode protected --protect-default-branch
swarmfile project create "EU Dataset" --residency eu --option hub-only=true
swarmfile project create "Repo" --git-compatible
swarmfile project show
swarmfile project set commit-mode=staged keep-full-history=false hub-only=inherit
swarmfile project rename "Tower Block A - Phase 2"
swarmfile project set-keep-full-history true
swarmfile project rotate-key
```

### `config`

YOUR personal default commit mode for this mount's project - advisory only. Distinct from `project set-commit-mode`/`branch set-commit-mode` above, which are admin **locks**: those, when set, always win over this personal preference. Also distinct from `changeset-idle`/`attr-cache`/etc. above, which are local, per-machine engine-behavior knobs, not a hub-side preference tied to your account.

| Subcommand | Flags | Description |
|---|---|---|
| `set-commit-mode [mode]` | - | Set (or, omitted/`unset`, clear) your personal default. `mode` is `auto-commit` or `staged`. Refused (exit 2, `policy_enforced`) if an admin lock already resolves for this project+branch - a preference is never silently set unable to take effect. |
| `get-commit-mode` | - | Shows your personal preference (`pref`, null when unset) and the **effective** mode with its source: the response's `effectiveMode` is what this mount's next save or `changelist open` will use, and `effectiveSource` is `branch` or `project` for an admin lock (a lock always wins), `user_pref` when your own `set-commit-mode` default applies, or `unenforced` when nothing is set. |

```bash
swarmfile config set-commit-mode staged
swarmfile config get-commit-mode
swarmfile config set-commit-mode unset
```

### `runner`

Read-only run history for the headless runner-as-CI daemon - see [Runner (Headless CI)](https://swarmfile.com/docs/guides/runner-as-ci) for the full walkthrough. Jobs execute unsandboxed as the daemon's own local user (see [Security](https://swarmfile.com/docs/guides/runner-as-ci#security)). There is no `runner create`/`runner edit` here: rules live in `.swarmfile/runner.yml` on the watched branch, not on the hub, so there's nothing on the hub side for this command to create or edit.

| Subcommand | Flags | Description |
|---|---|---|
| `runs` | `--branch <name>` | Run history - every branch by default, or one branch with `--branch`. Each row carries the matched rule's name, the triggering commit, status (`running`/`success`/`failure`/`timed_out`), exit code, and a truncated log. |

```bash
swarmfile runner runs
swarmfile runner runs --branch main
```

### `ci`

Hosted CI (`.swarmfile/ci.yml` workflows that run on Swarmfile's own containers - see the [Hosted CI guide](https://swarmfile.com/docs/guides/hosted-ci)). Requires the engine to be signed in: reads, `settings`/`size list`, `run` and `cancel` need a signed-in member; enable/disable, secrets, variables and size **writes** are owner/admin on the hub, and hosted CI stays off for a project until an owner enables it. In some organizations hosted CI is unavailable entirely, in which case these commands answer `404 ci_not_available`. The workflow dialect is documented in the [Hosted CI guide](https://swarmfile.com/docs/guides/hosted-ci).

| Subcommand | Flags | Description |
|---|---|---|
| `list` | `--branch <name>`, `--limit <n>` | Run history, newest first - every branch by default. Rows carry the workflow name, run number, commit, status and a blocked reason when admission refused. |
| `status` | `<run-id>` | One run plus its jobs: status, size, exit code, billed seconds and metered charge. |
| `cancel` | `<run-id>` | Ask the hub to cancel a queued/running run. |
| `rerun` | `<run-id>` | Re-run a finished run as a NEW run at the same branch, commit and changeset - every job starts fresh. The hub re-runs the trigger gates, so an E2E project or an unprotected branch without the opt-in refuses exactly like the original trigger (with the same typed code). |
| `retry` | `<job-id>` | Retry ONE failed job in place (owner/admin). The job's attempt count bumps, its per-attempt fields reset, and the run reopens and re-finalizes when the job finishes. `success`/`running` jobs cannot be retried. |
| `run` | `--branch <name>`, `--input NAME=VALUE` | Manually dispatch the branch head's workflow (`workflow_dispatch`). `--input` is repeatable for workflows declaring `on.manual.inputs`; the hub coerces values to the declared type. |
| `logs` | `<job-id>`, `--offset <bytes>`, `--follow` | One job's log chunk on stdout (pipe-friendly); `--offset` continues where the last chunk ended. `--follow` keeps reading from the offset cursor (~2 s apart) and stops once the job is terminal and the tail is drained; Ctrl-C stops cleanly. |
| `settings` | - | The project's hosted-CI settings. |
| `enable` | `--allow-unprotected` | Enable hosted CI for the project. `--allow-unprotected` is the loud opt-in for branches without effective protection (workflows can reference secrets, so leave it off unless you mean it). |
| `disable` | - | Disable hosted CI for the project. |
| `secret list` | - | Secret names and metadata only - values are write-only and never printed. |
| `secret set` | `<name>`, `--from-env <VAR>` | Store a secret value: a hidden prompt when run in a terminal, `--from-env <VAR>` to read it from that environment variable, or piped stdin for scripts. The value never echoes or lands in shell history. |
| `secret delete` | `<name>` | Remove a secret. |
| `variable list` | - | Names and values of the non-secret config. |
| `variable set` | `<name> <value>` | Create or update a variable. |
| `variable delete` | `<name>` | Remove a variable. |
| `size list` | - | Org-defined custom runner sizes. |
| `size set` | `<name>`, `--vcpu`, `--memory`, `--disk` | Create or update a custom size (quarter-vCPU steps; the platform enforces the ceilings). |
| `size delete` | `<name>` | Remove a custom size. |

```bash
swarmfile ci list --branch main
swarmfile ci status 018f3c2e-…
swarmfile ci logs 018f3c2e-… --offset 65536 | less
swarmfile ci logs 018f3c2e-… --follow          # live until the job ends
swarmfile ci rerun 018f3c2e-…                  # clone a finished run
swarmfile ci retry 018f3c2e-…                  # re-queue one failed job
swarmfile ci run --branch main
swarmfile ci secret set NPM_TOKEN            # hidden prompt for the value
swarmfile ci secret set NPM_TOKEN --from-env NPM_TOKEN   # read it from the environment
swarmfile ci variable set REGION eu
swarmfile ci size set big --vcpu 4 --memory 12 --disk 20
```

### `office`

Peer-discovery office grouping - applies immediately, no restart.

| Subcommand | Flags | Description |
|---|---|---|
| `status` | - | This mount's current `office_id` and whether `lan_from_office` is on. |
| `set` | `<office_id>`, `--lan-from-office` | Set the office-grouping label this mount announces for peer discovery. Pair with `--lan-from-office` on a network that blocks mDNS multicast - see `ec-placement` above. |

> Seed nodes are the one exception to `--lan-from-office`: a seed node joins another peer's placement roster purely by matching `office_id`, with no flag and no shared network required at all - see [Self-Hosted Seed Nodes](https://swarmfile.com/docs/admin/self-hosted-seed-nodes) for using this to give a cloud-hosted render farm deliberate redundancy among its own nodes.

### `acl`

Folder-ACL project mode and the ACL change log - the headless equivalent of the dashboard's ACL settings panel.

| Subcommand | Flags | Description |
|---|---|---|
| `mode get` | - | Whether this mount's project is `open` (default-readable) or `protected` (default-denied, entries need an explicit grant). |
| `mode set <open\|protected>` | - | Switch project ACL mode. Admin-or-owner-gated: exit 2 on `forbidden`/`not_found`. Switching **to** `protected` additionally needs the org's plan to include folder ACLs - exit 2 with `plan_restricted` (402) if not. |
| `audit [--entry-id] [--limit] [--offset]` | - | The ACL change log - every grant/revoke, project- or entry-scoped. |
| `entry get <entry-id>` | - | The ACL roster on one entry (direct + inherited rows). `<entry-id>` accepts a mount path too (resolved first). Admin-gated: 403s for a member who is neither an org admin nor an entry admin (exit 2), since the roster reveals who else has access. |
| `entry set <entry-id>` | `--principal <id>`, `--permission <read\|comment\|comment_upload\|write\|admin>`, `--effect <allow\|deny>` (default `allow`), `--inherit` | Add or update a grant. Creating one needs the org's plan to include folder ACLs - exit 2 with `plan_restricted` (402) on Starter. |
| `entry remove <entry-id> <principal-id>` | - | Revoke a principal's grant on an entry. |
| `principals [--query] [--limit] [--offset]` | - | Look up an ACL `principal id` - the value `entry set --principal` and `mr assign` take. `--query` searches display name/email case-insensitively; without it, lists the org's principals. One page is capped at 50 rows; `--offset` pages past it, for a search as well as the full list. |

```bash
swarmfile acl mode get
swarmfile acl mode set protected
swarmfile acl audit --entry-id 3f9a2b --limit 50
swarmfile acl principals --query "Priya"
swarmfile acl entry get 3f9a2b
swarmfile acl entry set 3f9a2b --principal u_9f2a --permission write --inherit
swarmfile acl entry remove 3f9a2b u_9f2a
```

`acl entry` is the per-entry grant surface the project-wide `mode` toggle doesn't cover; the same grants are editable from the dashboard's permissions panel, and (on Windows) projected as NTFS DACLs on the mount.

### `e2e-key`

End-to-end encryption device keys.

| Subcommand | Description |
|---|---|
| `status` | This device's own enrolled key - id and public key - read straight from the local cache file, no hub round trip. `enrolled: false` means this device hasn't finished enrolling yet (or the binary was built without the `e2e` feature at all, flagged with `e2eBuild: false`). |
| `list` | Every device key on this account - every machine, not just this one. For finding the id of a lost device to revoke. |
| `revoke <id>` | Revoke a device key by id (see `list`) - e.g. after a lost laptop. |

There's no `enroll` here - a device enrolls its own key automatically at startup.

```bash
swarmfile e2e-key status
swarmfile e2e-key list
swarmfile e2e-key revoke key_9f2a
```

### `device-transfer`

Move a device's end-to-end project keys onto a second device without going through the org's recovery-key quorum - a one-time, ten-minute code links the new device to an already-enrolled one and moves the wrapped project keys across, entirely client-side. The private key never touches the hub.

| Subcommand | Description |
|---|---|
| `start` | Run on the device that already has the project's key. Prints a one-time code, waits for a new device to link, then uploads this mount's currently-mounted project key to it automatically. Requires an enrolled E2E key and a mounted E2E project on this engine. |
| `link <code> [--label]` | Run on the new device with the code from `start`. Waits for the other device to upload, then finalizes - re-wrapping every recovered project key to this device's own already-enrolled key. `--label` names the device (shown later in `e2e-key list` and **Settings → My Devices**). |

Both commands poll every couple of seconds until the handshake completes or the code expires; past 10 minutes either side reports a clear "expired, run `start` again" rather than hanging.

```bash
# On the device that already has the project:
swarmfile device-transfer start
# Transfer code: 7F3K-9QRT
# On the new device, within 10 minutes, run:
#     swarmfile device-transfer link 7F3K-9QRT

# On the new device:
swarmfile device-transfer link 7F3K-9QRT --label "Jordan's laptop"
```

`start` transfers only the mount's currently-configured project - run it again, once per mounted project, on a device that holds more than one. There's no `--enroll` step on the new device: every engine enrolls its own long-term device key automatically at startup, and `link`/`finalize` re-wrap to that key directly.

If nobody's engine is online to complete an ordinary access grant, an org member can also fulfill it manually from the web dashboard rather than running this at all - see [End-to-End Encryption Setup](https://swarmfile.com/docs/admin/end-to-end-encryption).

### `admin nodes`

The hub's P2P mesh node allow-list - owner-only. `node_id` is the 64-hex-char Ed25519 public key an engine self-generates (visible in the engine's own startup log, or in a not-yet-authorized node's connection-refused message). The same list, with an authorize form and a revoke button, is also available from **Settings → Nodes** in the web dashboard.

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Every node currently on the org's allow-list. |
| `authorize <node-id> <user-id> [--label]` | - | Add a node, authenticating as `user-id`. |
| `revoke <node-id>` | - | Remove a node - it can no longer join the mesh. |

```bash
swarmfile admin nodes list
swarmfile admin nodes authorize a1b2c3...64hex user_88 --label "render-farm-3"
```

### `admin retention`

The organization's trash/history retention window and its **size-adaptive floor**, plus the history-retention default for NEW projects - owner-only. When a project approaches its storage limit, the hub automatically shrinks the retention window under pressure; the floor is the tightest it may ever shrink to. Both are `null` (the global defaults) until set. `keepFullHistoryDefault` decides what newly created projects start with: `true` (the product default) keeps every saved version, `false` starts them on named commits only - a creator's explicit choice at creation still wins. The same controls are in **Settings → Organization** in the web dashboard.

| Subcommand | Flags | Description |
|---|---|---|
| `get` | - | The current window, floor, and new-project retention default. |
| `set` | `--days <1-3650>`, `--floor <1-3650>`, `--clear-days`, `--clear-floor`, `--keep-full-history-default <true\|false>` | Set and/or clear the day values, and/or set the new-project default. Omitted fields are left unchanged. The hub rejects a floor above the window (it would be inert). |

```bash
swarmfile admin retention get
swarmfile admin retention set --days 120 --floor 30
swarmfile admin retention set --clear-floor
swarmfile admin retention set --keep-full-history-default false
```

### `admin rate-limits`

The organization's **request budgets**: the per-org and per-user request ceilings, in requests per minute - owner/admin-configurable. The same limits appear in the tray's **Settings → Developer** panel and the web dashboard's **Settings → Organization** page. `show` reports the current values alongside each plan default and maximum; `set` changes them. The Free plan's limits are fixed, Starter/Pro cap at their tier maxima, and Enterprise's org ceiling can be removed entirely. A PAT shares its owner's per-user window; an API key gets its own window at the plan's default per-user value; content streaming isn't counted here.

| Subcommand | Flags | Description |
|---|---|---|
| `show` | - | The current per-org and per-user ceilings, their plan defaults and maxima, and whether each is configurable. |
| `usage` | - | This minute's usage against the ceilings, for the whole org and for you (the caller), including the separate media-read windows. |
| `set` | `--org <n>`, `--user <n>`, `--clear-org`, `--clear-user`, `--unlimited-org`, `--for <dur>` | Set and/or clear either ceiling. Omitted fields are left unchanged. `--clear-*` resets a field to the plan default; `--unlimited-org` removes the org ceiling entirely (Enterprise only); `--for 6h` (units `s`/`m`/`h`/`d`, max 30 days) makes a raise auto-revert. The hub rejects a value above the plan's max and refuses any change on the Free plan. |

`rate-limits` is also available at the top level (`swarmfile rate-limits …`) - the same subcommands, without the `admin` prefix.

```bash
swarmfile admin rate-limits show
swarmfile admin rate-limits usage
swarmfile admin rate-limits set --org 4000 --user 450
swarmfile admin rate-limits set --org 8000 --for 6h
swarmfile rate-limits set --clear-org
swarmfile rate-limits set --unlimited-org
```

### `notifications`

This account's notification inbox - comments, mentions, unlock requests, merge requests, RFIs, quarantine, failed uploads, and more. `list`/`unread-count`/`read`/`read-all` hit the **hub's full notification feed** (the same inbox the dashboard's bell shows), not just this machine's own upload failures.

| Subcommand | Flags | Description |
|---|---|---|
| `list` | `--kind`, `--unread`, `--since <time>`, `--limit` | Notification history, newest first. `--since` accepts Unix seconds, epoch milliseconds, a duration (`2h`, `7d`), or an RFC 3339 timestamp. `--kind` accepts a hub notification kind (the same open vocabulary - comments, mentions, unlocks, RFIs, MRs, quarantine, uploads, storage, …); an unknown value is rejected with the accepted list and exit 2, so a typo can't silently return the whole inbox. |
| `unread-count` | - | Cheap count for a shell prompt or a CI/cron check. |
| `read <id>` | - | Mark one notification read. |
| `read-all [--kind]` | - | Mark every notification read, optionally narrowed to one kind. An unknown `--kind` is rejected (exit 2) rather than marking the whole inbox read. |
| `preferences get` | - | Current email/webhook delivery preferences - the webhook status (`url`/`enabled`/`configured`) rides along in this same output. |
| `preferences set` | `--email-enabled [<true\|false>]` (bare = on), `--quiet-start`/`--quiet-end` (`HH:MM`), `--timezone`, `--kind <KIND=on\|off\|default>` (repeatable) | Update one or more fields - omitted flags leave the existing value untouched. `--quiet-start`/`--quiet-end` must both be set together or both left unset. Clearing quiet hours or the timezone is dashboard-only: the API takes an explicit null for it, but the CLI has no null form (an empty value is a validation refusal), so there is no CLI clear. `--kind` sets a per-kind email override (`off` mutes that event, `on` forces it, `default` clears that kind's whole override - email and webhook - back to the kind's defaults: the global switches plus that kind's own default, where one applies); the CLI merges into your current overrides, so other kinds and their webhook flags are preserved. An unknown kind or malformed spec exits 2. Exit 2 on a `400` (mismatched pair or malformed time). |
| `preferences webhook set <url>` | `--enabled [<true\|false>]` (bare = on) | Set (or change) YOUR OWN personal webhook - a third notification channel next to the in-app bell and email, one URL per user. The `<url>` argument is required on every call (no partial "just flip `--enabled`" shorthand - pass the same URL again to toggle it alone). Mints a fresh signing secret whenever the URL changes, or is set for the first time, and prints it ONCE. Distinct from the org-wide `swarmfile webhook` command group below: this one is fire-and-forget, with no delivery log or auto-disable. |
| `preferences webhook clear` | - | Remove your personal webhook. |
| `preferences webhook rotate-secret` | - | Mint a fresh signing secret without changing the URL. Hard cutover - the old secret stops verifying immediately. Prints the new secret ONCE. |
| `preferences webhook test` | - | Fire one synthetic event at your configured webhook right now, synchronously. Nothing is logged - this channel has no delivery log - the result prints directly. |

```bash
swarmfile notifications list --unread --limit 20
swarmfile notifications list --kind comment_mention
swarmfile notifications read-all
swarmfile notifications preferences set --quiet-start 22:00 --quiet-end 07:00 --timezone America/New_York
swarmfile notifications preferences set --kind file_changed_by_other=off
swarmfile notifications preferences set --kind comment_added=default   # clears email + webhook for that kind
```

### `share`

Share links - a read/comment/respond link to one file, handed to someone outside the org. This covers only the member-authed management side: creating your own links, listing/revoking them, and (owner-only) the access log and email block-list. The anonymous recipient's own side of a link is the web-hosted `/s/:token` page, not a CLI surface.

| Subcommand | Flags | Description |
|---|---|---|
| `create <entry-id>` | `--expires-at <unix-secs>`, `--max-accesses <N>`, `--password <string>`, `--allow-comments`, `--allow-responses` | Create a link for one file. `<entry-id>` accepts a mount path too (the path is resolved first). Exactly one of `--allow-comments`/`--allow-responses` is meaningful - the hub picks the more permissive one if both are set. `--allow-responses` is the RFI share-link path (needs a directory entry, not a file) - see [RFI attachments](#rfi). Exit 2 on a `400` (missing/invalid `entry-id`, or wrong entry type for the requested permission) or a `404` (entry gone). |
| `list` | `--entry-id` | The caller's own share links, optionally narrowed to one file. |
| `revoke <id>` | - | Revoke a link. Exit 2 on `404` (not found, or not yours). |
| `access-log <id>` | `--limit`, `--offset` | Who has actually opened this link (the mandatory-email-gating log, not just a hit count). Owner (creator) only - exit 2 on `404`. |
| `block <id> <email>` | - | Block one email from this link going forward. Owner only. Takes effect on that email's next gated request - an already-issued 30-day session cookie isn't separately revoked. |
| `unblock <id> <email>` | - | Unblock a blocked email address. Owner only. |

```bash
swarmfile share create e-1a2b3c --allow-comments --expires-at 1735689600
swarmfile share list --entry-id e-1a2b3c
swarmfile share access-log shr_9f2a --limit 20
swarmfile share block shr_9f2a spammer@example.com
swarmfile share revoke shr_9f2a
```

### `activity`

The org/project activity feed - commits, branch switches, comments, CI runs, membership/ACL changes, who did what and when. An org owner sees everything; everyone else only what they have read access to (owner-only sources like quarantine and org-policy events project to empty for them).

| Subcommand | Flags | Description |
|---|---|---|
| `list` | `--since`/`--until <time>`, `--limit`, `--kind <csv>`, `--project-id`, `--entry-id`, `--actor-id`, `--cursor` | The feed, newest-filtered-window first. Defaults to the last 7 days when `--since`/`--until` are omitted. `--since`/`--until` accept Unix seconds, epoch milliseconds, a duration (`2h`, `7d`), or an RFC 3339 timestamp. `--kind` takes a comma-separated list of event kinds. `--cursor` resumes from a previous response's pagination cursor. |
| `export` | `--since`/`--until`, `--kind`, `--project-id`, `--out <path>` | Unbounded CSV export (capped at 100k rows hub-side) - **org-owner-only**, and needs the Pro+ `audit_log` entitlement (exit 2 on the `403` either gate returns). Prints CSV to stdout by default; `--out` writes it to a file instead. Not paginated - narrow `--since`/`--until` if you need a complete window (a response is capped at 100k rows). |
| `tail` | `--interval <secs>` (default 3), `--since <time>`, `--kind`, `--project-id`, `--actor-id` | Follow the feed: print new events as they land, until Ctrl+C. Starts from now unless `--since` is given (same time shapes as `list`). |

`tail` follows by polling the same `GET /activity` endpoint `list` uses - polling is the supported way to follow the feed. Expect a few seconds of latency, bounded by `--interval`. With `--json` it prints one event per line (JSON-lines), so a consumer can process the stream incrementally instead of re-parsing a document that repeats each poll.

```bash
swarmfile activity list --kind mr_merged,commit_created --limit 50
swarmfile activity list --since 1735689600 --project-id p_9f2a
swarmfile activity tail --kind commit_created
swarmfile activity export --since 1767225600 --until 1798675200 --out audit-2026.csv
```

### `webhook`

Manages this **org's** [outbound webhook](https://swarmfile.com/docs/admin/webhooks) subscriptions - signed HTTP callbacks fired on file/branch/merge/ACL/membership events, straight into Slack, Zapier, a SIEM, or your own endpoint. Everything the web dashboard's webhook settings page does is available here too; this is the org-wide subscription list, distinct from the single personal channel under `notifications preferences webhook` above.

| Subcommand | Flags | Description |
|---|---|---|
| `list` | - | Every webhook subscription for this mount's org. Never shows signing secrets. |
| `create <url>` | `--event <kind>` (repeatable), `--description` | Subscribe a URL to org activity events. Prints the raw signing secret ONCE - copy it now, the hub never returns it again. `--event` may be repeated to subscribe to specific kinds only; omit it to receive every kind. |
| `delete <id>` | - | Remove a subscription. Immediate. |
| `reenable <id>` | - | Clear a subscription's auto-disable - the hub disables one automatically after too many consecutive delivery failures. |
| `rotate-secret <id>` | - | Mint a fresh signing secret. Hard cutover - the old secret stops verifying immediately. Prints the new secret ONCE. |
| `update <id>` | `--url`, `--description`, `--clear-description` | Update a subscription's URL and/or description. An omitted flag keeps its current value. Doesn't touch which event kinds it receives or its custom headers. |
| `deliveries <id>` | `--limit`, `--cursor` | A subscription's delivery log, newest first, cursor-paginated (use the previous response's `nextCursor`). |
| `test <id>` | - | Fire one synthetic event at a subscription's URL right now, synchronously. Recorded in the delivery log, but never counts toward auto-disable health tracking. |
| `retry-delivery <id> <delivery-id>` | - | Manually re-send one past delivery. A delivery with no stored payload can't be retried. |

```bash
swarmfile webhook create https://hooks.example.com/swarmfile --event mr_merged --event commit_created
swarmfile webhook list
swarmfile webhook deliveries wh_9f2a --limit 20
swarmfile webhook rotate-secret wh_9f2a
```

### Scripting against the CLI

Pass the global `--json` (before or after the subcommand - both work) and it prints the engine's raw response, which is the intended path for automation - commit from CI, hydrate before a render, check `conflicts` in a pre-flight script (`conflicts resolve <path> --auto` clears text conflicts headlessly, exiting 2 when a human is needed). A usage error under `--json` emits `{"code":"usage_error",…}` rather than clap's prose, so the same parser handles both. Pair it with an API key and no part of the loop needs a human.

For reacting to changes rather than polling for them, `tail` streams this project's activity as it happens - hold that connection open for sub-second reaction. If your integration doesn't want to hold a connection open at all, `swarmfile webhook create` (org owner) or `notifications preferences webhook set` (personal) both set up a signed HTTP callback to a URL you control instead. They differ in delivery: org webhooks are delivered on a periodic schedule, not in real time, with retries and a delivery log; the personal channel is fire-and-forget, enqueued as each notification happens, with no delivery log, retry, or auto-disable.
