Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

139 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

portool

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.)

What & why

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 different HOME/XDG_STATE_HOME keeps a separate ledger, and portool does not coordinate a machine-wide port space across those.
  • Passive. portool never wraps git worktree add, never daemonizes, never manages your processes. It installs a post-checkout (and post-merge) git hook once (portool init); after that, plain git 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.portool fresh without re-checking that the ports are actually free right now, and portool exec doesn'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 second exec. Pass --check-ports, --strict, or --reallocate-on-conflict to exec when 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.

Install

cargo install portool

Requires macOS or Linux (see Platform support).

Quick start

cd your-repo
portool init      # installs the post-checkout hook, updates git's info/exclude, runs sync
cat .env.portool   # your ports are already here

No .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 needed

See .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 checkout

.portool.toml

Commit 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 (webWEB_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 of block_align (default 5). Four ports with offsets 0..3 → raw size 4 → rounds up to 5.
  • No manifest at all → a single block_align-wide block, exposed as PORT=<block start>. This lets you adopt portool before writing a manifest.
  • The manifest is fail-closed: an empty [ports] table, an unknown top-level field, or a portool/portool_* key (reserved for portool's own identity variables) is a hard .portool.toml error rather than a silent single-block fallback or a colliding env var.

.env.portool

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=3003

Besides 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 +a

App code (e.g. Vite), with a fallback for when the file hasn't been generated yet:

const port = process.env.WEB_PORT || 3000;

portool exec

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. $NAME is 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}/testdb

and run your integration tests through portool:

portool exec --env-file .env.test -- npm run test:int

Each 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 '...'.

Keep your repo portool-free

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 runs npm test directly where it isn't.
  • Every port reference carries a fallback. ${WEB_PORT:-3000} in compose and env files, process.env.WEB_PORT || 3000 in 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 test stops 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.

Worktree identity

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. portool itself 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

CLI reference

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 recorded sequence is behind the ledger, or that is unreadable/corrupt — because a doctor --repair from 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's PORTOOL_PROJECT_ID / PORTOOL_WORKTREE_ID match 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-level exit/exec (unreachable) or is malformed. --repair is the only way portool ever touches a corrupt ledger: it restores registry.json from registry.json.bak (every project kept) and copies the corrupt file aside to a uniquely named registry.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's post-checkout / post-merge hook 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-readable partial_unhook JSON object listing the residue and exits non-zero instead of reporting success.
  • deinit — full reverse of init, run as an ordered transaction: remove the hooks, verify they no longer invoke portool, remove .env.portool from 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 the info/exclude entry. If any step can't complete — a hook that still invokes portool, an env file that won't delete — it prints a partial_deinit JSON 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-allocations to only remove the hooks and the ignore rule (the pre-0.7 deinit behavior).
  • 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.

portool init

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/exclude update and the initial sync still run first; only the hook-install step is then reported as a failure.
  • A core.hooksPath pointing at a directory that doesn't exist, or a core.hooksPath that resolves outside the repository in any config scope (see core.hooksPath and Husky below): hook installation itself fails outright, so init exits immediately and the info/exclude update and initial sync do 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.

portool sync

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.

portool exec

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.

portool reallocate

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.

portool ls

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.

ls --json output

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".

portool prune

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.

portool reserve / portool unreserve

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 5432
portool 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.

portool pin / portool unpin

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.

Exit codes

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).

Global config (config.toml)

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.

How it works

Allocation is two levels:

  1. Pool — the full port range ([3000, 9999] by default).
  2. Block — each worktree gets one contiguous block, sized from its .portool.toml (or block_align if 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.

post-checkout hook

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 0

PORTOOL_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.

core.hooksPath and Husky

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, run portool sync there once.

    One Husky-inherited caveat: in a brand-new worktree, .husky/_ doesn't exist until npm install (the prepare script) generates it, so no hook — portool's included — can fire on that first checkout, just like hooks don't run in a fresh clone before npm install. Run portool sync once in new worktrees, or add portool sync --quiet to your package.json prepare script.

  • Any other core.hooksPath whose directory exists, set in a per-repo (local/worktree) scope or as a repo-relative path: portool installs or appends <hooksPath>/post-checkout (and post-merge) there, idempotently.

  • A core.hooksPath that 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): init refuses 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.hooksPath that isn't an existing directory (likely generated by a tool portool doesn't recognize): init refuses to install into a location git would never read, and prints the exact line to add to your hook manager's post-checkout instead.

Claude Code integration

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.

Platform support

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.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages