Internals — daemon, RPC, storage¶
Scope and non-goals¶
These boundaries are deliberate design decisions, not gaps. Each is enforced in code at the named place:
- Git-worktree-only VCS. Every git subprocess is funneled through
internal/gitcmd(which explains why no Go git library is used), parsing helpers live ininternal/gitx, and worktree creation goes throughwt.CreateInStore→git worktree add. Treeman requires git to be installed anyway (it manages git worktrees), so Jujutsu, Mercurial, and Sapling are non-goals. Multi-VCS support would mean abstractinggitcmd+gitx+wt/createbehind a VCS interface and is deliberately out of scope. - Single-user daemon. One human per host: the socket checks the peer's uid (SO_PEERCRED on Linux, owner stat elsewhere) and the SQLite store assumes one writer identity.
- One host. The daemon, its socket, the registry DB, and the databases it prepares are all local; there is no remote-daemon or fleet coordination layer.
- Unix-like OS. Unix sockets,
setsidhook detachment, and systemd-user/launchd units are the process model; Windows lacks all three natively.
If you find an issue thread asking "would treeman work with $OTHER_VCS / multi-user / remote daemons?", link here instead of re-deriving the answer.
Storage layout¶
| Path | What |
|---|---|
~/.local/share/treeman/treeman.db |
SQLite event log + worktree registry + snapshots table (override with $TREEMAN_DB_PATH) |
~/.local/share/treeman/treemand.log |
Daemon stderr |
$XDG_RUNTIME_DIR/treeman.sock |
JSON-line RPC socket — overridable via $TREEMAN_SOCKET, falls back to $XDG_DATA_HOME/treeman/treeman.sock (SO_PEERCRED on Linux, stat-based owner check elsewhere) |
~/.config/systemd/user/treemand.service |
systemd-user unit (Linux) |
~/Library/LaunchAgents/dev.stubbe.treemand.plist |
launchd LaunchAgent (macOS) |
<worktree>/.treeman-hooks/<phase>-<n>.log |
Per-hook driver stdout/stderr |
<repo>/schemas/treeman.schema.json |
JSON Schema (only present after treeman schema install) |
The store schema lives in internal/store/migrations/ (0001_init.sql
… and onward) and ships embedded into the binary, so a fresh
treeman.db self-migrates on first daemon start.
Daemon model¶
treemand is the long-running process; treeman is a thin RPC
client that round-trips JSON over the unix socket. Why a daemon:
- Watcher lifecycles survive shell exits.
watcher startfrom one shell keeps watching even after the shell closes. - Hook drivers are detached into their own session (
setsid), so they survive the CLI exit andworktree createreturns promptly regardless of how slow the hooks themselves are. - Snapshot cache is shared across shells; two terminals creating two worktrees on the same branch share the cached template DB.
The daemon's socket is 0600 and ownership-checked on every
accept; on Linux via SO_PEERCRED, on other platforms via
stat() of the socket file.
Init parity¶
| Platform | Unit file | Boot helper |
|---|---|---|
| Linux | ~/.config/systemd/user/treemand.service |
systemctl --user enable --now treemand |
| macOS | ~/Library/LaunchAgents/dev.stubbe.treemand.plist |
launchctl bootstrap gui/$UID … |
treeman daemon install writes whichever fits the host and runs
the boot helper. treeman daemon start / stop / status route
through the same init. The CLI falls back to spawning treemand
directly when no unit is installed, so transient use without
install also works.
RPC envelope¶
treeman talks to treemand over the unix socket with a line-JSON
protocol (protocol version 2): a request is {"method": <m>, "<m>":
{…args}} and every response carries a kind field. State mutations
(create/finalize/teardown/prepare/…) don't have their own methods —
they're submitted as a plan of tasks through the run_plan method
and the daemon executes them, returning plan_queued (async) or
plan_result (with wait).
The full method / response-kind / task / param surface is generated
from the constants in pkg/rpc/rpc.go:
rpc-reference.md.
The calling shell's environment rides along on the relevant tasks (via
their params) so hook subprocesses see the user's $PATH,
nvm/asdf/rbenv shims, etc.
Every daemon goroutine — accept loop, per-connection handler, watcher
loops, plan lanes, background reapers — runs through pkg/safego,
which recovers panics so one bad async task can't take down the daemon.
Adding an engine¶
The per-family switches live in ONE place: the driver registry in
internal/db/engineconn (registry.go + the explicit production list
in registry_drivers.go). Everything else — engineconn.Connect,
engineconn.Configured, prepare, teardown, GC, and stale-worktree
recovery — is registry-driven and picks a new engine up for free.
Checklist for a new engine family (≤4 touchpoints, #47):
- Driver package
internal/db/<engine>: dial + theConnprimitives (Drop/Exists/ListMatching/DropMatching/…). - Registry entry in
engineconn/registry_drivers.go: aConfiguredfield-check and aConnectdialer. - Config: a
ConnectionsConfigfield, adatabases[].enginealias pair inengine.Known+engine.Canonical, and avalidate.gorule if the family can't support some modes. - Extras (only what applies): a patcher driver if config files
need rewriting, an
initgenpreset if a framework pairs with it, docker-tier e2e undere2e/<engine>.
The jsonschema engine enum is GENERATED from engine.Known
(just sync-schema) — adding an alias cannot half-land. History:
the pre-registry switches drifted (the GC once rejected mongodb
while prepare accepted it), which is what this table exists to
prevent.
Development¶
just build # ./bin/treeman + ./bin/treemand with version baked in
just check # lint (golangci-lint fmt+vet+run) + test + sync-{schema,docs,flake}
just nix-check
just sync-flake [VERSION]
just release-{patch,minor,major} # tag + push, GH Actions builds + publishes
The sync-flake recipe rewrites nix/package.nix vendorHash and
version to match the current go.sum / tag. Called automatically
from the release recipes so the flake build never drifts.