A passive, global port ledger for git worktree-based development.
portool keeps a cooperating ledger that hands every git worktree a stable
block of TCP ports and prevents portool-managed blocks from overlapping —
across all the repositories that share one ledger — so you rarely have to
think about port numbers. (The ledger is per OS-user + XDG-state scope.
Real port availability is checked only when you opt in — portool exec --check-ports (or --strict / --reallocate-on-conflict); the default
exec doesn't bind-check at all — see How it works.)
If you run more than one project (or more than one worktree of the same
project) at a time, you've hit this: two dev servers both want 3000, and
whichever started first wins. portool fixes that with two rules it never
breaks:
- Cooperating ledger. One JSON file (
~/.local/state/portool/registry.json) tracks every port block handed out to every project and worktree that shares it. No two portool-managed blocks overlap. The ledger's scope is one OS-user +XDG_STATE_HOME— a different user,sudo, or a container with a differentHOME/XDG_STATE_HOMEkeeps a separate ledger, and portool does not coordinate a machine-wide port space across those. - Passive.
portoolnever wrapsgit worktree add, never daemonizes, never manages your processes. It installs apost-checkout(andpost-merge) git hook once (portool init); after that, plaingit worktree add,git checkout, or an agent creating worktrees on your behalf all just work — the right ports show up on their own. The hook keeps.env.portoolfresh without re-checking that the ports are actually free right now, andportool execdoesn't check by default either — a worktree's own already-running dev server legitimately occupies its block, so a default-on check would be noise on every secondexec. Pass--check-ports,--strict, or--reallocate-on-conflicttoexecwhen you actually want the block's availability verified.
The ledger itself stays passive: portool never daemonizes, proxies
requests, or supervises processes. The one place it touches your command
line is portool exec — a thin execution boundary that
composes the allocated ports into an environment and then replaces itself
with your command via exec(2). Even there it templates nothing on its
own: the only env files it expands are the ones you explicitly pass.
cargo install portoolRequires macOS or Linux (see Platform support).
cd your-repo
portool init # installs the post-checkout hook, updates git's info/exclude, runs sync
cat .env.portool # your ports are already hereNo .portool.toml yet? sync and exec both still work — you get a
single PORT variable pointing at a free block:
portool exec -- npm run dev # PORT=<block start> injected, no manifest neededSee .portool.toml to declare more than one port.
From here on, every worktree gets its own block automatically:
git worktree add ../your-repo-feature-x -b feature-x
cd ../your-repo-feature-x
cat .env.portool # a different block, allocated by the hook during checkoutCommit this to your repo root to declare the ports your project needs:
[ports]
web = 0 # offset within the block
api = 1
hmr = 2
db = 3- Keys must match
[a-z][a-z0-9_]*. Each becomes an env var named<KEY>_PORT(web→WEB_PORT). - Values are offsets within the worktree's block, not absolute port
numbers. Offsets may be sparse (e.g.
0, 1, 3) but must be unique. - Block size =
max(highest offset + 1, number of declared ports), rounded up to the nearest multiple ofblock_align(default5). Four ports with offsets0..3→ raw size4→ rounds up to5. - No manifest at all → a single
block_align-wide block, exposed asPORT=<block start>. This lets you adoptportoolbefore writing a manifest. - The manifest is fail-closed: an empty
[ports]table, an unknown top-level field, or aportool/portool_*key (reserved for portool's own identity variables) is a hard.portool.tomlerror rather than a silent single-block fallback or a colliding env var.
Every worktree gets a generated, gitignored .env.portool:
# generated by portool — DO NOT EDIT
# block: 3000-3004 generation: 1 sequence: 42 project: myapp worktree: /home/user/dev/myapp
PORTOOL_PROJECT_ID=abe4c983b7295f370a207b1614878153
PORTOOL_WORKTREE_ID=740da24128c5e2f7abf4fa15ab4253f3
WEB_PORT=3000
API_PORT=3001
HMR_PORT=3002
DB_PORT=3003Besides the port variables, the file always carries two identity
variables (see Worktree identity below).
Consumers must ignore unknown PORTOOL_* metadata variables and must
not assume every variable ends with _PORT.
portool only ever writes this file — reading it into your process is your
tool's job (or portool exec's, if you'd rather not wire
that up yourself):
direnv — add to .envrc:
dotenv_if_exists .env.portool(Don't call portool itself from .envrc; that would hit the ledger on
every cd. .env.portool is already kept fresh by the git hook.)
Docker Compose — namespace each worktree's containers, networks, and
volumes by its worktree ID, in compose.yaml:
name: myapp-${PORTOOL_WORKTREE_ID:-default}docker compose --env-file .env.portool up(The :-default fallback keeps the file working in environments where
portool isn't installed.)
Plain shell:
set -a; source .env.portool; set +aApp code (e.g. Vite), with a fallback for when the file hasn't been generated yet:
const port = process.env.WEB_PORT || 3000;Runs a command with the current worktree's assignments injected as
environment variables — no source, no per-worktree launcher scripts:
portool exec [-e <PATH>]... [--check-ports] [--strict] [--reallocate-on-conflict]
[--env-file-overrides] -- <COMMAND> [ARGS...]In order, exec locates the current worktree, runs the equivalent of
portool sync, loads each env file passed with -e/--env-file,
composes everything into one environment, expands ${NAME} /
${NAME:-default} references, and then replaces itself with <COMMAND>
via exec(2) — no shell in between, so stdin/stdout/stderr, signals, and
the exit code all pass through untouched. The -- is required, and
omitting the command is a usage error. If sync or environment
construction fails, the command is never started.
Precedence, lowest to highest:
earlier env files < later env files < parent environment < portool-managed variables
So a stale TEST_DB_PORT left in a file or in your shell can never shadow
the current worktree's allocation, while variables portool doesn't
manage (DATABASE_URL, JWT_SECRET, …) keep their parent-environment
values.
Expansion rules:
- Only
${NAME}and${NAME:-default}are recognized.$NAMEis not, and command substitution ($(), backticks) is never executed. - Unquoted and double-quoted values are expanded; single-quoted values are left verbatim.
- An undefined
${NAME}is an error — use${NAME:-default}when a fallback makes sense.
exec must run inside a git worktree — no .portool.toml is required (see
Quick start); without one you get a single PORT
variable, same as sync. Env-file paths are resolved relative to the
current working directory, and a missing file is an error — nothing
(.env, .env.test, …) is ever read implicitly.
Execution-boundary check (opt-in). exec can bind-check the allocated
block on 127.0.0.1 right after syncing — the one moment it's worth
confirming the ports are actually free — but doesn't by default: a
worktree's own already-running dev server legitimately occupies its block,
so a default-on check was noise on every second exec. Pass
--check-ports to get a neutral stderr advisory when the block is in use
(the command still runs), --strict to fail (exit 1) on a conflict
instead, or --reallocate-on-conflict to move to a fresh block first
(equivalent to running portool reallocate). --strict and
--reallocate-on-conflict each imply --check-ports.
The payoff of these rules is that one committed env file serves both
worlds. Write a .env.test like:
DATABASE_URL=postgresql://postgres:password@localhost:${TEST_DB_PORT:-5432}/testdband run your integration tests through portool:
portool exec --env-file .env.test -- npm run test:intEach worktree's tests hit that worktree's own database port. On CI, where
portool isn't installed, the exact same file works as a plain dotenv
with the 5432 default.
exec returns the child command's exit code as-is; 127 if the command
wasn't found, 126 if it isn't executable. If you need shell features,
ask for them explicitly: portool exec -- sh -c '...'.
portool works best when the repositories that use it don't depend on it. Treat it as an overlay applied from the outside, not a dependency built in:
- Scripts never call or detect portool.
package.json,Makefile, and compose files stay portool-free; the caller wraps a command once —portool exec -e .env.test -- npm test— where portool is available, and runsnpm testdirectly where it isn't. - Every port reference carries a fallback.
${WEB_PORT:-3000}in compose and env files,process.env.WEB_PORT || 3000in code. Machines without portool (CI, a teammate's fresh laptop) get the defaults; worktrees with portool get their own block. - The unwrapped commands are the contract. If
npm teststops working without portool, the integration is wrong — fix the fallback rather than adding a portool dependency.
The rest is already handled for you: the post-checkout hook no-ops when
portool isn't installed, and .env.portool is gitignored, so a clone
without portool never sees a trace of it beyond the inert
.portool.toml.
See examples/webapp for a complete project laid out
this way — the same files and commands, runnable with and without
portool.
PORTOOL_* is portool's reserved env-var prefix. Two identity
variables are always present in .env.portool, whether or not the repo
has a .portool.toml:
PORTOOL_PROJECT_ID— the same for every worktree that shares one git common-dir (i.e. one repository clone). A separate clone gets a different ID.PORTOOL_WORKTREE_ID— different for every worktree, and practically collision-resistant across the whole machine. Use it wherever worktrees must not collide: Docker Compose project names, container/network/volume names, per-worktree caches, and so on.portoolitself never manages Docker or processes — it only provides the identifier.
Both are the first 32 lowercase hex characters (128 bits) of a SHA-256 over the worktree's canonical git paths. They are derived purely from git identity — never stored in the ledger — so deleting the registry regenerates the exact same IDs. They are not secrets (the input paths are guessable), so don't use them as tokens.
What the IDs survive:
| Operation | IDs |
|---|---|
| Branch checkout, rename, detached HEAD | unchanged |
.portool.toml changes, port re-allocation |
unchanged |
| Registry deletion / regeneration | unchanged |
| Re-creating a worktree at the same path | unchanged |
| Moving the repository or worktree | changed |
| Cloning to a different path | changed |
portool init [--hook-only | --gitignore-only]
portool sync [--quiet]
portool exec [-e <PATH>]... [--check-ports] [--strict] [--reallocate-on-conflict]
[--env-file-overrides] -- <COMMAND> [ARGS...]
portool reallocate [--quiet]
portool ls [--json] [--all]
portool prune [--all] [--dry-run]
portool check
portool doctor [--repair] [--abandon-other-projects]
portool release
portool unhook
portool deinit [--keep-allocations]
portool reserve <PORT|START-END> [--label <LABEL>]
portool unreserve <PORT|START-END>
portool pin [--label <LABEL>]
portool unpin
check— validate the config and ledger; exits non-zero on any problem (read-only, script-friendly). It also exits non-zero on a degraded backup — one whose recordedsequenceis behind the ledger, or that is unreadable/corrupt — because adoctor --repairfrom it would restore stale state; a merely missing backup (a fresh ledger) is only a stderr warning, since it heals on the next save.doctor— diagnose and repair the current project: re-import ledger entries from live worktrees'.env.portool(skipping any worktree whose path isn't valid UTF-8, and verifying each file'sPORTOOL_PROJECT_ID/PORTOOL_WORKTREE_IDmatch this project and worktree before importing it), report blocks whose ports are currently in use, and check hook effectiveness — including an advisory when a hook's managed block sits behind a top-levelexit/exec(unreachable) or is malformed.--repairis the only way portool ever touches a corrupt ledger: it restoresregistry.jsonfromregistry.json.bak(every project kept) and copies the corrupt file aside to a uniquely namedregistry.json.corrupt-<nanos>-<pid>; without a usable backup it refuses and points at--repair --abandon-other-projects, the one destructive path, which discards the ledger and rebuilds only the current project. Every other command fails closed on a corrupt or unsupported-version ledger and leaves the file untouched.release— free the current worktree's block from the ledger and remove its.env.portool.unhook— remove portool'spost-checkout/post-mergehook content and nothing else. It verifies each hook is actually neutralized afterward; if one still invokes portool (a symlink, an unreadable file, or a malformed managed block), it prints a machine-readablepartial_unhookJSON object listing the residue and exits non-zero instead of reporting success.deinit— full reverse ofinit, run as an ordered transaction: remove the hooks, verify they no longer invoke portool, remove.env.portoolfrom every worktree git currently reports and every worktree path still recorded in the ledger (so it works even when the ledger is missing or already empty), release this project's ledger allocations, and remove theinfo/excludeentry. If any step can't complete — a hook that still invokes portool, an env file that won't delete — it prints apartial_deinitJSON object listing the residue and exits non-zero; when the hooks aren't neutralized it deliberately keeps the allocations (so a later checkout can't re-hook and hand out a block a live env still points at). Pass--keep-allocationsto only remove the hooks and the ignore rule (the pre-0.7deinitbehavior).reserve/unreserve— permanently reserve (or release) a port or port range so portool never allocates over it, even when nothing is currently listening there.pin/unpin— exempt (or stop exempting) the current worktree's allocation from garbage collection.
Installs the post-checkout / post-merge hooks (§ below), appends
.env.portool to $GIT_COMMON_DIR/info/exclude (never the tracked
.gitignore — see below), then runs sync once. All three steps are
idempotent — running init again never duplicates the hook content or the
info/exclude entry.
| Flag | Effect |
|---|---|
--hook-only |
Only install the hooks. |
--gitignore-only |
Only append .env.portool to info/exclude. |
(--hook-only and --gitignore-only are mutually exclusive; --gitignore-only
keeps its pre-1.0 name even though it now touches info/exclude.)
init exits non-zero whenever the hook doesn't end up actually
installed and runnable, and prints the exact line to add to your hook
manager instead of silently succeeding with nothing installed. What else
runs before the non-zero exit depends on which case triggered it:
- A symlinked hook file, a hook with a non-shell interpreter, or a hook
that couldn't be read or has a malformed managed block: the hook-install
step itself doesn't error, so the
info/excludeupdate and the initialsyncstill run first; only the hook-install step is then reported as a failure. - A
core.hooksPathpointing at a directory that doesn't exist, or acore.hooksPaththat resolves outside the repository in any config scope (seecore.hooksPathand Husky below): hook installation itself fails outright, soinitexits immediately and theinfo/excludeupdate and initialsyncdo not run.
init writes the ignore rule to $GIT_COMMON_DIR/info/exclude — shared by
every worktree of the repo, never committed, and independent of the tracked
.gitignore. init never edits .gitignore; a .env.portool line left
there by an older portool (or added by hand) is harmless but no longer
necessary, and deinit only ever hints at removing it, never edits it.
Allocates a block for the current worktree if it doesn't have one yet,
refreshes .env.portool if the manifest changed, and reclaims this
project's own stale worktree entries before allocating — so a worktree
re-created on the same branch can reclaim the block it just vacated instead
of being pushed onto a different one. This is what the git hook calls after
every checkout; when nothing has changed it's a lock-free read that writes
nothing.
If a changed manifest no longer fits the current block, sync first tries
to grow the block in place — keeping its start, extending only the
tail, and bind-checking only the newly added ports (the existing ones are
expected to be in use by this worktree's own processes). Only when that
extension doesn't fit (blocked by a neighboring block/reservation, or the
end of the pool) does sync fall back to moving the worktree to a
different block via the general allocator — and if the current block's
ports are still in use at that point, moving would split the worktree
across old and new ports, so sync refuses with an error instead: stop the
processes and re-run sync, or run portool reallocate to move it
explicitly and accept the split.
| Flag | Effect |
|---|---|
--quiet |
Suppress the normal-case summary line on stdout (used by the hook). Warnings and errors always go to stderr regardless. |
Runs <COMMAND> with the composed environment — covered in detail in
portool exec above.
| Flag | Effect |
|---|---|
-e, --env-file <PATH> |
An env file to load before composing. Repeatable; later files override earlier ones. |
--check-ports |
Warn on stderr (advisory, not a failure) if the allocated block's ports are already in use. Off by default. |
--strict |
Fail (exit 1) instead of warning if the allocated block's ports are already in use. Implies --check-ports. |
--reallocate-on-conflict |
Move the worktree to a fresh block (like portool reallocate) if the allocated block's ports are in use, then run. Implies --check-ports. |
--env-file-overrides |
Let the passed env files override the parent environment instead of losing to it (default precedence: parent beats files). |
--strict and --reallocate-on-conflict are mutually exclusive — they
disagree about what to do on a conflict, so passing both is a CLI usage
error (exit 64) rather than one silently winning.
exec doesn't bind-check the block on 127.0.0.1 by default — a
worktree's own already-running dev server legitimately occupies it, so a
default-on check would fire on every second exec. Pass --check-ports
(or --strict/--reallocate-on-conflict, which imply it) to opt in.
Forces the current worktree onto a fresh free-and-bindable block, excluding
its current one, and rewrites .env.portool. Use it to move off a block
whose ports something else now holds. Requires the worktree to already have
an allocation (run sync first).
| Flag | Effect |
|---|---|
--quiet |
Suppress the normal-case summary line on stdout. |
Lists allocated blocks.
PROJECT WORKTREE BRANCH BLOCK STATUS
myapp ~/dev/myapp main 3000-3004 active
myapp ~/dev/myapp-wt/feat-api feat/api-v2 3005-3009 active
blog ~/dev/blog main 3500-3504 stale?
reserved 5432-5432 postgres
STATUS is active (worktree directory present), stale? (worktree gone —
a prune candidate), or pinned. Any reservations print below the table
(see portool reserve).
| Flag | Effect |
|---|---|
--json |
Emit a machine-readable envelope instead of a table (for scripts and agents) — see below. |
--all |
Show every project instead of just the one for the current directory. |
Outside a git repository, --all is required; plain ls exits 1.
A stable, versioned envelope, independent of the ledger's on-disk storage schema:
{
"format_version": 1,
"ok": true,
"registry_schema_version": 4,
"effective_config": { "range": [3000, 9999], "block_align": 5 },
"allocations": [
{
"project": "myapp",
"project_key": "/home/user/dev/myapp/.git",
"project_id": "abe4c983b7295f370a207b1614878153",
"worktree_id": "740da24128c5e2f7abf4fa15ab4253f3",
"path": "/home/user/dev/myapp",
"branch": "main",
"block": [3000, 3004],
"pending_block": null,
"env_block": [3000, 3004],
"state": "ok",
"sync_required": false,
"generation": 1,
"pinned": false,
"label": null,
"status": "active",
"ports": { "WEB_PORT": 3000, "API_PORT": 3001 },
"allocated_at": "2026-07-17T09:00:00+00:00",
"last_seen_at": "2026-07-17T09:00:00+00:00"
}
],
"reservations": [
{ "block": [5432, 5432], "label": "postgres", "pinned": true }
]
}On failure (a corrupt or unreadable ledger, or an unsupported schema
version) ls --json prints an error envelope and exits non-zero instead:
{ "format_version": 1, "ok": false, "error": "registry is corrupt: ..." }Compatibility promise: format_version changes only on a breaking
shape change to this envelope (renamed/removed keys, changed types, a
newly required field); adding an optional field doesn't bump it. A
ledger storage-schema migration (registry_schema_version) never changes
format_version. ports is null when it can't be derived (the worktree
directory is gone, or its .portool.toml is unreadable or invalid);
otherwise it's the same env-var-name → port map portool exec would set.
Each allocation also reports its sync state so a machine consumer is never
shown a clean state while work is pending: pending_block is a target block
reserved mid-move (an interrupted two-phase update) or null; env_block
is the block the worktree's .env.portool actually hands out (or null
when the dir/env is gone); state is one of ok, pending_move,
recovery_required (env disagrees with the ledger), env_missing, or
stale; and sync_required is true whenever state != "ok".
Explicitly reclaims stale worktree entries (the same check sync runs
implicitly, on demand). A whole project's entry is reclaimed only once its
entire repository clone (common_dir) is gone.
| Flag | Effect |
|---|---|
--all |
Operate across every project instead of just the current one. |
--dry-run |
Print what would be reclaimed without touching the ledger. |
Permanently reserves a port or port range so portool never hands it out — useful for a service (Postgres, Redis, …) that's sometimes stopped, when a plain bind check alone would read its port as "free":
portool reserve 5432 --label postgres
portool ls --json | jq '.reservations'
portool unreserve 5432portool reserve <PORT|START-END> [--label <LABEL>]
portool unreserve <PORT|START-END>
reserve takes a single port (5432) or an inclusive range
(6000-6009); it errors if the reservation would overlap an existing
allocation or reservation. unreserve matches a single port against
whichever reservation currently contains it; a range must match an
existing reservation exactly. Reservations are global, not per-project,
and show up in portool ls's table footer and --json output.
Exempts the current worktree's allocation from every GC path (implicit
sync GC, prune) until unpinned:
portool pin --label "long-lived staging"
portool unpin| Flag | Effect |
|---|---|
--label <LABEL> |
Optional label shown in ls. |
Requires the worktree to already have an allocation (run sync first). A
pinned worktree shows STATUS pinned in portool ls regardless of
whether its directory currently exists. portool unpin clears the label
along with the pin — a later label-less pin won't resurrect a stale name.
| Code | Meaning |
|---|---|
| 0 | Success (including a no-op sync). |
| 1 | General error — outside a git repository, a malformed .portool.toml or config.toml, an unreadable or corrupt ledger, an I/O failure, etc. |
| 3 | The pool has no room left for a block. |
| 4 | Timed out (10s) waiting for the registry lock. |
| 64 | CLI usage error (unknown subcommand or bad flags). Kept distinct from portool's semantic codes above. |
(Exit code 2 was retired with the per-project subrange model.)
portool exec is the exception: once the child command starts, its exit
code is passed through unchanged (with 127 for command not found and
126 for not executable).
Optional, at ${XDG_CONFIG_HOME:-~/.config}/portool/config.toml. Every
field is optional and falls back to its default:
range = [3000, 9999] # the full port pool
block_align = 5 # block-size rounding unit (and minimum block size)(subrange_size — removed in 0.5.0 — and gc_days — removed in 0.7.0 —
are no longer part of the effective config: blocks are allocated directly
from range, and GC is condition-based (a worktree's directory is gone
and its ports are free), not age-based. A config that still sets either is
accepted with a deprecation warning and otherwise ignored.)
Changing this file only affects new allocations — existing blocks are left in place.
The config is fail-closed: a malformed file (invalid TOML, an unknown
field such as a misspelled ragne, a reversed range, …) is a hard error
(exit 1), never a silent revert to defaults — so a typo can't quietly move
your pool back to 3000..=9999. Only an absent file means defaults. Note
too that XDG_STATE_HOME / XDG_CONFIG_HOME are honored only when set to an
absolute path (per the XDG Base Directory spec); a relative value is ignored.
Allocation is two levels:
- Pool — the full port range (
[3000, 9999]by default). - Block — each worktree gets one contiguous block, sized from its
.portool.toml(orblock_alignif it has none), carved directly from the pool. There is no per-project reservation, so the pool serves far more projects than the old fixed-slice model — which capped the default pool at ~14 repositories regardless of how few ports each actually used.
Block placement is deterministic: every branch, including main/master,
prefers slot FNV-1a-32(project_key + "\n" + branch) % slots (a detached
worktree hashes its worktree path instead of a branch name) — a stable
hash, so re-creating a worktree on the same branch tends to return to the
same block, and different projects' identically named branches spread
across the pool instead of piling onto one hotspot. If the preferred slot
is taken (or reserved — see
portool reserve — or something
outside the ledger is already listening on it), portool scans forward,
wrapping, for the next free and bindable slot. Whether a block's ports are
actually free is only checked when you opt in at
portool exec time (--check-ports / --strict /
--reallocate-on-conflict); run portool reallocate to move a worktree
off a block whose ports something else now holds.
Ledger writes always go through an exclusive flock on
registry.json.lock; reads for the common case (nothing changed) never
take the lock at all — and revalidate their snapshot against the ledger's
per-worktree generation counter, so a concurrent move is always noticed.
A manifest that outgrows its block is grown in place first — same
start, only the added tail ports bind-checked — before the general
allocator is ever consulted; see portool sync above.
Moving a worktree to a genuinely different block (in-place growth doesn't
fit, or portool reallocate) is a two-phase update: the target block
is first reserved in the ledger alongside the old one, then
.env.portool is rewritten, then the move is finalized. A crash at any
point leaves the block your env file points at reserved in the ledger — it
can never be handed to another worktree — and the next sync resolves the
interrupted move automatically (forward if the env was already rewritten,
backward otherwise). sync (unlike reallocate) refuses to start this
move at all if the current block's ports are still in use, since moving
them out from under a running process would split the worktree across old
and new ports.
The ledger is fail-closed, like the config: a corrupt, unreadable, or
newer-schema registry.json makes every command error out (exit 1) without
touching the file. Every successful save refreshes registry.json.bak, a
byte-exact copy of the last successfully saved ledger; if a backup write
fails, portool warns and continues (the ledger save itself still
succeeds), and portool check reports the backup as stale until the next
save heals it. The explicit recovery path is portool doctor --repair:
for a corrupt ledger it restores registry.json from that backup (every
project's allocations survive) and copies the corrupt file aside to a
uniquely named registry.json.corrupt-<nanos>-<pid>; without a usable
backup it refuses rather than guess, and points at --repair --abandon-other-projects — the one destructive path, and the only way an
unsupported-version ledger (written by a newer portool) is ever discarded.
Either way, doctor then rebuilds the current project's entries from its
worktrees' .env.portool files.
Every write portool makes — the ledger, .env.portool, and
registry.json.bak — goes through the same durable-write path: a temp
file in the target's own directory, fsynced, then renamed into place
(with a best-effort directory fsync afterward so the rename itself
survives a crash), so a crash mid-write can never leave a truncated or
half-written file. The backup is written this same way — temp file +
rename, not an in-place copy — so a crash mid-backup can't leave it
half-overwritten either. This is the standard POSIX fsync/rename
durability guarantee; portool does not additionally issue macOS's stronger
F_FULLFSYNC.
portool init installs this at <git-common-dir>/hooks/post-checkout (and
the same script at post-merge, so a .portool.toml arriving via git pull
is picked up too) — shared by every worktree of the repo, so one init
covers all of them, including ones created later:
#!/bin/sh
# installed by portool
PORTOOL_BIN="/usr/local/bin/portool"
if ! [ -x "$PORTOOL_BIN" ]; then PORTOOL_BIN=portool; fi
if command -v "$PORTOOL_BIN" >/dev/null 2>&1; then
"$PORTOOL_BIN" sync --quiet || echo 'portool: sync failed; Git was not blocked' >&2
fi
exit 0PORTOOL_BIN is the absolute path of the portool binary recorded at
init time, so a hook run by a GUI git client (whose PATH may not include
wherever you installed portool) can still find it; if that recorded path no
longer exists (the binary moved), the hook falls back to a plain portool
lookup on PATH. The hook always exits 0, so portool can never fail
your Git command: the command -v guard makes it a no-op when portool
isn't installed, and the || echo … >&2 plus trailing exit 0 mean that
even when portool is installed and sync fails (a full pool, a locked
ledger, a bad .portool.toml), the failure is reported to stderr but
git checkout / git worktree add still succeed — even under hook
managers that run hooks with sh -e.
Because git worktree add runs a checkout internally, this fires on
worktree creation too — that's the whole mechanism behind "ports just
appear."
If a post-checkout hook already exists, init inserts a single guarded
block right after the shebang (or at the very top if there is none) —
never appended at end-of-file, where a pre-existing top-level
exit/exec could leave it unreachable:
# >>> portool >>>
PORTOOL_BIN="/usr/local/bin/portool"
if ! [ -x "$PORTOOL_BIN" ]; then PORTOOL_BIN=portool; fi
if command -v "$PORTOOL_BIN" >/dev/null 2>&1; then "$PORTOOL_BIN" sync --quiet || true; fi
# <<< portool <<<Re-running init refreshes this block in place instead of duplicating it.
portool only writes into shell hooks: a hook with a non-shell shebang
(Python, Node, …) is left untouched with a printed manual line, a symlinked
hook is never modified, and a hook's existing permission bits are
preserved. A malformed block — the # >>> portool >>> / # <<< portool <<< markers missing one side, duplicated, or in the wrong order — is
never auto-repaired: earlier portool versions treated a truncated block as
extending to end-of-file and could delete a user's own code after it, so
init/unhook/deinit now all leave the hook completely untouched and
print a warning asking you to fix the markers by hand (portool doctor
flags this too, along with a block that's technically valid but sits
behind an unreachable top-level exit/exec). An older, unsafe hook
installed by portool ≤ 0.4 is migrated to the current form on the next
init.
When core.hooksPath is set, git ignores <git-common-dir>/hooks, so
portool init installs into the effective location instead:
-
Husky (
core.hooksPath=.husky/_): the hook goes into the user-managed.husky/post-checkout— Husky's own way of adding hooks — so Husky's bootstrap (HUSKY=0,~/.config/husky/init.sh, PATH setup,sh -e, exit-code propagation) stays fully in charge. The file is tracked; commit it to share the hook with your team.Because the hook is a tracked file (unlike the shared
<git-common-dir>/hooks), other worktrees pick it up once they check out a commit containing it; until then, runportool syncthere once.One Husky-inherited caveat: in a brand-new worktree,
.husky/_doesn't exist untilnpm install(thepreparescript) generates it, so no hook — portool's included — can fire on that first checkout, just like hooks don't run in a fresh clone beforenpm install. Runportool synconce in new worktrees, or addportool sync --quietto your package.jsonpreparescript. -
Any other
core.hooksPathwhose directory exists, set in a per-repo (local/worktree) scope or as a repo-relative path: portool installs or appends<hooksPath>/post-checkout(andpost-merge) there, idempotently. -
A
core.hooksPaththat resolves outside this repository — whatever scope set it (local,worktree,global,system, even a command-line-c) and however it escapes (an absolute path, a relative../, or a symlink into another directory):initrefuses to auto-install there, since its hook could then run on every repository that shares — or symlinks to — that directory, and prints the exact line to add to a per-repo hook instead. -
A
core.hooksPaththat isn't an existing directory (likely generated by a tool portool doesn't recognize):initrefuses to install into a location git would never read, and prints the exact line to add to your hook manager'spost-checkoutinstead.
If an agent creates worktrees for you (or otherwise sidesteps the git
hook), add a SessionStart hook so the ledger self-heals at the start of
every session — it's idempotent, so there's no cost to running it
redundantly:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "portool sync --quiet" }
]
}
]
}
}Put this in .claude/settings.json (project) or ~/.claude/settings.json
(user-wide). portool ls --json is also a convenient machine-readable
source of truth if an agent needs to know which ports are already in use.
portool targets macOS and Linux only. portool relies on flock and
realpath-style path canonicalization; Windows is not supported. It uses
git worktree list --porcelain -z (git ≥ 2.36) with a fallback to the
newline parser on older git, and git config --show-scope (git ≥ 2.26) to
detect shared hooks directories (on older git, an absolute core.hooksPath
is conservatively refused). Repository and worktree paths must be valid
UTF-8 — a non-UTF-8 path (possible on Linux only) is a hard error rather
than a lossy ledger key that could collide.
portool is a CLI, not a library: the crate ships a binary only (no lib
target), so there is no Rust API to depend on. The stable interface is the
documented commands, exit codes, and file formats.
MIT — see LICENSE.