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 flag | Applies to | Default | Purpose |
|---|---|---|---|
<slug> | create | required | Short kebab-case name for the feature, e.g. media-cli |
--issue <n> | create | required | The issue number this feature tracks. Digits only, at most six |
--skip-install | create | false | Skip installing dependencies in the new worktree |
--skip-data | sync | false | Reconcile ports only, leaving the backend alone |
--env <environment> | destroy | feature | The environment whose teardown is recorded on the audit trail. It never changes what is deleted |
--local-only | destroy | false | Tear down only the worktree and its ports, leaving the Cloudflare resources in place |
--json | all three | false | One 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"}}}}| Key | Type | Meaning |
|---|---|---|
command | "feature.create" | The subcommand that produced the line |
branch | string | The feature branch created or attached |
worktree | string | The absolute worktree path |
worktreeCreated | boolean | Whether a new worktree was created. False on an idempotent re-run over an existing one |
behindRemote | number or null | How 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 |
dev | object | The feature’s dev config — the document written to the worktree’s .dev.config.json |
dev.version | 1 | Dev-config schema version |
dev.branch | string | The branch this config belongs to |
dev.ports.index | number | The block’s index in the machine’s registry |
dev.ports.base | number | The first port in the block |
dev.ports.size | number | How many ports the block spans — its Worker capacity |
dev.workers.<name>.port | number | The port that Worker binds locally, for the life of the feature |
dev.workers.<name>.origin | string | The 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}| Key | Type | Meaning |
|---|---|---|
command | "feature.sync" | The subcommand that produced the line |
branch | string | The feature branch |
block | object | The feature’s reserved port block, as the central registry holds it |
block.block | number | The block index, 0-based |
block.base | number | The first port in the block |
block.size | number | How many ports the block spans |
dev | object | The reconciled dev config, in the shape feature.create reports |
addedWorkers | string[] | Workers that gained a port on this run |
removedWorkers | string[] | Workers that went away and released their port |
data | boolean | Whether 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}| Key | Type | Meaning |
|---|---|---|
command | "feature.destroy" | The subcommand that produced the line |
deletedResources | object[] | 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[].name | string | The resource name |
deletedResources[].id | string | The id that was deleted |
remote | boolean | Whether the remote teardown ran. false under --local-only |
portsFreed | boolean | Whether the feature’s port block was returned to the registry |
worktreePruned | boolean | Whether a registered worktree was pruned |
branchDeleted | boolean | Whether the feature branch was deleted. Only when it has been merged |
interrupted | boolean | Present 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 --jsonNeither 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.