pithy feature

Stand up an isolated environment for one issue — worktree, ports, local backend, and its own ephemeral Cloudflare resources — and tear the lot back down.

Synopsis

pithy feature create <slug> --issue <n> [--skip-install] [--json]
pithy feature sync [--skip-data] [--json]
pithy feature destroy [--env <environment>] [--local-only] [--json]

create runs from the main checkout. sync and destroy run from inside the worktree and take no name: the branch says which feature it is.

The feature’s live Cloudflare environment is pithy provision --feature. Provisioning is one job whichever environment it is for, so it is one command and one page. What lives here is the rest of a branch’s lifecycle.

destroy needs a Cloudflare account. It acts on real D1, KV and R2. create and sync are entirely local — a git worktree, a port reservation, and a Miniflare-backed dev backend. destroy --local-only is the deliberate way to tear down the local half without credentials.

Flags

Argument or flagApplies toDefaultPurpose
<slug>createrequiredShort kebab-case name for the feature, e.g. media-cli
--issue <n>createrequiredThe issue number this feature tracks. Digits only, at most six
--skip-installcreatefalseSkip installing dependencies in the new worktree
--skip-datasyncfalseReconcile ports only, leaving the backend alone
--env <environment>destroyfeatureThe environment whose teardown is recorded on the audit trail. It never changes what is deleted
--local-onlydestroyfalseTear down only the worktree and its ports, leaving the Cloudflare resources in place
--jsonall threefalseOne line of machine-readable output

What it does

The branch is cut from local main. Not from origin/main: a repository holding unpushed work would otherwise start every feature before that work, and where the older tree still loads there is no symptom at all — just a branch rooted in the past, found at merge. When local main is behind its remote the run says so and carries on, because cutting from a main a few commits behind is usually fine and sometimes deliberate; being told is what stops it becoming a surprise.

create

Validates the issue number and the slug at the boundary — before a branch or a worktree exists, so a refusal leaves nothing behind — then cuts the worktree and its feature/<issue>-<slug> branch, installs dependencies, reserves a port block, pins one port per Worker, and migrates and seeds the local dev backend.

Port allocation takes a lock over the machine’s <config>/dev-ports.json, claims the lowest free non-overlapping block across every checkout on the machine, and writes a per-worktree .dev.config.json that fixes each Worker’s port for the life of the feature. Assignment is sticky and never probed at startup, which is what lets several worktrees run at once. pithy dev has the whole model.

sync

Makes the worktree’s local environment ready, whatever state it is in, and covers the two everyday cases with one command: you added a Worker, or a colleague pushed the branch and you pulled it.

None of that state is in git — the .dev.config.json and the port reservation are both machine-local — so a sync creates them on your machine, with your own free block. It reconciles ports through the same code create uses, then migrates and seeds the local backend. Every step is idempotent.

--skip-data reconciles ports only. It leaves the backend entirely alone: no migrate, no seed. It touches no .dev.vars either, with or without the flag — each Worker’s is generated by pithy dev from sources already on your machine.

Provisioning the live half

pithy provision --feature, run from inside the worktree. It resolves the feature identity from the branch and the project name from the root pithy.config.ts.

Nothing is stored to make this work: every name is derived from the branch, and an already-provisioned resource is recovered by looking its name up in Cloudflare — which is what makes it idempotent and resumable. On a second push, CI computes the same names, finds the existing resources, rewrites the same wiring and deploys. There is no id file to merge, so there is nothing to conflict.

The config it writes is a build artifact, and it is not written where source lives. The ids go into apps/<worker>/.wrangler/pithy/wrangler.feature.jsonc, generated from the Worker’s tracked wrangler.jsonc on every run — so the tracked file is never dirty, git add -A cannot reach the ids, and a feature abandoned without destroy strands nothing.

The consequence worth stating: pithy env reports what a Worker’s tracked config declares, so a feature environment does not appear there.

It mints the feature’s own master key, and binds it. A feature environment used to get every resource except its secrets, so a Worker composing secrets deployed and failed on its first request. The key is the feature’s own, and destroy deletes it — pithy secrets deprovision preserves a key unless asked, because losing it orphans every secret, and for an ephemeral environment that reasoning inverts.

A feature has no secrets manager, deliberately. A manager is a Worker with its own D1 and its own rotation cron, and one per open pull request is not a cost a branch should carry. So pithy secrets create targets a declared environment, never a feature: a secret a feature needs beyond its master key is one the report names as unbound, with the command that creates it.

destroy

Deletes the feature’s Cloudflare resources — first by the exact ids in the worktree’s manifest, then by recomputing every name the enabled capabilities could have produced and deleting what still exists — then frees the port block, prunes the worktree, and deletes the branch if it has been merged. That second pass is what catches a partly-failed provision.

It does not need a Worker config to load. Its local half is derived from the branch and the root config, and that is deliberate: the state it is most needed in is a create that failed partway, which leaves a worktree whose Worker config throws. A teardown that loaded it first was unavailable in exactly that state, and the port block leaked to a branch that no longer existed.

Missing credentials are a hard failure rather than a silent skip. Skipping the remote half would leak every D1, KV and R2 while reporting success — and the teardown then deletes the branch the resource names are derived from, so a later attempt could no longer work out what to delete. A CI job whose credentials did not propagate must fail loudly. --local-only is the deliberate opt-out.

Both provisioning and destroy are audited. The trail lands in the project’s own top-level dev database rather than the feature’s, because the feature’s does not exist yet when provisioning starts and is deleted by teardown.

--json

One line, one object, one shape per subcommand.

feature.create

$ pithy feature create media-cli --issue 69 --json
{"command":"feature.create","branch":"feature/69-media-cli","worktree":"/repo/.worktrees/69-media-cli","worktreeCreated":true,"dev":{"version":1,"branch":"feature/69-media-cli","ports":{"index":1,"base":8807,"size":20},"workers":{"api":{"port":8807,"origin":"http://localhost:8807"}}}}
KeyTypeMeaning
command"feature.create"The subcommand that produced the line
branchstringThe feature branch created or attached
worktreestringThe absolute worktree path
worktreeCreatedbooleanWhether a new worktree was created. False on an idempotent re-run over an existing one
behindRemotenumber or nullHow many commits local main is behind origin/main, or null when it is level, has no remote, or the repository has no local main. The branch is cut from local main either way — this reports it rather than refusing
devobjectThe feature’s dev config — the document written to the worktree’s .dev.config.json
dev.version1Dev-config schema version
dev.branchstringThe branch this config belongs to
dev.ports.indexnumberThe block’s index in the machine’s registry
dev.ports.basenumberThe first port in the block
dev.ports.sizenumberHow many ports the block spans — its Worker capacity
dev.workers.<name>.portnumberThe port that Worker binds locally, for the life of the feature
dev.workers.<name>.originstringThe origin sibling Workers reach it at

feature.sync

$ pithy feature sync --json
{"command":"feature.sync","branch":"feature/69-media-cli","block":{"block":1,"base":8807,"size":20},"dev":{…},"addedWorkers":["web"],"removedWorkers":[],"data":true}
KeyTypeMeaning
command"feature.sync"The subcommand that produced the line
branchstringThe feature branch
blockobjectThe feature’s reserved port block, as the central registry holds it
block.blocknumberThe block index, 0-based
block.basenumberThe first port in the block
block.sizenumberHow many ports the block spans
devobjectThe reconciled dev config, in the shape feature.create reports
addedWorkersstring[]Workers that gained a port on this run
removedWorkersstring[]Workers that went away and released their port
databooleanWhether the backend steps ran — migrate then seed, together. false under --skip-data, and never anything else: one flag governs both, so they cannot disagree

block and dev.ports describe the same reservation from two sides — block is the registry’s record, dev.ports is the worktree’s copy — and they spell the index differently because each keeps the name its own file uses.

feature.destroy

$ pithy feature destroy --json
{"command":"feature.destroy","deletedResources":[{"kind":"d1","name":"acme-f69-media-cli-db-d1","id":"…"}],"remote":true,"portsFreed":true,"worktreePruned":true,"branchDeleted":false}
KeyTypeMeaning
command"feature.destroy"The subcommand that produced the line
deletedResourcesobject[]Every Cloudflare resource deleted, from the manifest and from the name reconcile. Empty when nothing remained, or when the remote half was skipped
deletedResources[].kind"d1", "kv" or "r2"The resource kind
deletedResources[].namestringThe resource name
deletedResources[].idstringThe id that was deleted
remotebooleanWhether the remote teardown ran. false under --local-only
portsFreedbooleanWhether the feature’s port block was returned to the registry
worktreePrunedbooleanWhether a registered worktree was pruned
branchDeletedbooleanWhether the feature branch was deleted. Only when it has been merged
interruptedbooleanPresent and true only on a teardown that failed partway. It says deletedResources is a record of what went before the failure rather than of the whole teardown, and that the three local flags are all false because the local half deliberately did not run — the worktree is where the re-run happens from, and the manifest naming what is left lives in it

A teardown deletes real infrastructure one resource at a time, with no transaction across them. So a failure on the fourth delete still fails the command, and stdout still names the three that went: an operator finishing the teardown by hand needs the record more on that run than on the one that worked.

Errors

A bad issue number, or a bad slug. Checked before the branch or worktree exists, so nothing is left behind.

Issue must be a number of at most 6 digits (got "sixty-nine").
Pass --issue <number>.

Credentials missing on destroy. Refused rather than silently skipped, because the alternative leaks every resource while reporting success.

Cloudflare credentials are missing, so the feature's resources cannot be deleted.
Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to tear down the remote environment, or pass --local-only to remove just the worktree and its ports.

The project has no name. It is the first segment of every feature resource name and the only key teardown has to find them by, so it is required with no guessed fallback: a wrong guess means destroy computes names that match nothing, deletes nothing, and exits 0.

The branch is not a feature branch. sync and destroy derive the issue and slug from the checked-out branch, so they must be run from inside the worktree.

An illegal --env on destroy. Validated at the flag, before any Cloudflare client is built.

Examples

Cut a feature environment from the main checkout.

$ pithy feature create media-cli --issue 69
Worktree /repo/.worktrees/69-media-cli.
Branch feature/69-media-cli.
api: http://localhost:8807
Local backend migrated and seeded.
Done.

Pick up a colleague’s branch on your own machine, from inside the worktree.

$ pithy feature sync
api: http://localhost:8807 (new).
Local backend migrated and seeded.
Done.

Stand up the live environment in CI, and tear it down on merge.

pithy provision --feature --json
pithy feature destroy --json

Neither leaves anything to commit: a feature’s ids are written to a git-ignored file, and the provision line says so.

$ pithy feature destroy --local-only
Remote teardown skipped. Cloudflare resources were left in place.
Worktree pruned.
Done.
ESC