Canonical repository: git.keiailab.com/keiailab-oss/gitswarm · mirror: github.com/KeiaiLab/gitswarm (issues: github.com/KeiaiLab/gitswarm/issues)
gitswarm is a coordination layer over any git remote for many LLM agents
working on the same repository at once. Each agent run gets an isolated
workspace branch; agents read each other's work without a checkout, publish,
and drop. All state lives in the remote (refs/heads/gitswarm/*): no server,
no daemon, no database. Every CLI command has one MCP tool with the same
arguments and result.
uv tool install gitswarm # or: uvx gitswarm --helpPyPI publication is pending. Until then, install from git:
uvx --from git+https://github.com/KeiaiLab/gitswarm gitswarm --helpClaude Code (details: docs/recipes/claude-code.md):
claude mcp add gitswarm -- uvx gitswarm mcp
# until PyPI:
claude mcp add gitswarm -- uvx --from git+https://github.com/KeiaiLab/gitswarm gitswarm mcpPlatform: POSIX (Linux, macOS), git >= 2.39, Python >= 3.11.
Run it inside a clone. gitswarm finds the remote (origin), creates its
local mirror on first use, and branches from the remote HEAD. The outputs
below are real, from a file:// remote whose default branch is main.
$ cd repo
$ gitswarm ws create --agent impl --checkout
{"ok": true, "id": "01M4B3NX30CF0XQFN7FGKTRSBC", "branch": "refs/heads/gitswarm/ws/01M4B3NX30CF0XQFN7FGKTRSBC", "base_oid": "bc876f294bbcf32c441f7e13bfef37700cc1c785", "path": "/tmp/demo/home/hives/c63648f6fa869444/wt/01M4B3NX30CF0XQFN7FGKTRSBC", "token": null, "clone": "git clone -b gitswarm/ws/01M4B3NX30CF0XQFN7FGKTRSBC file:///tmp/demo/remote.git", "branch_name": "gitswarm/ws/01M4B3NX30CF0XQFN7FGKTRSBC", "remote": "file:///tmp/demo/remote.git"}Work in path with plain git, then publish. Inside the worktree the remote
is found from the hive, so no flag is needed.
$ cd /tmp/demo/home/hives/c63648f6fa869444/wt/01M4B3NX30CF0XQFN7FGKTRSBC
$ git add src/calc.py && git commit -qm "Add calc"
$ gitswarm ws publish 01M4B3NX30CF0XQFN7FGKTRSBC
{"ok": true, "id": "01M4B3NX30CF0XQFN7FGKTRSBC", "oid": "0212f996898c32b842cdc3068580f9ac019391a0", "remote": "file:///tmp/demo/remote.git"}publish pushes with a lease whose baseline is gitswarm's own last push to
the branch; a plain git fetch does not move it. On Conflict, rebase onto
the remote branch, then publish again (below).
Anyone can read it without a checkout:
$ gitswarm ws tree 01M4B3NX30CF0XQFN7FGKTRSBC src
{"ok": true, "path": "src", "entries": [{"name": "app.py", "kind": "blob", "oid": "b80e3222ab264bd7cafb376749bd18814fd66776"}, {"name": "calc.py", "kind": "blob", "oid": "4693ad3cf8b0903b98497fb89b8b524fbf1b93f4"}], "remote": "file:///tmp/demo/remote.git"}
$ gitswarm ws read 01M4B3NX30CF0XQFN7FGKTRSBC src/calc.py
{"ok": true, "path": "src/calc.py", "content": "def add(a, b):\n return a + b\n", "remote": "file:///tmp/demo/remote.git"}Drop it when done. This deletes the remote branch, the worktree and any
token. It is idempotent: dropping again repeats the cleanup, so a branch
pushed after the drop is deleted again. Before deleting, drop records the
local branch tip and worktree HEAD in the reflog of refs/gitswarm/trash
(message drop <id>), so a lost commit stays recoverable for 90 days:
git -C <hive>/repo.git reflog show refs/gitswarm/trash, then
git -C <hive>/repo.git branch <name> <oid>.
$ gitswarm ws drop 01M4B3NX30CF0XQFN7FGKTRSBC
{"ok": true, "id": "01M4B3NX30CF0XQFN7FGKTRSBC", "state": "dropped", "base_ref": "refs/heads/main", "base_oid": "bc876f294bbcf32c441f7e13bfef37700cc1c785", "branch": "refs/heads/gitswarm/ws/01M4B3NX30CF0XQFN7FGKTRSBC", "agent": {"name": "impl"}, "parent": null, "created_at": "2026-10-07T11:58:44Z", "ttl_s": 7200, "labels": {}, "token_id": null, "published_oid": "0212f996898c32b842cdc3068580f9ac019391a0", "remote": "file:///tmp/demo/remote.git"}The remote is chosen in this order: --remote, $GITSWARM_REMOTE, the cwd
(inside a hive worktree: that hive; inside a git repo: origin), and for
commands that take an id (get, read, tree, publish, drop) the one
hive holding wt/<id> (two such hives: Usage, pass --remote). The
remote key of every success line is the remote actually used. With none
of these:
$ cd /tmp && gitswarm ws list
{"ok": false, "error": {"kind": "Usage", "detail": "no remote: pass --remote (MCP: remote), set $GITSWARM_REMOTE, or run inside a git repo with an origin"}}--base defaults to the branch the remote HEAD names (main here, stable
on some hosts). Pass it to pin the base and save one round trip.
An agent on another machine needs only git. Run the clone field of the
create result, commit, push, then record the result with ws publish. A
plain git push does not change the workspace state; ws publish does.
$ gitswarm ws create --agent reviewer
{"ok": true, "id": "01M4B3P6V3D549KPH4SC5BTJ74", "branch": "refs/heads/gitswarm/ws/01M4B3P6V3D549KPH4SC5BTJ74", "base_oid": "bc876f294bbcf32c441f7e13bfef37700cc1c785", "path": null, "token": null, "clone": "git clone -b gitswarm/ws/01M4B3P6V3D549KPH4SC5BTJ74 file:///tmp/demo/remote.git", "branch_name": "gitswarm/ws/01M4B3P6V3D549KPH4SC5BTJ74", "remote": "file:///tmp/demo/remote.git"}
# on the other host
$ git clone -b gitswarm/ws/01M4B3P6V3D549KPH4SC5BTJ74 file:///tmp/demo/remote.git work
$ cd work && git add REVIEW.md && git commit -qm "Add review"
$ git push origin HEAD
To file:///tmp/demo/remote.git
bc876f2..87d3743 HEAD -> gitswarm/ws/01M4B3P6V3D549KPH4SC5BTJ74
$ gitswarm ws publish 01M4B3P6V3D549KPH4SC5BTJ74
{"ok": true, "id": "01M4B3P6V3D549KPH4SC5BTJ74", "oid": "87d3743c1103ca653c4afe8db4d7a9e841649344", "remote": "file:///tmp/demo/remote.git"}Use branch_name (gitswarm/ws/<id>), not branch (refs/heads/…), with
git clone -b. Publishing before any commit was pushed is refused:
$ gitswarm ws publish 01M4B3PT92MMV3BST3Q0JDNBR6
{"ok": false, "error": {"kind": "InvalidState", "detail": "nothing published: branch is still at base; commit and push to the branch first"}}Whoever coordinates the run drops the workspace with ws drop.
ws gc reclaims open workspaces past ttl_s, from any host, when the
branch tip is still the base or the last tip this host pushed or saw, and
the local worktree (if any) has no unpushed commits or uncommitted changes.
Otherwise it reports the id under conflicted and leaves it alone. It never
touches published workspaces. Both need an explicit ws drop <id>. More: docs/recipes/other-host.md.
ws publish from a worktree pushes with a lease: it overwrites the remote
branch only if the branch is still where gitswarm last saw it. If someone
else pushed in between, nothing is overwritten and you get Conflict
(exit 3):
$ gitswarm ws publish 01M4B3PF534F5ZZJDWM0YWK6TM
{"ok": false, "error": {"kind": "Conflict", "detail": "refs/heads/gitswarm/ws/01M4B3PF534F5ZZJDWM0YWK6TM moved on remote; run `git pull --rebase origin gitswarm/ws/01M4B3PF534F5ZZJDWM0YWK6TM` in the worktree"}}Rebase onto their commits and publish again. gitswarm sees that your HEAD contains the remote tip, so nothing can be lost, and pushes:
$ git pull --rebase origin gitswarm/ws/01M4B3PF534F5ZZJDWM0YWK6TM
$ gitswarm ws publish 01M4B3PF534F5ZZJDWM0YWK6TM
{"ok": true, "id": "01M4B3PF534F5ZZJDWM0YWK6TM", "oid": "efb41f844494ac27c759c925cad13257e1c037f7", "remote": "file:///tmp/demo/remote.git"}
$ gitswarm ws read 01M4B3PF534F5ZZJDWM0YWK6TM NOTES.md
{"ok": true, "path": "NOTES.md", "content": "notes\n", "remote": "file:///tmp/demo/remote.git"}A plain git fetch in the worktree does not move the lease baseline
(refs/gitswarm/lease/…); only gitswarm's own pushes do. Publishing again
without the rebase returns the same Conflict.
Every command prints one JSON line (except --help). MCP tools return the
same object. remote is optional everywhere; MCP uses the server's cwd.
| CLI | MCP tool | MCP arguments | returns |
|---|---|---|---|
hive init <url> |
hive_init |
url |
url, path |
ws create [--base] [--agent] [--run] [--ttl] [--from-ws] [--checkout] [--label k=v] |
workspace_create |
remote, base_ref, agent_name, agent_run, ttl_s, from_ws, checkout, labels |
id, branch, branch_name, base_oid, path, token, clone, remote |
ws get <id> |
workspace_get |
ws_id, remote |
the record: id, state, base_ref, base_oid, branch, agent, parent, created_at, ttl_s, labels, token_id, published_oid, remote |
ws list [--state] |
workspace_list |
remote, state |
workspaces, invalid, remote |
ws read <id> <path> |
workspace_read_file |
ws_id, path, remote |
path, content (UTF-8) or content_b64, remote |
ws tree <id> [path] |
workspace_tree |
ws_id, path, remote |
path, entries [{name, kind, oid}], remote |
ws publish <id> |
workspace_publish |
ws_id, remote |
id, oid, remote |
ws drop <id> |
workspace_drop |
ws_id, remote |
the record, state: dropped |
ws gc |
workspace_gc |
remote |
expired, invalid, conflicted, remote |
events tail [--since <oid>] |
events_tail |
remote, since |
events [{kind, id, oid, at, payload}], invalid [{oid, detail}], remote |
doctor |
doctor |
remote |
ok, checks [{name, ok, detail}], remote |
stats |
stats |
remote |
by_kind, by_state, open_oldest_age_s, total_events, invalid, unrevoked_tokens, remote |
mcp |
— | — | runs the MCP server on stdio |
invalidlists records that cannot be read (events tail: meta commits that cannot be read as events,[{oid, detail}];stats: a count of both);conflictedlists expired workspaces whose branch tip is neither the base nor the last tip this host pushed or saw, or whose worktree holds unpushed or uncommitted work.gcskips both; reclaim them withws drop <id>.tokenis returned once bycreateand never stored (onlytoken_id). It isnullunless a token adapter is configured.- Event kinds:
ws.created,ws.published,ws.dropped,ws.expired,ws.revoked.
$ gitswarm ws gc
{"ok": true, "expired": ["01M4B3PT92MMV3BST3Q0JDNBR6"], "invalid": [], "conflicted": [], "remote": "file:///tmp/demo/remote.git"}
$ gitswarm events tail --since 1ab2a0e68ea5680f66af0bc5c6e5662b81f7c49b
{"ok": true, "events": [{"kind": "ws.expired", "id": "01M4B3PT92MMV3BST3Q0JDNBR6", "oid": "d75a472ed8e037e0d4d1daf677112a1cb4873c13", "at": "2026-10-07T21:00:23+09:00", "payload": {"agent": {"name": "idle"}, "base_oid": "bc876f294bbcf32c441f7e13bfef37700cc1c785", "base_ref": "refs/heads/main", "branch": "refs/heads/gitswarm/ws/01M4B3PT92MMV3BST3Q0JDNBR6", "created_at": "2026-10-07T11:59:14Z", "id": "01M4B3PT92MMV3BST3Q0JDNBR6", "labels": {"task": "demo"}, "parent": null, "published_oid": null, "state": "dropped", "token_id": null, "ttl_s": 60}}], "remote": "file:///tmp/demo/remote.git"}
$ gitswarm stats
{"ok": true, "by_kind": {"ws.expired": 1, "ws.dropped": 4, "ws.published": 6, "ws.created": 7}, "by_state": {"dropped": 5, "published": 2}, "open_oldest_age_s": null, "total_events": 18, "invalid": 0, "unrevoked_tokens": 0, "remote": "file:///tmp/demo/remote.git"}
$ gitswarm doctor
{"ok": true, "checks": [{"name": "git", "ok": true, "detail": "git 2.55.0"}, {"name": "home", "ok": true, "detail": "/tmp/demo/home"}, {"name": "config", "ok": true, "detail": "0 remote(s), 0 sink(s)"}, {"name": "remote", "ok": true, "detail": "reachable; default branch main"}, {"name": "hive", "ok": true, "detail": "/tmp/demo/home/hives/c63648f6fa869444: 1 worktree(s)"}, {"name": "tokens", "ok": true, "detail": "every recorded token revoked"}, {"name": "ssh_mux", "ok": true, "detail": "not an ssh remote"}], "remote": "file:///tmp/demo/remote.git"}doctor against an SSH remote also checks connection multiplexing:
$ gitswarm doctor --remote ssh://git@git.keiailab.com/keiailab-oss/gitswarm.git
{"ok": true, "checks": [{"name": "git", "ok": true, "detail": "git 2.55.0"}, {"name": "home", "ok": true, "detail": "/tmp/demo/homeR"}, {"name": "config", "ok": true, "detail": "0 remote(s), 0 sink(s)"}, {"name": "remote", "ok": true, "detail": "reachable; default branch stable"}, {"name": "hive", "ok": true, "detail": "absent; the first ws command creates it"}, {"name": "tokens", "ok": true, "detail": "no hive; nothing recorded"}, {"name": "ssh_mux", "ok": true, "detail": "connections are multiplexed"}], "remote": "ssh://git@git.keiailab.com/keiailab-oss/gitswarm.git"}Failures print {"ok": false, "error": {"kind", "detail"}}. detail ends
with the next step when there is one.
| kind | meaning | exit |
|---|---|---|
Usage |
bad arguments or no remote found (surfaces only) | 1 |
NotFound |
id, ref or path missing; malformed id; read on a directory, tree on a file |
2 |
Conflict |
someone else moved the branch or meta; branch gone from the remote; CAS retries exhausted | 3 |
InvalidState |
transition not allowed, malformed record or config.toml, refused URL |
4 |
Unsupported |
the adapter lacks the capability | 5 |
RemoteError |
git or the hosting API failed | 6 |
doctor prints {"ok": false, "checks": […]} and exits 1 when any check
fails. A refused --remote is still InvalidState (exit 4).
$ gitswarm doctor --remote file:///tmp/demo/missing.git
{"ok": false, "checks": [{"name": "git", "ok": true, "detail": "git 2.55.0"}, {"name": "home", "ok": true, "detail": "/tmp/demo/home"}, {"name": "config", "ok": true, "detail": "0 remote(s), 0 sink(s)"}, {"name": "remote", "ok": false, "detail": "file:///tmp/demo/missing.git unreachable: fatal: '/tmp/demo/missing.git' does not appear to be a git repository\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists."}, {"name": "hive", "ok": true, "detail": "absent; the first ws command creates it"}, {"name": "tokens", "ok": true, "detail": "no hive; nothing recorded"}, {"name": "ssh_mux", "ok": true, "detail": "not an ssh remote"}], "remote": "file:///tmp/demo/missing.git"}~/.gitswarm/config.toml (move the whole home with GITSWARM_HOME).
Without it every remote is plain git and no events are sent.
[remote."git.example.com"]
adapter = "forgejo" # issues a repo-scoped token per workspace
api = "https://git.example.com"
user = "gitswarm-bot"
credential_file = "~/.config/gitswarm/forgejo.cred"
[remote."github.com"]
adapter = "github" # GitHub App installation token
api = "https://api.github.com"
user = "<app_id>/<installation_id>"
credential_file = "~/.config/gitswarm/github-app.pem"
[[sink]]
kind = "jsonl" # or "webhook" with target = "https://…"
target = "~/.gitswarm/events.jsonl"The adapter is chosen by the remote's host; undeclared hosts are plain git.
Sinks receive each event once, with no retry; events tail --since replays
from the log.
Credential scope. The Forgejo credential_file is a secret for the
whole user account: Forgejo's token endpoint accepts only account
authentication. Use a dedicated bot account that exists only to mint
tokens, is a member of the target org, and can write only the target
repositories. The GitHub PEM can mint tokens for every repository the App
is installed on; install it on the needed repositories only. GitHub
installation tokens last one hour and cannot be revoked by id; drop
prints their expiry to stderr. Setup: forgejo-tokens.md,
github-app.md.
URLs: credentials (userinfo) in http(s) URLs are refused — use a
credential helper; ssh login names are fine. git:// is refused (no
authentication, no stall bound). https://user:token@host/…
and https://token@host/… fail with InvalidState (the error shows
https://***@host/…); ssh://git@host/… and git@host:… are accepted.
A workspace is not bound to the agent that created it. Anyone who can push
refs/heads/gitswarm/* can publish or drop any workspace and rewrite any
record; the boundary is the remote's push ACL. Every value read from the
meta branch is validated and fails closed. ws drop deletes unpublished
commits on that branch, including other people's (the hive's reflog keeps
them for 90 days); ws gc deletes no commit beyond the base that this host
has not pushed or seen, and never a worktree with unpushed or uncommitted
work. Details: SECURITY.md.
Workspace branches are ordinary branches. Make your CI ignore
gitswarm/**, or every create, publish and meta write starts a run:
on:
push:
branches-ignore:
- "gitswarm/**"Forgejo Actions and GitHub Actions use the same syntax. More: docs/recipes/ci.md.
Remote round trips per command: create 4 (5 when the base comes from the
remote HEAD), publish 4, read and tree 2, get, list and
events tail 1, gc 1 plus 3 per expired workspace, drop 4 (7 when the
branch moved since this host last saw it, including one ls-remote with a
60 s timeout). SSH connections are multiplexed
per hive, so a command opens no new handshake while the master lives (60 s).
Measured on 2026-10-07 against Forgejo over SSH (RTT 0.2 s), code at
commit efcf1da; every budget passed:
| command | ssh connections | new handshakes | seconds | budget |
|---|---|---|---|---|
| ws create --checkout | 4 | 0 | 2.13 | ≤ 4 conn, ≤ 2.5 s |
| ws get | 1 | 0 | 0.32 | ≤ 1 conn |
| ws list | 1 | 0 | 0.31 | ≤ 1 conn |
| ws read | 2 | 0 | 0.52 | ≤ 2 conn |
| ws tree | 2 | 0 | 0.53 | ≤ 2 conn |
| ws publish | 4 | 0 | 1.94 | ≤ 4 conn, ≤ 2.5 s |
| events tail | 1 | 0 | 0.46 | ≤ 1 conn |
| ws gc | 1 | 0 | 0.37 | ≤ 1 conn |
| ws drop | 4 | 0 | 3.17 | ≤ 4 conn |
Reproduce with uv run scripts/bench.py <remote-url> --base <branch>
(exit 1 if over budget). The connection budget is the real gate; the 2.5 s
budget is tight on a shared server, where a push sometimes takes seconds.
See CONTRIBUTING.md. The gate, as CI runs it:
uv sync --dev
uv run ruff check src scripts tests
uv run ruff format --check src scripts tests
uv run vulture src vulture_whitelist.py --min-confidence 60
uv run pytest -q --cov # coverage must be 100%Design: docs/ARCHITECTURE.md; the design specs and plans live in the repository. Changes: CHANGELOG.md.
A Workspace (this release) → B Intent (record why each commit exists: instruction, rationale, checks) → C Landing (merge concurrent results: serial rebase and machine arbitration; per-agent ownership).
MIT. Copyright KeiaiLab.