treewright

command module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: MIT Imports: 5 Imported by: 0

README

treewright

Give every ticket its own git worktree, tmux window, and agent session. One command to start it, one to tear it down.

You're halfway through a bug fix when a review lands. So you stash, switch branches, wait for npm install, lose your agent's context, do the review, switch back, unstash, and spend five minutes remembering where you were.

Git worktrees fix that. You can have ten branches checked out in ten directories at once. What you get in exchange is ten directories to keep track of, ten sets of gitignored .env files to copy around, ten terminal tabs to keep straight, and a cleanup chore you'll skip until your disk fills up.

treewright is the part git leaves out:

tw new eng-2318-cart-total-rounding

That gets you:

  • a checkout at ~/code/storefront-eng-2318-cart-total-rounding
  • a branch john/eng-2318-cart-total-rounding, forked off the latest origin/main
  • your gitignored .env files copied into it
  • npm install already running in the background
  • a tmux window called ENG-2318 with your agent in it

Do that three times and prefix + T switches between all three from anywhere, including from inside a running agent. When the PR merges, tw rm eng-2318 takes the whole thing away, and stops you if there's unpushed work in there.

One session per repo

Every repo's windows live in a tmux session named after it, so your storefront's windows never end up next to your payments service's:

tmux session "storefront"
├── MAIN      ~/code/storefront
├── ENG-2295  ~/code/storefront-eng-2295-flaky-payment-test
├── ENG-2318  ~/code/storefront-eng-2318-cart-total-rounding
└── ENG-2324  ~/code/storefront-eng-2324-apple-pay-retry

tmux session "checkout-api"
├── MAIN    ~/code/checkout-api
└── PAY-88  ~/code/checkout-api-pay-88-idempotency-keys

MAIN is your home base. It sits in the main checkout, parked on main. Start new work from there, ask your general questions there, and keep feature work out of it.

Install

brew install jay-snyder/tap/treewright

You get two names for one binary: treewright, and tw, which is the one you'll actually type. The cask pulls in git and tmux, and clears the quarantine flag macOS puts on unsigned downloads.

The cask is macOS only. On Linux, grab a tarball from releases or:

go install github.com/jay-snyder/treewright@latest
Set up your shell

Add one line to your shell's startup file. It defines tw, sets up tab completion, and lets tw cd move your shell:

# in ~/.zshrc
eval "$(treewright shell-init zsh)"

# in ~/.bashrc
eval "$(treewright shell-init bash)"

# in ~/.config/fish/config.fish
treewright shell-init fish | source

Open a new terminal afterwards, or re-source the file, and tw is there.

Set up tmux

Add one line to ~/.tmux.conf:

run-shell 'treewright tmux-init --apply'

That binds two keys:

Key What it does
prefix + T Pick a worktree and jump to it
prefix + N Type a slug, get a worktree

You need them because a treewright window runs your agent as the window's command. There's no shell in there to type into. The keys open a popup on top of whatever's running, and close it again when you've picked.

Both are unbound in stock tmux. If your config already uses them, choose your own by adding flags to the same line:

run-shell 'treewright tmux-init --apply --resume-key G --new-key C-n'

Getting started

Go to the repo you want to work in and run two commands:

tw setup      # writes a config for this repo
tw doctor     # tells you if anything's missing

setup works out most of it for you: where your main checkout is, which branch to fork from (it reads origin/HEAD), your branch prefixes — read off the branches already on origin, or your git email if they say nothing — and which gitignored .env files to carry into new worktrees. It prints every guess and writes them to a commented TOML file, so fixing a bad one is a two-second edit. Use --dry-run if you'd rather look before it writes anything.

After that it's four commands:

tw new eng-2318-cart-total-rounding   # worktree + branch + window
tw ls                                 # what's going on
tw resume eng-2318                    # back to that window from anywhere
tw rm eng-2318                        # PR merged, clean it up

Seeing where everything stands

$ tw ls
   SLUG                          STATUS     AHEAD/BEHIND  WINDOW
*  main                          base       +0/-2         MAIN
   eng-2295-flaky-payment-test   merged     +1/-3         ENG-2295
   eng-2318-cart-total-rounding  dirty (1)  +0/-3         ENG-2318
   eng-2324-apple-pay-retry      active     +1/-3         ENG-2324

The * is where you're standing. +1/-3 is how far ahead of and behind origin/main that branch is, so the -2 on the top row is your main checkout going stale.

Status What it means
dirty (n) n uncommitted files. Beats every other status, since it's the easiest thing to lose.
merged Landed on origin/main. Safe to delete, and tw prune will.
unpushed (n) n commits that exist nowhere else. tw rm won't touch it without --force.
active Pushed, not merged. An open PR. tw prune leaves these alone.
base Your main checkout. Not a worktree, never deleted.

Squash merges count as merged, which sounds obvious but isn't: a squash merge leaves none of your branch's commits upstream, so the usual check says your work is unpushed and refuses to clean up. treewright rebuilds the patch and asks git whether it already landed.

Hit prefix + T and that same table becomes a menu, in a popup sized to fit it:

      SLUG                          STATUS     AHEAD/BEHIND  WINDOW
1) *  main                          base       +0/-2         MAIN
2)    eng-2295-flaky-payment-test   merged     +1/-3         ENG-2295
3)    eng-2318-cart-total-rounding  dirty (1)  +0/-3         ENG-2318
4)    eng-2324-apple-pay-retry      active     +1/-3         ENG-2324

select 1-4 (Esc to cancel):

One keypress, no Enter. Your main checkout is always row 1, so home base doesn't move around on you.

Commands

Command What it does
tw new <slug> [window-name] Fork a branch off the latest origin/<base_branch>, make the worktree, open a window on it
tw resume [slug] Go back to a worktree's window, or open it again. Shows the menu if you don't name one
tw cd [slug] Move your shell into a worktree
tw base [repo] Go to the main checkout's window
tw attach [repo] Attach this terminal to a repo's tmux session
tw ls [--json] [repo] The table above. Touches nothing
tw rm [-f] [-y] <slug> Delete the worktree, branch, stale remote ref, and window
tw prune [-y] [repo] Delete every merged, clean worktree. Lists them first unless you pass --yes
tw setup [-n] [name] Register the repo you're standing in
tw config [repo] Show the settings actually in force, defaults and all
tw doctor Check your install and every config you've registered
tw shell-init <shell> Print the shell integration for zsh, bash, or fish
tw tmux-init [--apply] Print the tmux integration, or load it straight into the server

tw help <command> has the details on any of them. There's also tw popup, which is what the key bindings run; you won't type it yourself.

You don't have to spell slugs out. tw cd eng-2318 finds eng-2318-cart-total-rounding and tells you that's what it did. If a prefix matches two worktrees you get an error listing both, because guessing on tw rm is how people lose work. Several commands take a second name too, if the first isn't what came to mind: create, remove, list, reopen, main.

Output is pipe-friendly. stdout is the answer and nothing else, so cd "$(tw new eng-2318)" and tw ls --json | jq both do what you'd hope.

Configuring it

One TOML file per repo, written by tw setup, in ~/.config/treewright/repos/<name>.toml. The filename is what you pass to commands that take a [repo].

main_dir      = "~/code/storefront"  # required: your main checkout
base_branch   = "staging"            # fork from and compare against this (default: main)
branch_prefix = "john/"              # branch name is <prefix><slug> (default: none)

# Or, if your team namespaces by kind of work rather than by person, list them
# instead and pick one by naming it: `tw new bug/eng-1` branches bug/eng-1, and
# the worktree is still repo-eng-1. A bare slug gets the first. One key or the
# other, not both.
# branch_prefixes = ["feature/", "bug/", "chore/"]

# Files git ignores, like your .env. A new worktree starts without them,
# so treewright copies them in from your main checkout.
carry_files = ["apps/api/.env", ".env.local"]

command        = "claude"              # what `new` and `base` launch
resume_command = "claude --continue"   # what `resume` launches
post_create    = "npm install"         # runs in the background after `new`

# Or a list of commands, run in order and stopped at the first failure. Each one
# is its own step, starting in the worktree root. `new` prints where the log is.
# post_create = ["npm install", "npm run codegen", "npm run build"]

ticket_pattern = '(?i)^(eng-[0-9]+)'   # first capture group names the window
tmux_session   = "shop"                # session for this repo (default: this file's name)

main_dir is the only one you need. Misspell a key and you get an error instead of a setting that silently does nothing. If you're ever unsure what's in effect, tw config prints the lot with defaults filled in, and tw doctor checks it.

Nothing that runs for you fails quietly. If post_create stops, the next ls, cd or resume for that worktree tells you which command it stopped at and where the log is. If command fails, its window stays open with the error still on screen rather than closing before you can read it.

You don't have to say which repo you mean. treewright matches on where you're standing, and that works from inside a worktree too.

It won't let you lose work

  • tw rm refuses if the worktree is dirty or has commits that aren't on origin. It fetches first, so something you merged two minutes ago still counts as merged. --force if you mean it.
  • tw prune only takes worktrees that are both merged and clean. Your open PRs are never on the list.
  • Nothing destructive runs on a guess. An ambiguous slug is an error, not a coin flip.
  • Deleting a worktree strands its tmux window in a directory that no longer exists, so treewright offers to close it for you. If that window is the last one in the session, it says so first, because closing it would detach you.

Questions you might have

Why not just use git worktree?

You should, and treewright does. Git makes the directory. It doesn't copy your .env files, run your install step, name a tmux window after the ticket, remember which window belongs to which checkout, or stop you deleting a branch you never pushed.

Do I need to be using an AI agent?

No, though that's what it was built for, and it shows. The defaults launch claude, and the tmux key bindings exist because a window running an agent has no shell in it to type into. But command is just a shell command. Set it to nvim, or $SHELL for a plain prompt, and you've got a worktree and tmux manager with no AI in it anywhere.

Will it mess with my existing tmux setup?

No. Every repo's windows go in their own session, named after its config. The one global thing the tmux snippet does is turn terminal titles on, and it's two lines you can delete.

What if I'm not in tmux?

Everything still works. Windows get created detached and treewright tells you how to attach. Without the shell integration, tw cd prints the path instead of moving you.

More

  • tw help <command> for detail on anything above.
  • docs/design-notes.md if you want to know why it behaves the way it does.
  • CLAUDE.md if you're working on treewright itself.

Documentation

Overview

Command treewright gives every ticket its own git worktree, tmux window, and agent session: one command to make all three, one to take them away when the work is done.

Translating errors into exit codes happens only here, so that every other package can report failure by returning an error rather than exiting. That is what makes the rest of the code testable.

Directories

Path Synopsis
internal
cli
Package cli parses treewright's arguments and runs the requested subcommand.
Package cli parses treewright's arguments and runs the requested subcommand.
config
Package config loads treewright's per-repo configuration.
Package config loads treewright's per-repo configuration.
git
Package git wraps the git commands treewright needs.
Package git wraps the git commands treewright needs.
gittest
Package gittest builds throwaway git repositories for tests: a bare "origin" plus a main checkout pushed to it, in a temp directory that the testing package removes afterwards.
Package gittest builds throwaway git repositories for tests: a bare "origin" plus a main checkout pushed to it, in a temp directory that the testing package removes afterwards.
refname
Package refname restates the parts of git's branch-name syntax that treewright checks before it builds a branch name out of a prefix and a slug.
Package refname restates the parts of git's branch-name syntax that treewright checks before it builds a branch name out of a prefix and a slug.
shellinit
Package shellinit produces the shell integration treewright needs.
Package shellinit produces the shell integration treewright needs.
testenv
Package testenv answers one question for a test that cannot run: is that a fact about a developer's machine, or a hole in CI?
Package testenv answers one question for a test that cannot run: is that a fact about a developer's machine, or a hole in CI?
tmux
Package tmux wraps the handful of tmux commands treewright drives.
Package tmux wraps the handful of tmux commands treewright drives.
tmuxinit
Package tmuxinit produces the tmux-side integration, the way shellinit produces the shell-side one.
Package tmuxinit produces the tmux-side integration, the way shellinit produces the shell-side one.
ui
Package ui renders treewright's interactive terminal pieces.
Package ui renders treewright's interactive terminal pieces.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL