dx worktree
dx worktree add <branch> [--create=<ref>] [--force] [--output=path]
dx worktree release [<path>] [--force]
dx worktree list [--disk]
dx worktree path <branch>
dx worktree prune [--apply] [--bases]
dx worktree hook create|remove|stop|guard
Keeps a small pool of warm checkouts, called slots, so a new worktree reuses
a slot that already has a Bazel server, output base and caches. Slots live at
<cache>/dx/worktrees/<repo>/<n>. <cache> is $XDG_CACHE_HOME, else
$HOME/.cache, else %LOCALAPPDATA%. Lease records live in
<cache>/dx/worktrees/<repo>/.dx/. <repo> is the basename of the main
checkout, or <repo>-2, <repo>-3 when another repository already uses that
name.
A bare dx worktree runs list.
Flags: --create=<ref> (add only), --force (add and release), --disk
(list only), --apply (prune only). A flag on the wrong subcommand exits 2.
Scopes: none. dx worktree takes a subcommand and its arguments.
Reports: none.
--output text|json|path. path works with add only. Text prints the lines
above. add prints dx worktree: <branch> is in slot <n> at <path>. When
add starts a new branch, it first prints
dx worktree: started <branch> from <base>, on stderr with --output=path.
<base> is the --create ref, else origin/HEAD, origin/main, or
origin/master.
release prints dx worktree: released slot <n> at <path>, or
dx worktree: removed <path> for an external worktree. Releasing a slot that
is already free prints dx worktree: slot <n> is already free and exits 0.
JSON reports it as a slot event with slot, action release, and state
free.
--output=json streams command_started, one slot event per slot, and
command_finished. list adds a summary event after the slots. prune
emits slot events with action prune, action prune_base for output
bases, and action prune_downloads for repository cache downloads.
When git fetch origin fails, add still uses local refs. Text prints
dx worktree: cannot fetch origin, using local refs: <error> before the slot
line. JSON emits a notice event with level warning and code fetch_failed
after command_started. hook create prints the same line on stderr.
When the caller already holds a slot on <branch>, add returns that slot
without fetching origin.
When the pool's disk or the disk of a Bazel output root has under 20 GiB and
under 10% free, or under 1 GiB free whatever its size, add and hook create
(after they fetch and pick a slot) and release drop orphaned output bases,
then repository cache downloads unused for 30 days, then free slots' output
bases, each oldest first, and print one of these lines before
the slot line. add and hook create keep the output base of the slot they
lease, so it stays warm for the build that follows.
dx worktree: low disk: <free> free; removed <what>; <free> free nowdx worktree: low disk: <free> free; nothing to reclaimdx worktree: low disk, cannot reclaim output bases: <error>
The first two end with ; run dx worktree prune --apply while disk stays low,
so a pool with nothing to reclaim prints
low disk: <free> free; nothing to reclaim; run dx worktree prune --apply.
All three use the disk_low notice code. JSON emits a notice event with
level warning and code disk_low before the slot event.
Reclaim stops the idle Bazel server of a free slot before it deletes that
slot's output base; a leased slot, one with a build running, and one whose
server will not stop are never touched.
When the pool's disk still has under 1 GiB free after that reclaim and the
slot they picked is a free slot, add and hook create stop that slot's idle
Bazel server and drop its output bases as a last resort. The disk_low line
then ends with
disk full: <free> free; removed the output bases of slot <n>, which this lease reuses; <free> free now.
When the disk is still that full, they refuse before they check anything out, with code disk_full,
exit 1, and the message
disk full: <free> free at <path>; cannot check out <branch>; free disk space with `dx worktree prune --apply --bases` .
A refusal emits no slot event. A branch you already hold still resolves to
its slot.
slot fields: action (add, release, list, path, prune,
prune_base, prune_downloads), path, state, server, and optional
slot, branch, holder, reason, base, disk_bytes, and entries.
base is set on add when it starts a new branch. disk_bytes is set for
prune, prune_base, and prune_downloads events other than kept and
skipped, and for list with --disk. entries is set for
prune_downloads: the number of downloads, and path is the repository
cache's content_addressable directory.
state values:
held:add,path, andlistfor a leased slot.free:releaseandpruneof a slot, andlistfor an unleased slot.reclaimable:listfor a held slot thataddmay take over.external:listandprunefor a worktree outside the pool.removed:releaseof an external worktree.free: alsoprune_basefor the output base of a free slot, with itsslot.kept:prune --applyfor a worktree it skipped, andprune_basefor an orphaned output base it keeps, with itsreason.orphaned:prune_basefor an output base whose workspace is gone.skipped:prunefor a slot, andprune_basefor an output base (with itsslotwhen it belongs to a free slot), thatprune --applycould not delete, with areason(server,held, orbusy).pruneevents in this state have nodisk_bytes.stale:prune_downloadsfor repository cache downloads unused for 30 days.
summary fields: slots, servers, servers_rss_bytes, plus disk_bytes and
disk_cache_bytes with --disk.
Exit codes: 0 success, 2 usage errors (unknown subcommand or hook name,
wrong arguments, an invalid branch name (invalid_branch), a path that is not
a slot (not_a_slot, including a bare release from the main checkout),
a flag on the wrong subcommand, an unknown --create ref,
--create on an existing branch, release of a slot another holder holds
without --force, any other dx command in a slot leased to another
session or in a slot that add is switching, and running outside a git repository, in a bare repository, or where
the main checkout cannot be found), 1 operational failures (a branch already
checked out elsewhere or held by another holder without --force, a dirty or
unpushed slot or external worktree on release without --force, slot_busy
when a dx command runs in the slot that add --force or release wants, even
with --force, a busy pool lock, no_home when no cache directory can be found,
lease_changed when another holder took the slot during the command, path finds
no checkout, prune finds work without --apply, no default branch to start
a new branch from, and any hook create, hook remove, or hook stop
failure), and 128 plus the signal number when Ctrl-C, SIGTERM, or SIGHUP
stops list --disk or prune (for example 143 after SIGTERM), with
stderr dx: cancelled: stopped by signal N.
Subcommands
add <branch> leases a slot, checks out <branch>, and prints where it is.
It picks the slot that last held the branch, then a slot with the same
MODULE.bazel.lock, then the most recently used, whatever the free disk.
When disk is low, under 20 GiB and under 10% free or under 1 GiB free, it then
deletes orphaned output bases, then repository cache downloads unused for 30
days, then the output bases of the other free slots,
least recently used first, until each output base's disk is no longer low. It
keeps the output base of the slot it picked. It reuses a free slot before it creates one. Files
matched by .worktreeinclude in the main checkout are copied in. --create=<ref>
starts a new branch from <ref>. Without it, add uses the local <branch>,
then origin/<branch>, and otherwise starts <branch> from the default branch
(origin/HEAD, else origin/main, else origin/master). --output=path prints
only the path on stdout and writes the dx worktree: ... warning lines to
stderr. Submodule checkouts and --separate-git-dir clones work too.
add refuses when the branch is already checked out in a slot held by another
holder or in a worktree outside the pool, and when --create names a branch
that exists. add returns your own slot for
<branch> as is. add --force hands a slot that holds <branch> for another
holder to you as is, without resetting it.
add takes over a held slot, including one that holds <branch>, only when
its holder is gone, no dx command runs in it, the tree is clean, and no
commit is unpushed. It does not wait. Otherwise it refuses and says which
condition fails. add never hands out, resets, or deletes a slot while a dx
command runs in it, free or held.
add also deletes free slots idle for 30 days.
release [<path>] frees the slot at <path>, or the slot you are in. A
relative <path> is read from the directory you ran dx in, including under
bazel run. It
stops the slot's Bazel server and detaches the checkout. The branch and its
commits stay in the repository. It refuses a dirty tree or unpushed commits
unless you pass --force; uncommitted changes are then wiped by the next add
of that slot. Only the holder can release a held slot, unless it is
reclaimable or you pass --force. Another agent in the same session is
another holder. While a dx command runs in the slot, release exits 1 with
slot_busy, even with --force. When disk is low, release deletes orphaned
output bases, then the output bases of free slots, least recently used first,
until each output base's disk is no longer low.
release <path> on an external worktree of this repository, such as an old
.claude/worktrees/<task> checkout, stops its Bazel server, removes the
checkout and its output base, and keeps the branch. It refuses a dirty tree or
unpushed commits unless you pass --force, which removes it anyway. The main
checkout is never removed.
Each refusal of a subcommand ends with the next step, such as pass --create=<ref>,
run the command again, pass a path from dx worktree list, or
run dx worktree add <branch>.
list prints one line per slot and a totals line:
1 feature-x session 7f3a agent a91c pid 4121 held server - /home/me/.cache/dx/worktrees/app/1
2 (detached) - free no server - /home/me/.cache/dx/worktrees/app/2
3 review-y pid 4180 (gone) held no server - /home/me/.cache/dx/worktrees/app/3
1 bazel server(s) running, 2.1 GiB resident
A holder that has exited shows (gone) after its name.
The columns are slot, branch, holder, state, server, disk use, and path. The
disk column shows - unless you pass --disk, which measures each slot and
its output base and adds the total to the last line, followed by the size of
the shared disk cache (0 B when it does not exist). It is slow on a large
pool.
$ dx worktree list --disk
1 feature-x session 7f3a agent a91c pid 4121 held server 1.2 GiB /home/me/.cache/dx/worktrees/app/1
2 (detached) - free no server 900.0 MiB /home/me/.cache/dx/worktrees/app/2
1 bazel server(s) running, 2.1 GiB resident, 2.1 GiB across slots, 3.4 GiB disk cache
state is held, free, reclaimable (holder gone, and the reclaim rule
above would allow taking it), or external (a worktree outside the pool). A
held slot whose holder is gone but which has uncommitted changes or unpushed
commits shows why after held, such as held (holder gone, commits are not pushed).
path <branch> prints the path of the checkout that holds <branch>, and
fails with exit 1 when none does.
prune lists free slots idle for 30 days, external worktrees that are clean,
fully pushed, not locked, without a running Bazel server, and on a branch that
tracks origin/<branch> and is merged into the default branch, or whose
origin/<branch> is deleted, and Bazel output bases of removed worktrees,
including checkouts removed from .claude/worktrees. It also lists the output
bases of the other free slots whose disk is low, least recently used first, as
output base of free slot <n>, <size>: <dir>. --bases lists the output bases of every free slot, whatever the disk state, least recently used first, except the slots already listed as idle. --bases works with prune only. It also lists the downloads in
Bazel's repository cache that no build used for 30 days. Only the default
cache, cache/repos/v1/content_addressable under each Bazel output root, is
checked. A cache moved with --repository_cache is not cleaned, and the
contents directory next to it is not touched. A deleted
download is fetched again when a build needs it. --apply deletes the idle free slots and stops each slot's
Bazel server and deletes those output bases; the other slots stay. --apply removes the
external worktrees with their output bases and keeps the branches. It deletes nothing without --apply. It exits 1 when something
would be pruned and --apply is absent, and 0 when there is nothing to prune.
Without --apply, text prints one line per item, and a closing line
dx worktree: <size> to reclaim; run dx worktree prune --apply. The item
lines are slot <n> idle <days> days, <size>: <path>,
external worktree <branch>, <size>: <path>,
output base of free slot <n>, <size>: <dir>,
output base of removed <workspace>, <size>: <dir>, and
repository cache downloads unused for 30 days, <n> entries, <size>: <dir>,
one per cache. With --apply the closing
line is dx worktree: pruned <size>, and dx worktree: nothing to prune
replaces everything when nothing qualifies.
--apply finds and deletes everything under the pool lock. It checks each
external worktree again. One that
became locked, got a Bazel server, uncommitted changes, or unpushed commits is
kept and printed as kept external worktree <branch> (<reason>): <path>, with
reason locked, server, dirty, unpushed, or busy (the delete failed and
part of it is still on disk). JSON reports it with state
kept and that reason. It still exits 0.
An output base whose workspace is gone but that a Bazel server still uses is
kept and printed as kept output base of removed <workspace> (server): <dir>.
JSON reports it as prune_base with state kept and reason server.
A free slot's output base whose Bazel server will not stop, whose slot was
taken, or whose slot runs a dx command, is kept and printed as
skipped output base of free slot <n> (<reason>): <dir>, with reason server,
held, or busy. An orphaned output base that cannot be deleted is printed as
skipped output base of removed <workspace> (busy): <dir>; this also covers
the output base of a slot that prune deleted while the base stayed. A slot
that was taken after the plan or is in use is printed as
skipped slot <n> (<reason>): <path>. So is a slot whose checkout is still
partly on disk after the delete; dx worktree add no longer leases it, and the
next prune or add tries to delete it again. Skipped items are not counted in
pruned <size>, and the command still exits 0.
--apply checks each download again right before it deletes it. One that a
build used since the check is kept without a line and is not counted in
pruned <size>.
Shared disk cache
The main checkout and every slot share one Bazel disk cache at
<cache>/dx/disk-cache, so a branch built in one checkout is not rebuilt in
another. dx build, test, coverage, run, and deploy add
--disk_cache=<cache>/dx/disk-cache after the Bazel verb. They skip it when
the workspace .bazelrc, a file it imports, ~/.bazelrc, or the system rc
sets --disk_cache or --nodisk_cache. Under CI=true and inside Bazel
tests no disk cache is added. Turn it off for one run with -- --disk_cache=.
bazel run @rules_dx//:dx -- build //... -- --disk_cache=
Disk cache size
When dx adds the disk cache, it also bounds it, using the same values as the vendored preset:
--experimental_disk_cache_gc_max_size=20G
--experimental_disk_cache_gc_max_age=14d
Bazel then deletes the oldest entries once the cache is over 20G, and entries
older than 14 days. dx adds both flags or neither. It leaves them out when an
rc file it reads sets either flag, or when the run's arguments set either flag
or --disk_cache. Your own values always win.
Bazel cleans the cache only while a server is idle. The cleanup starts after
--experimental_disk_cache_gc_idle_delay, which is 5 minutes by default, and
only if the server's last command carried the bound. A server that shuts down
sooner, because of a short startup --max_idle_secs, never cleans. So when an
rc file sets startup --max_idle_secs below 900, dx also adds
--experimental_disk_cache_gc_idle_delay=<max_idle_secs / 3>s. It does this
even when you set your own cache and bound, and skips it when an rc file or
the run's arguments set the idle delay.
Change the bound for every run in ~/.bazelrc:
common --experimental_disk_cache_gc_max_size=40G
common --experimental_disk_cache_gc_max_age=30d
Or for one run:
bazel run @rules_dx//:dx -- build //... -- --experimental_disk_cache_gc_max_size=40G
Hooks
hook create, hook remove, hook stop, and hook guard read one JSON
object from stdin for Claude Code hooks.
Run as exactly dx worktree hook <kind>, hooks ignore dx.toml and DX_*
variables and always print text. create, remove, and stop exit 1 on
any failure, including empty or invalid stdin. When create, remove, or
stop gets input without its field, the message names the field and the hook
event that runs it.
createreadsnameandcwd. Withoutcwd, it uses the current directory. It leases a slot on branchworktree-<name>, where characters other than letters, digits,-,_, and.become-, somy taskgivesworktree-my-task. It prints only the path on stdout. Warnings such asdx worktree: cannot fetch origin, using local refs: <error>go to stderr and do not change the exit code. It reuses a local branch with that name. A new branch starts from the default branch. If onlyoriginhasworktree-<name>, it usesworktree-<name>-2(or the next free number). Subagent worktrees get noremovehook, socreatefirst frees the session's earlierworktree-*slots that are at least a minute old, clean, fully pushed, and not running adxcommand. A slot freed this way goes to a latercreate, never the one that freed it. A slot that another agent leased in the meantime is left alone. The slot is held byworktree-<name>until the firstdx worktree addorreleasethat the same session runs inside the slot. That agent then becomes the holder.removereadsworktree_pathand releases that slot. Paths outside the pool are ignored.stopreadsagent_idfor theSubagentStopevent. It marks the slots that agent holds as held by a gone holder. Slots no agent has claimed are not marked; a latercreatefrees them.guardreadstool_input.command. It exits2with a message on stderr when the command runsgit worktree add,remove, orprune. Whenagent_idis set and the command runsdx worktree, it prints anupdatedInputthat exportsDX_WORKTREE_HOLDER=<agent_id>before the command. When stdin is empty or not valid JSON, it prints a note on stderr and exits0. It exits0otherwise.
Commit .claude/settings.json. The hooks run the repo launcher, so dx does
not need to be on PATH:
{
"hooks": {
"WorktreeCreate": [{"hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/tools/dx worktree hook create"}]}],
"WorktreeRemove": [{"hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/tools/dx worktree hook remove"}]}],
"SubagentStop": [{"hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/tools/dx worktree hook stop"}]}],
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/tools/dx worktree hook guard"}]}]
}
}
dx init --apply writes this file when it is absent and never overwrites it. If
.claude/settings.json exists, it prints the missing entries. It also writes
the launcher and the agent commands block in AGENTS.md; see
dx init. dx status reports the agents check as warn when a
hook is missing.
Leases
The holder is the agent session (CLAUDE_CODE_SESSION_ID or
CODEX_THREAD_ID), the agent (DX_WORKTREE_HOLDER, set by hook guard), and
a process: CLAUDE_PID when it is an ancestor of dx, else the nearest
ancestor that is not a shell or a wrapper such as timeout, env, nice,
nohup, setsid, stdbuf, time, or xargs. Two agents in one session are
different holders, and list shows each one. A slot from hook create goes
to the first agent of the same session that runs dx worktree add or
release inside it. A holder is gone when its process
exits or hook stop marks it. Any other dx command run inside a slot leased
to a different session exits 2 and names the holder. dx status
inside a slot shows the lease. Every other dx command marks the slot busy
while it runs, so no add, release, or hook create resets or frees it.
While add switches a slot, such a command waits, and exits 2 with slot N is being switched by dx worktree; run the command again if the switch takes
longer than two minutes. worktree, version, and completion always run.
Examples
bazel run @rules_dx//:dx -- worktree add feature-x
bazel run @rules_dx//:dx -- worktree add feature-x --create=origin/main
bazel run @rules_dx//:dx -- worktree add feature-x --output=path
bazel run @rules_dx//:dx -- worktree release
bazel run @rules_dx//:dx -- worktree list --output=json
bazel run @rules_dx//:dx -- worktree prune
bazel run @rules_dx//:dx -- worktree prune --apply