The worktree you are in, as wt sees it: how far behind trunk, its own remote, and wt up, undo and push from a small dialog. Optional: open it in gittree, and…

Git worktree tooling: one implementation, per-repo configuration.
Replaces the ~1,580-line copy of worktree scripts that seven repositories each carried privately — copies that had drifted into two to four variants of nearly every file, to the point where the same bug was found and fixed twice, in two repos, with two incompatible fixes, and neither reached the other five.
git clone git@github.com:anders-lindstrom/wt.git && cd wt
./install.sh # builds to ~/.local/bin, installs to ~/.local/share/wt
Then in your shell rc:
source ~/.local/share/wt/wt.sh
cd your-repo
wt init # writes bin/worktree/worktree.conf
wt doctor # check it
wt init asks for the three keys a repository actually varies, offering detected values as the defaults:
trunk (MAIN_BRANCH) [main]:
branch prefix [feat_wt]:
build command (blank for none): make build
Every other key is written commented at its default, so the file is this repository's reference for what it may set — uncommenting a line as it stands changes nothing. An answer that would not validate is rejected at the prompt, not written and then discovered by the next command:
! WORKTREE_BRANCH_PREFIX="wip_wt" yields default type "wip", which is not in
WORKTREE_TYPES; set WORKTREE_DEFAULT_TYPE to choose one of: feat fix docs …
With --yes, or with no terminal to answer from, the detected values are written without asking — so a script, a hook or an agent gets the same result without hanging on a prompt. --force replaces a configuration already there; without it, an existing file is an error rather than something to overwrite.
A repository with no configuration works too: every command runs on what wt init --yes would write — trunk from origin's HEAD, else the first of development, main and master that exists here or on origin, the default prefix, no build command — detected on each run and never written to the repository. That is what lets a harness make worktrees in a clone nobody set up (the Claude Code hooks, for one). wt new says so in one line on stderr, wt config shows the values as detected, and wt doctor notes it without counting it as a problem. Nothing that runs code is detected: only a bin/worktree/provision.sh the repository itself carries runs. When not even trunk can be detected — no origin HEAD, and none of those three branches — wt does not take the checked-out branch for it: commands stop with "cannot tell which branch is trunk", wt init --yes refuses, and wt init asks you to name it.
If the config lands somewhere git ignores — a repo ignoring bin/ for its build output also ignores bin/worktree/ — wt init says so, because that configuration would work for you and for nobody who clones the repo.
Grouped the way wt --help groups them. Every command carries worked examples: wt <command> --help.
Make a worktree
wt new <type>/<work> | create a branch and worktree, then provision it (--base, --no-setup, --no-build, --no-superset, --dry-run) |
wt checkout <branch> [<work>] | put a worktree on a branch that already exists; <remote>/<branch>, or a bare name only one remote has, creates the local branch tracking it, from the refs as last fetched; also wt co (--dry-run) |
wt pr checkout [<number>] | put a worktree on a pull request; with no number, pick one from the open ones, your review queue first |
wt pr list | every open pull request, its state and checks, and the worktree on it |
wt pr open [<work>] | open a worktree's pull request in the browser; with no argument, the one you are in |
Get to your work
wt cd [<pattern>] | cd to a worktree, in this shell; . is the one you are in, bare or / the main checkout |
wt exec <pattern> <cmd>… | run a command there, in a subshell; your shell stays put |
wt attach [<pattern>] [<session>] | open the Claude session in a worktree, in this terminal; asks which when there are several; --resume continues a conversation there instead |
wt list | every worktree, in any layout; s marks Superset's, ! one nothing owns; a PR column when a worktree here has one, asked for by branch and cached for a few minutes (--no-pr, --refresh); a SESSION column when one has a Claude session in it and the terminal is wide enough for it (--wide whatever the width, --no-sessions); --all, --roots, --profile for many repositories |
wt status [<work>] | each worktree's branch, whether its checkout is clean, and how far behind and ahead of trunk it is; with a worktree named, that one in full with wt sync's verdict; wt list's PR column and cache (--no-pr, --refresh; --all, --roots, --profile for many repositories) |
wt find <pattern> | resolve a worktree by fuzzy name, across repositories (--candidates) |
wt path <work> / wt branch <work> | where a piece of work lives or would go, and its branch; exact, this repository only, for scripts |
wt repos | every repository wt manages under your roots, with its worktrees and whether wt sync is set up (--all, --roots, --profile, --paths) |
Keep up with trunk
wt up [<work>] | sync the worktree you are in with trunk by rebasing it, only if it goes through without you — conflict-free, or every stop resolved by .wt-sync.yaml; otherwise it touches nothing and says why. Also where trunk declares no .wt-sync.yaml, conflict-free only. The short form of wt sync rebase . --if-ready (--push, --no-push, --no-fetch, --yes/-y for yes to everything including the push, --force/-f to go ahead past an agent session in it) | ||
wt status <work> --json / wt up --json | a worktree's plan for wt up, and a run's result, as one JSON object each for tools driving wt (--expect holds a run to its plan); wt schema prints their JSON Schemas, and docs/json.md explains them | ||
wt sweep --dry-run --json / wt sweep --yes --json | a sweep's plan, every row with why it is merged or kept, and a sweep's result row by row, for tools driving wt (--expect holds the sweep to its plan); see docs/json.md | ||
wt remove <work> --dry-run --json / wt remove <work> --yes --json | a removal's plan — where the branch stands, every reason it would refuse, what it would lose — and its result effect by effect, for tools driving wt (--expect holds the removal to its plan) | ||
wt restore <dir> --dry-run --json / wt restore <dir> --json | what putting back a worktree moved to a folder would do to its branch, and what it did; recovery.json in the folder is itself versioned (wt schema recovery) | ||
wt purge <dir> --json / … --yes --json | what deleting a removed worktree's folder for good would delete and leave unreachable, with a token, and what became of each pin and the folder (--expect holds the purge to its plan) | ||
wt refs sweep, restore, purge --json; wt refs swept --json | each plan with a token and each result row by row, for the ref sweep; see docs/json.md | ||
wt sync --json / `wt sync rebase\ | resume\ | undo --json` | the sync overview of every worktree, and a verb's result, as JSON for tools (wt sync rebase --expect holds a run to the overview); wt schema sync and wt schema sync-run |
wt new … --dry-run --json / wt new … --json | the same for creating a worktree, and for wt checkout: the plan with a token, and a result naming every side effect (--expect refuses a moved base or branch) | ||
wt sync | what rebasing each worktree onto trunk would do, simulated after fetching trunk; changes nothing of yours (--no-fetch) | ||
wt sync rebase [<work>...] | sync the named worktrees by rebasing them onto trunk, with the declared strategies (--no-fetch, --yes/-y, --push, --no-push, --if-ready); also spelled wt sync <work>... --rebase. With nothing named, every worktree the table calls ready except recipe?, asked first — and with no terminal, only with --yes. --if-ready rebases what is ready and fails if anything was not. The push question defaults to no; --yes answers yes to it, like every question, unless --no-push | ||
wt sync resume <work> | continue the rebase a run left at a conflict that was yours (--yes/-y, --push, --no-push); also spelled wt sync <work> --resume | ||
wt sync undo <work> | put back every ref the last wt sync rebase on this worktree moved, aborting a rebase a run handed over (--force/-f, --yes/-y); also spelled wt sync <work> --undo | ||
wt sync doctor | check what a run needs; --fix turns on rerere and removes expired locks, --prune deletes old safety refs; a keeper row says whether one is installed and how its last pass went | ||
wt sync keep once | one unattended pass: fetch trunk and, when it moved, rebase every ready worktree nobody is in and push what finished (--no-push); keep run still works; logged to .git/wt-sync-keep.log; what the job runs, and what cron runs elsewhere | ||
wt sync keep start | install a launchd job (macOS) that runs wt sync keep once every 30 minutes (--every, --no-push), pushing the way this shell's git does; wt sync keep status for the last pass and the next, wt sync keep stop to remove it; --all, --roots or --profile on all three for many repositories |
Put worktrees in their place
wt migrate <work> [<type>/<name>] | move a worktree where it belongs, renaming or retyping it on the way (--dry-run, --force/-f); also wt move |
wt adopt <path> | provision a worktree another tool created (--relocate, --no-build) |
wt setup [<from-dir>] | provision the worktree you are in (--no-build, --source to name what ran it) |
wt remove <work> | remove a worktree; delete its branch when merged — on trunk, or as a pull request the cache says landed — keep it when not (--yes, --dry-run, . for the one you are in, --force/-f past a session, a held lock or hidden files, --force=<list> past only those named, --move-to <dir> to move it into a folder instead of deleting it, --keep-superset, --json, --expect) |
wt restore <dir> | put back a worktree --move-to moved into <dir>, and its branch, from the recovery.json there (--dry-run, --json) |
wt purge <dir> | delete a worktree --move-to moved into <dir> for good: the refs pinning its commits, then the folder; refuses a folder no removal made or one a removal or restore left half-way, and finishes a purge that stopped (--yes, --dry-run, --json, --expect) |
wt sweep | delete local branches already merged into trunk — or whose pull request GitHub merged — and remove the worktrees on such branches that nothing is using; from the main checkout only (--no-fetch, --yes, --dry-run, --json, --expect, --move-to <dir>, --keep-superset), or across repositories with --all, --roots, --profile |
wt refs sweep | move backup branches and tags (backup/*, safe-* and the like) that another ref contains, or older than ref_sweep_age, to pins under refs/wt-swept/<run>/; wt refs swept lists runs, wt refs restore <run> puts one back, wt refs purge deletes pins for good (--remote, --only, --run-id, --yes, --dry-run, --json, --expect) |
This repository, and this build
wt init | create this repository's worktree.conf, pinning what wt otherwise detects on each run (--yes to skip the prompts, --force/-f to replace one) |
wt config [--shell] | the resolved configuration, typed or eval-able |
wt config get/set/unset/path | your own settings, per machine, from anywhere |
wt doctor | check config, required tools and worktree health, and your roots and profiles; --all, --roots, --profile check every repository, wt sync doctor included where it is set up |
wt about / wt version | which build this is and what changed; the version alone, for scripts |
wt completion zsh | shell completion, including live work names |
A bare <work> takes the repository's default type, so wt new thing creates feat_wt/thing. <work> matches exactly, inside this repository, and is what every command that changes or deletes takes; <pattern> (cd, exec, find) is fuzzy and searches your other repositories too.
wt knows where your repositories live: your roots, set per machine.
# ~/.config/wt/config.toml
[roots]
work = "~/src/work" # a folder: every repository one level down
oss = "~/src/oss"
dotfiles = "~/dotfiles" # a repository: that one, searched no deeper
[profiles]
api = ["~/src/work/api", "~/src/work/billing"]
wt config set root.work ~/src/work and wt config set profile.api "<dir> <dir>" write them; WT_ROOTS overrides the table. Only repositories with a bin/worktree configuration count.
wt repos lists them, and wt cd <repo> goes to one.wt sweep --all and wt sync --all --rebase work across every one, planned a few at a time and asked once; wt sync --all is the overview of each. --roots work or --profile api narrows the run.wt list, wt status and wt doctor take the same flags.wt doctor checks that every root is there and every profile entry is still a repository wt manages, inside a root.A binary cannot change its caller's directory. These do:
wt_cd <pattern> | the same as wt cd, if you prefer the underscore form |
wt_exec <pattern> <cmd>… | run a command there, in a subshell; your shell stays put |
wt_attach [<pattern>] [<session>] | the same as wt attach |
wt_dir <pattern> | print the path (stdout is path-only) |
wt_ls [pattern] | list worktrees, or show what a pattern matches |
wt_rm_me | remove the worktree you are standing in |
wt cd, wt exec and wt attach are the same functions under a nicer name: wt is itself a shell function that handles those three and passes everything else to the binary. The first two are there because a process cannot change its caller's directory, and wt attach because your shell's claude may be a function of your own, which only your shell can call.
They are thin wrappers over wt find; the matching itself lives in the binary where it is tested. A pattern of . is the worktree you are standing in, and / the repository's main checkout.
<parent>/<repo>_wt/<type>_wt/<work> branch: <type>_wt/<work>
code/
├─ myrepo/ ← the repo
└─ myrepo_wt/
├─ feat_wt/api-tidy/ branch: feat_wt/api-tidy
└─ fix_wt/login-crash/ branch: fix_wt/login-crash
The path tail below <repo>_wt/ is character-for-character the branch name, so the two convert with no rules to remember. Why the layout is this shape, and the rules a tool building on it follows: docs/worktree-conventions.md.
_wtThe _wt on a branch is one setting. A repository whose branches should read fix/login-crash says so in its own file:
WORKTREE_BRANCH_SUFFIX=""
and a person who wants that wherever the repository has not decided says so in theirs, once, for every repository they work in:
wt config set branch_suffix "" # or "-wt", or whatever yours carry
The worktrees do not move for it. The folders are wt's layout, not a name anybody types, and they keep the suffix WORKTREE_TYPE_SUFFIX gives them:
code/
├─ myrepo/ ← the repo
└─ myrepo_wt/
├─ feat_wt/api-tidy/ branch: feat/api-tidy
└─ fix_wt/login-crash/ branch: fix/login-crash
The repository outranks the person, but only where it said something: a committed file that never mentions the suffix is not a decision, so whoever clones it may still name their own branches. wt config and wt doctor both print which file decided.
What the suffix buys, and what dropping it costs: a suffixed branch says at a glance that wt made it, and wt remove renames an unmerged one out of the prefix to keep its commits (fix_wt/login-crash → login-crash). Without one, any <word>/<name> branch — dependabot/npm-x, an agent's own claude/… — reads as a worktree branch of type <word>. Nothing breaks: every lookup still goes through git worktree list, so only the names change.
feat or feature, docs or doc: the word is a preference, and the type underneath it is not. Name the ones you want called something else, in the repository's file or in your own:
WORKTREE_TYPE_NAMES=(feat=feature docs=doc) # the repository's naming
wt config set type_names "feat=feature" # or yours, everywhere
$ wt new feat/login-crash # or feature/login-crash: the same work
~/src/myrepo_wt/feat_wt/login-crash branch: feature/login-crash
The type is the identity — a fix is a fix whether its branches say fix, fix_wt or bugfix — so it is what the folders carry and what every command reasons in. Only the branch reads the word, which is why renaming one strands no checkout, and why a branch named before the change still resolves.
Everything takes both spellings: wt new, wt migrate, wt path, wt branch, the type read out of a bare name (wt new feature_dev-123), the type read off somebody else's pull request branch, and the completions, which offer the words you would type. The same two-file rule as the suffix applies — the repository where it named its types, you everywhere else — and a pair for a type a repository does not have is a name for somewhere else, so it quietly does not apply there.
Two names for one type, or a name that is already another type, would make a branch ambiguous; the repository's file is told so, and your own pairs are dropped where they would collide.
The type comes from the spec — wt new fix/login-crash — or, for a bare name, is read out of the name itself: wt new fix_dev-123 creates fix_wt/dev-123. A name whose first word is not a type is left whole, and an explicit <type>/<work> always wins. This is what lets the type survive a tool that has nowhere to enter one; Superset mints every branch from a single fixed prefix.
Worktrees in other layouts keep working. Every lookup goes through git worktree list, never the shape of a path, so worktrees made by Superset, by plain git worktree add, or before a repo was migrated all resolve.
Superset builds <parent>/<repo>_wt/<repo>/<type>_wt/<work> — the canonical path with the repository name repeated, because it joins its per-project worktree base directory with <repo>/<branch>. No setting on either side removes that segment.
wt treats it as a layout of its own rather than a fault. wt setup provisions a Superset workspace where it stands and never moves it, wt doctor passes it, and wt list marks it s. Only ! — a layout nothing owns, such as a pre-migration <repo>-<work> checkout — is a wt migrate candidate, because Superset stores the absolute path of every workspace and a move leaves that workspace pointing at nothing. wt migrate says so before it moves.
Off until you turn it on. Superset is a per-person choice, so nothing here happens until:
wt config set superset true
After that, wt new, wt checkout and wt pr checkout hand the worktree to Superset once it is provisioned, so one started from the shell appears in the app beside the ones started there. Superset adopts the checkout git already has — it creates nothing and moves nothing, and the workspace points at wt's canonical path.
This needs all three of: the superset CLI, on the PATH or at ~/.superset/bin/superset where the desktop app installs it; the host service running, which means the app is open; and this repository already one of Superset's projects. A machine with no Superset, and a repository Superset does not track, are ordinary states and pass in silence. A Superset that is there and would not answer is one line on stderr and nothing else — wt new still prints only the path on stdout and still exits the same way. wt never starts the app and never creates a project. wt doctor reports which of the three it found, whatever the mode.
Registering a branch that already has a workspace is Superset's own no-op, so running it twice changes nothing. A new workspace makes Superset run the project's setup step if it has one, which for these repos is wt setup --source superset: a second, idempotent provisioning pass on top of the one wt new just did. --no-setup therefore skips registration too.
Removal mirrors it. wt remove and wt sweep, plain or --move-to, delete the worktree's workspace with superset ws delete — only after the directory has left its path and git no longer lists it, because Superset's delete force-removes the checkout. A Superset that is down or errs is one line and the removal stands. wt restore registers the worktree again.
Off again with wt config set superset false, which stops wt running Superset at all. With it on, a repository can still decline with SUPERSET_REGISTER=off in its worktree.conf, and one run can with wt new --no-superset. A repository that asks for it with SUPERSET_REGISTER=on hears about every way the registration can stop, this one included, and wt doctor counts those as problems.
On by default, and inert where it cannot apply. wt reads pull requests through the gh CLI and keeps no credentials of its own: whatever gh is logged in as is what wt sees. Nothing wt does writes to GitHub — the only gh command it runs that changes anything is gh pr checkout, and what that changes is your own checkout.
wt pr list # every open pull request, and the worktree on it
wt pr checkout 12 # a worktree for that one, provisioned like any other
wt pr checkout # pick from the open ones
wt pr open # this worktree's pull request, in the browser
wt pr checkout makes the worktree at the canonical path and provisions it through the same step as wt new, so everything else — provision.sh, the build, the Superset registration if you have opted into it — happens by its own rules. The worktree goes on the pull request's own head branch, put there by gh pr checkout running inside the new worktree, so a push from it updates the pull request. That is what makes a pull request from a fork work: gh points the branch's remote at the fork, which nothing else would get right. Your main checkout is never switched.
The worktree is named pr-<number>-<branch>, under the type its branch suggests — feat_wt/pr-12-residential_fixes — so wt cd pr-12 finds it. A head branch that already follows this repository's convention keeps its own name instead, so a branch wt made lands exactly where wt new would have put it. A pull request whose branch is already in a worktree prints that path and makes nothing.
Given a number, a merged or closed pull request is checked out too: finished work is worth re-reading. GitHub deletes the head branch when a pull request merges, so wt falls back to refs/pull/<number>/head — the same commit, on a branch with no upstream, and it says so.
A local branch of the pull request's name that has diverged from it — commits of its own, and missing some of the pull request's — stops the checkout before anything is made: gh only fast-forwards a branch that exists. That check is for an open pull request from this repository's own branches; a fork's, or a finished one, goes to gh as it is. wt prints how far a
hooks/register.tsx 1250 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Busy, Input, Outcome, Plan, Review } from '../types'
5
6// The worktree the session stands in, as `wt status --json` reports it: one
7// line above the prompt in git's marks, the part of it that is wrong in the
8// warning colour, and a small dialog (`/wt`, or a click on that line) where
9// the keys act. Nothing is pinned under the prompt: the engine draws a mod's
10// notice there as a warning whatever it says. Every action is a wt command the person pressed or
11// typed; nothing here rebases, pushes or undoes by itself.
12//
13// Two knobs, off unless the person's settings turn them on, each a section of
14// its own that reads nothing of wt's but the worktree's path: `gittree` (open
15// the worktree in gittree) and `reviewStatus` (when this conversation last ran
16// a review skill, and when the person last typed: above the prompt once each
17// is old, and in the dialog).
18
19type Engine = EngineInterface
20type Loose = Record<string, any>
21type Ran = { exitCode: number; stdout: string; stderr: string }
22type Action = 'up' | 'upDiverged' | 'undo' | 'push' | 'resume' | 'fetch' | 'refresh' | 'details' | 'close' | 'dismiss' | 'gittree'
23type Tone = 'suggestion' | 'success' | 'warning' | 'error'
24// One part of the line above the prompt, in the colour that says how it is:
25// none when it is as it should be.
26type Part = { text: string; tone: Tone | 'dim' | undefined; isOwn?: true }
27type Note = Part
28// What the dialog shows for a worktree: what is not as it should be, and the
29// keys, the one that moves the branch on a row of its own.
30type Detail = { notes: Note[]; primary: Action | null; hint: string; follow: Action[]; other: Action[] }
31
32const PANE = 'wt'
33const TEN_MINUTES = 600_000
34const PULSE_MS = 10_000
35// Past this, what is shown about trunk says how old it is.
36const STALE_MS = 60 * 60_000
37
38const plan = atom({ plugin: 'wt', key: 'plan' } as const, null)
39const busy = atom({ plugin: 'wt', key: 'busy' } as const, 'idle')
40const outcome = atom({ plugin: 'wt', key: 'outcome' } as const, null)
41const inputs = atom({ plugin: 'wt', key: 'inputs' } as const, 0)
42const lastInput = atom({ plugin: 'wt', key: 'lastInput' } as const, null)
43const lastReview = atom({ plugin: 'wt', key: 'lastReview' } as const, null)
44const firstInput = atom({ plugin: 'wt', key: 'firstInput' } as const, null)
45
46// The knobs as the person's settings have them; `register` fills them in.
47// A skill that performs a review: "review" in its name, and not the one about
48// receiving one.
49const REVIEW_SKILLS = '^(?!.*receiving).*review'
50const knobs = {
51 hasGittree: false,
52 hasReview: false,
53 reviewSkills: new RegExp(REVIEW_SKILLS, 'i'),
54 // How old the last input and the last review are before the line above
55 // the prompt says them.
56 inputAfterMs: 5 * 60_000,
57 reviewAfterMs: 30 * 60_000,
58}
59
60const LABEL: Record<Action, string> = {
61 up: 'wt up',
62 upDiverged: 'wt up over the divergence',
63 undo: 'undo',
64 push: 'push…',
65 resume: 'resume',
66 fetch: 'fetch',
67 refresh: 'refresh',
68 details: '/wt',
69 close: 'close',
70 dismiss: 'dismiss',
71 gittree: 'gittree',
72}
73// On the line above the prompt gittree is a word to click: the arrow says it
74// opens somewhere else.
75const GITTREE_OUT = 'gittree ↗'
76// The dialog's keys are letters: it holds the keyboard while it is open.
77const HOTKEY: Partial<Record<Action, string>> = {
78 up: 'u',
79 upDiverged: 'v',
80 undo: 'z',
81 push: 'p',
82 resume: 'c',
83 fetch: 'f',
84 refresh: 'r',
85 gittree: 'g',
86 dismiss: 'd',
87 close: 'q',
88}
89// Said beside the dialog's close key: how it goes away while it holds the
90// keyboard, and how it gets the keyboard when it opened without it.
91const CLOSING = 'Esc closes too'
92const KEYLESS = 'click here, or ctrl+x then tab, for the keys'
93const RUNNING: Record<Busy, string> = {
94 idle: '',
95 up: 'wt up is running: fetch, fast-forward, rebase…',
96 undo: 'wt sync undo is running…',
97 resume: 'wt sync resume is running…',
98}
99
100const parse = (text: string): Loose | null => {
101 try {
102 const value: unknown = JSON.parse(text)
103
104 return value !== null && typeof value === 'object' ? (value as Loose) : null
105 } catch {
106 return null
107 }
108}
109
110const short = (sha: unknown): string => (typeof sha === 'string' && sha !== '' ? sha.slice(0, 7) : '?')
111const lastLine = (text: string): string => text.trim().split('\n').pop()?.trim() ?? ''
112
113// How long ago, in the fewest words that stay true.
114const ago = (now: number, at: number): string => {
115 const minutes = Math.max(0, Math.round((now - at) / 60_000))
116
117 if (minutes < 1) {
118 return 'just now'
119 }
120
121 return minutes < 90
122 ? `${minutes} min ago`
123 : minutes < 2_880
124 ? `${Math.round(minutes / 60)}h ago`
125 : `${Math.round(minutes / 1_440)} days ago`
126}
127
128const inputsSince = (count: number): string => (count === 0 ? 'no input since' : count === 1 ? '1 input since' : `${count} inputs since`)
129
130// What the review knob puts on the line above the prompt: the last input and
131// the last review, each only once it is older than its limit. A conversation
132// no review has run in is as old as its first input.
133const lingerOf = (typed: Input | null, review: Review | null, first: number | null, count: number, now: number): string => {
134 const parts: string[] = []
135
136 if (typed !== null && now - typed.at >= knobs.inputAfterMs) {
137 parts.push(`last input ${ago(now, typed.at)}: “${typed.head}”`)
138 }
139
140 if (review !== null) {
141 if (now - review.at >= knobs.reviewAfterMs) {
142 parts.push(`reviewed ${ago(now, review.at)}, ${inputsSince(count - review.inputs)}`)
143 }
144 } else if (first !== null && now - first >= knobs.reviewAfterMs) {
145 parts.push(`not reviewed yet, ${count === 1 ? '1 input' : `${count} inputs`}`)
146 }
147
148 return parts.join(' · ')
149}
150
151const planOf = (stdout: string): Plan | null => {
152 const s = parse(stdout)
153
154 if (s === null || s.command !== 'status' || s.worktree === null || typeof s.worktree !== 'object') {
155 return null
156 }
157
158 const w: Loose = s.worktree
159 const own: Loose | null = w.ownRemote ?? null
160 const stack: Loose[] = Array.isArray(s.stack) ? s.stack : []
161 const sessions: Loose[] = Array.isArray(s.sessions) ? s.sessions : []
162
163 return {
164 work: String(w.work),
165 branch: typeof w.branch === 'string' && w.branch !== '' ? w.branch : null,
166 path: String(w.path),
167 isMain: w.isMain === true,
168 trunk: String(s.trunk ?? ''),
169 trunkRef: String(s.trunkRef ?? s.trunk ?? ''),
170 trunkFetchedAt: s.trunkRefUpdatedAt ?? null,
171 gitDir: null,
172 fetchedAt: null,
173 trunkRemoteAhead: Number(s.trunkSync?.remoteAhead ?? 0),
174 behind: typeof w.behind === 'number' ? w.behind : null,
175 ahead: typeof w.ahead === 'number' ? w.ahead : null,
176 tree: String(w.state ?? 'clean'),
177 ownRemote:
178 own === null
179 ? null
180 : {
181 ref: own.ref ?? null,
182 state: String(own.state),
183 ahead: own.ahead ?? null,
184 behind: own.behind ?? null,
185 blocks: own.blocks ?? null,
186 },
187 upEligible: s.upEligible === true,
188 upIneligibleCode: s.upIneligibleCode ?? null,
189 upIneligibleReason: s.upIneligibleReason ?? null,
190 token: s.token ?? null,
191 stack: stack.map(member => String(member.work)).filter(name => name !== String(w.work)),
192 sessions: sessions.map(one => ({ name: String(one.name), kind: String(one.kind), state: String(one.state) })),
193 sessionsError: typeof s.sessionsError === 'string' ? s.sessionsError : null,
194 schemaVersion: String(s.schemaVersion ?? ''),
195 }
196}
197
198// The remote a ref lives on: "origin" of "origin/feat/login".
199const remoteOf = (ref: string): string => ref.split('/')[0] ?? ref
200
201// The branch against trunk in marks: ↓ what trunk has that it lacks, ↑ its own
202// on top, ✓ when it lacks nothing, ? when wt could not count.
203const trunkMarks = (p: Plan): string =>
204 p.behind === null ? '?' : `${p.behind > 0 ? `↓${p.behind}` : '✓'}${p.ahead !== null && p.ahead > 0 ? `↑${p.ahead}` : ''}`
205
206// The branch against its own remote in the same marks, then the remote's
207// name; undefined when there is nothing to say (in sync, or no ref of its own).
208const ownMarks = (p: Plan): string | undefined => {
209 const own = p.ownRemote
210
211 if (own === null || own.ref === null) {
212 return undefined
213 }
214
215 const remote = remoteOf(own.ref)
216
217 switch (own.state) {
218 case 'rebased':
219 return `↑ ${remote} (rebased)`
220 case 'ahead':
221 return `↑${own.ahead ?? ''} ${remote}`
222 case 'behind':
223 return `↓${own.behind ?? ''} ${remote}`
224 case 'diverged':
225 return `↓${own.behind ?? ''}↑${own.ahead ?? ''} ${remote} diverged`
226 case 'gone':
227 return `✗ ${remote}`
228 default:
229 return undefined
230 }
231}
232
233// The same in words, for the dialog.
234const ownWords = (p: Plan): string | undefined => {
235 const own = p.ownRemote
236
237 if (own === null || own.ref === null) {
238 return undefined
239 }
240
241 switch (own.state) {
242 case 'rebased':
243 return `rebased and not pushed to ${own.ref} yet`
244 case 'ahead':
245 return `${own.ahead} to push to ${own.ref}`
246 case 'behind':
247 return `${own.behind} behind ${own.ref}`
248 case 'diverged':
249 return `diverged from ${own.ref}: ${own.ahead} here, ${own.behind} there`
250 case 'gone':
251 return `${own.ref} is gone`
252 case 'unknown':
253 return `${own.ref} could not be checked`
254 default:
255 return undefined
256 }
257}
258
259// How old what is known about trunk is, once that is worth saying: nothing
260// here fetches, so a count against trunk is as of the last fetch anyone made.
261const staleWords = (p: Plan, now: number): string | undefined =>
262 p.fetchedAt !== null && now - p.fetchedAt > STALE_MS ? `fetched ${ago(now, p.fetchedAt)}` : undefined
263
264const ownBehind = (p: Plan): number => (p.ownRemote?.state === 'behind' ? (p.ownRemote.behind ?? 0) : 0)
265const behindTrunk = (p: Plan): number => p.behind ?? 0
266
267// Why `wt up` would not run on it as it stands, or undefined when it would.
268// Changes in the tree are not judged here: status counts untracked files and
269// the run refuses tracked changes only, so the run is the one to say.
270const obstacle = (p: Plan): string | undefined => {
271 if (!p.upEligible) {
272 return p.upIneligibleReason ?? p.upIneligibleCode ?? 'wt would refuse it'
273 }
274
275 const working = p.sessions.filter(one => one.state === 'busy').map(one => one.name)
276
277 return working.length > 0 ? `another session is working in it (${working.join(', ')})` : undefined
278}
279
280const hasWork = (p: Plan): boolean => behindTrunk(p) > 0 || ownBehind(p) > 0
281const canUp = (p: Plan): boolean => !p.isMain && p.token !== null && hasWork(p) && obstacle(p) === undefined
282
283// The worktree as the parts of one line: against trunk, against its own
284// remote, a rebase waiting, a run wt would refuse, how old the view of trunk
285// is. What wt up would do something about is in the accent colour, and only
286// while wt up would run; what is wrong is in the warning colour, and comes
287// before what is dim, so that a narrow terminal cuts the dim part first.
288const standing = (p: Plan, now: number): Part[] => {
289 const parts: Part[] = []
290
291 if (p.isMain) {
292 parts.push({ text: p.trunkRemoteAhead > 0 ? `main checkout ↓${p.trunkRemoteAhead} ${remoteOf(p.trunkRef)}` : 'main checkout', tone: undefined })
293 } else {
294 const own = ownMarks(p)
295 const state = p.ownRemote?.state
296 const isHandedOver = p.upIneligibleCode === 'handedOver'
297 const wouldRun = !isHandedOver && obstacle(p) === undefined
298 // Uncommitted changes, as a git prompt marks them, after the last mark.
299 const dirty = !isHandedOver && p.tree === 'dirty' ? ' *' : ''
300 parts.push({
301 text: `${p.work} ${trunkMarks(p)} ${p.trunk}${own === undefined ? dirty : ''}`,
302 tone: wouldRun && behindTrunk(p) > 0 ? 'suggestion' : undefined,
303 })
304
305 if (own !== undefined) {
306 parts.push({
307 text: `${own}${dirty}`,
308 tone: state === 'diverged' || state === 'gone' ? 'warning' : wouldRun && state === 'behind' ? 'suggestion' : undefined,
309 isOwn: true,
310 })
311 }
312
313 if (isHandedOver) {
314 parts.push({ text: 'REBASE STOPPED', tone: 'warning' })
315 } else if (!wouldRun && hasWork(p)) {
316 parts.push({ text: 'wt up would refuse', tone: 'warning' })
317 }
318 }
319
320 const stale = staleWords(p, now)
321
322 if (stale !== undefined) {
323 parts.push({ text: stale, tone: 'dim' })
324 }
325
326 return parts
327}
328
329// The same as text, for a command's answer.
330const statusText = (p: Plan | null, now: number): string | undefined =>
331 p === null
332 ? undefined
333 : standing(p, now)
334 .map(part => part.text)
335 .join(' · ')
336
337// The line above the prompt, or null outside a worktree of wt's: a run under
338// way, what the last run came to, or how the worktree stands.
339const lineOf = (p: Plan | null, last: Outcome | null, state: Busy, now: number): Part[] | null => {
340 if (state !== 'idle') {
341 return [{ text: RUNNING[state], tone: 'suggestion' }]
342 }
343
344 // An outcome is shown, and acted on, only for the worktree it was run on.
345 // It never hides what is wrong with the worktree as it stands now, and one
346 // that succeeded is followed by what is left to push.
347 if (last !== null && p !== null && last.path === p.path) {
348 return [
349 { text: last.brief, tone: last.isOk ? 'success' : 'error' },
350 ...standing(p, now).filter(part => part.tone === 'warning' || (last.isOk && part.isOwn === true)),
351 ]
352 }
353
354 if (p === null || p.isMain) {
355 return null
356 }
357
358 return standing(p, now)
359}
360
361// What the dialog shows for the worktree; `mine` is the last outcome when it
362// is this worktree's.
363const detailOf = (p: Plan, mine: Outcome | null): Detail => {
364 const notes: Note[] = []
365 const isHandedOver = p.upIneligibleCode === 'handedOver'
366 const own = ownWords(p)
367 const why = p.isMain || isHandedOver ? undefined : obstacle(p)
368
369 if (p.isMain && p.trunkRemoteAhead > 0) {
370 notes.push({ text: `local ${p.trunk} is ${p.trunkRemoteAhead} behind ${p.trunkRef}`, tone: 'warning' })
371 }
372
373 if (own !== undefined) {
374 const state = p.ownRemote?.state
375 notes.push({ text: own, tone: state === 'diverged' || state === 'gone' ? 'error' : state === 'behind' ? 'warning' : undefined })
376 }
377
378 if (!p.isMain && p.tree !== 'clean') {
379 notes.push({ text: p.tree === 'dirty' ? '* uncommitted changes (untracked files count)' : `the tree is ${p.tree}`, tone: 'warning' })
380 }
381
382 if (p.stack.length > 0) {
383 notes.push({ text: `moves with ${p.stack.join(', ')}`, tone: undefined })
384 }
385
386 if (p.sessionsError !== null) {
387 notes.push({ text: `sessions could not be listed: ${p.sessionsError}`, tone: 'warning' })
388 } else if (p.sessions.length > 0) {
389 notes.push({ text: `sessions: ${p.sessions.map(one => `${one.name} (${one.kind}, ${one.state})`).join(', ')}`, tone: undefined })
390 }
391
392 // Why there is no key for wt up, for someone who looked for it: said only
393 // when wt counted, and the count is none.
394 if (!p.isMain && !isHandedOver && p.behind === 0 && ownBehind(p) === 0 && mine === null && p.upIneligibleCode !== 'ownRemoteDiverged') {
395 notes.push({ text: 'not behind: wt up has nothing to do', tone: 'dim' })
396 }
397
398 if (isHandedOver) {
399 notes.push({ text: 'a rebase stopped at a conflict and is yours: resolve it in the worktree, then resume; or undo it', tone: 'warning' })
400 } else if (why !== undefined && (hasWork(p) || p.upIneligibleCode === 'ownRemoteDiverged')) {
401 notes.push({ text: `wt up would refuse: ${why}`, tone: 'warning' })
402 }
403
404 let primary: Action | null = null
405 let hint = ''
406 const follow: Action[] = []
407
408 if (isHandedOver) {
409 primary = 'resume'
410 hint = 'once the conflict is resolved'
411 follow.push('undo')
412 } else if (canUp(p)) {
413 primary = 'up'
414 hint = `fetches, then rebases onto ${p.trunkRef}; never pushes`
415 } else if (p.upIneligibleCode === 'ownRemoteDiverged' && p.token !== null) {
416 primary = 'upDiverged'
417 hint = 'rebases although the branch diverged from its own remote'
418 }
419
420 if (mine !== null) {
421 if (mine.pushCommand !== null) {
422 follow.push('push')
423 }
424
425 if (mine.canUndo && !follow.includes('undo')) {
426 follow.push('undo')
427 }
428
429 follow.push('dismiss')
430 }
431
432 return { notes, primary, hint, follow, other: knobs.hasGittree ? ['fetch', 'gittree', 'refresh'] : ['fetch', 'refresh'] }
433}
434
435const failed = (action: Outcome['action'], p: Plan, text: string, brief: string): Outcome => ({
436 action,
437 work: p.work,
438 path: p.path,
439 isOk: false,
440 text,
441 brief: `✗ ${brief}`,
442 pushCommand: null,
443 canUndo: false,
444})
445
446// The results that mean a participant is where the run meant to leave it.
447const SETTLED: readonly string[] = ['rebased', 'fastForwarded', 'skipped', 'undone']
448const RESULT_WORDS: Record<string, string> = {
449 handedOver: 'stopped at a conflict',
450 notRun: 'was not run',
451 refused: 'was refused',
452 restored: 'failed and was put back',
453 needsRecovery: 'needs recovery',
454 interrupted: 'was interrupted',
455 rebasedStepFailed: 'was rebased, but a step after it failed',
456}
457
458// One wt run's JSON (`wt up`, `wt sync resume`, `wt sync undo`) as the sentence
459// the dialog shows, the few words the line above the prompt shows, and what
460// can follow it. A run moves the whole stack, so another member that did not
461// settle makes the outcome a failure and takes the push away: the person
462// sorts the stack out first.
463const outcomeOf = (action: Outcome['action'], ran: Ran, p: Plan): Outcome => {
464 const result = parse(ran.stdout)
465
466 if (result === null) {
467 return failed(action, p, lastLine(ran.stderr) || `wt exited ${ran.exitCode} and printed no result`, `wt ${action} failed for ${p.work}`)
468 }
469
470 const list: Loose[] = Array.isArray(result.worktrees) ? result.worktrees : []
471 const mine = list.find(one => one.path === p.path) ?? list.find(one => one.work === p.work)
472 const error = typeof result.error === 'string' && result.error !== '' ? result.error : null
473 const reasonOf = (one: Loose): string => (typeof one.reason === 'string' && one.reason !== '' ? `: ${one.reason}` : '')
474
475 // A run wt refused before it touched anything lists its participants as
476 // not run and says why at the top.
477 if (mine === undefined || String(mine.result) === 'notRun') {
478 const why = error ?? (mine !== undefined && reasonOf(mine) !== '' ? reasonOf(mine).slice(2) : lastLine(ran.stderr))
479
480 return failed(action, p, `wt did not touch ${p.work}${why === '' ? '' : `: ${why}`}`, `wt did not touch ${p.work}`)
481 }
482
483 const unsettled = list.filter(one => one !== mine && !SETTLED.includes(String(one.result)))
484 const also =
485 unsettled.length === 0
486 ? ''
487 : ` But in its stack ${unsettled.map(one => `${one.work} ${RESULT_WORDS[String(one.result)] ?? one.result}${reasonOf(one)}`).join('; ')}.`
488 const range = `${short(mine.before)} → ${short(mine.after)}`
489 const sync: Loose | null = mine.ownRemoteSync ?? null
490 const forwarded = sync?.fastForwarded === true ? `, after a fast-forward to ${sync.ref}` : ''
491 const pushCommand = unsettled.length === 0 && Array.isArray(mine.pushCommand) ? mine.pushCommand.map(String) : null
492 const reason = reasonOf(mine)
493 const recovery = typeof mine.recovery === 'string' && mine.recovery !== '' ? ` ${mine.recovery}` : ''
494 const onto = String(result.trunkRef ?? p.trunkRef)
495 const done = (text: string, brief: string, canUndo: boolean): Outcome => ({
496 action,
497 work: p.work,
498 path: p.path,
499 isOk: unsettled.length === 0,
500 text: text + also,
501 brief: unsettled.length === 0 ? `✓ ${brief}` : `✗ ${brief}, but its stack did not settle`,
502 pushCommand,
503 canUndo,
504 })
505
506 switch (String(mine.result)) {
507 case 'rebased':
508 return done(`${p.work} rebased onto ${onto}${forwarded} (${range}). Undo puts it back.`, `${p.work} rebased onto ${onto}`, true)
509 case 'fastForwarded':
510 return done(
511 `${p.work} fast-forwarded to ${sync?.ref ?? 'its own remote'} (${range}); nothing was left to rebase.`,
512 `${p.work} fast-forwarded`,
513 true,
514 )
515 case 'undone':
516 return done(`${p.work} is back where it was (${range}).`, `${p.work} is back where it was`, false)
517 case 'skipped':
518 return done(`${p.work}: nothing to do${reason}`, `${p.work}: nothing to do`, false)
519 case 'handedOver':
520 return {
521 ...failed(
522 action,
523 p,
524 `${p.work}: the rebase stopped at a conflict and is yours now. Resolve it in the worktree, then resume; or undo it.${also}`,
525 `${p.work}: the rebase stopped at a conflict`,
526 ),
527 canUndo: true,
528 }
529 case 'refused':
530 return failed(action, p, `wt refused ${p.work}${reason}${also}`, `wt refused ${p.work}`)
531 case 'restored':
532 return failed(action, p, `${p.work}: the rebase failed and wt put the branch back${reason}.${also}`, `${p.work}: the rebase failed, the branch is back`)
533 case 'rebasedStepFailed':
534 case 'interrupted':
535 return {
536 ...failed(action, p, `${p.work} ${RESULT_WORDS[String(mine.result)]}${reason}.${recovery}${also}`, `${p.work} ${RESULT_WORDS[String(mine.result)]}`),
537 canUndo: true,
538 }
539 default:
540 return failed(action, p, `${p.work}: ${mine.result}${reason}.${recovery}${also}`, `${p.work}: ${mine.result}`)
541 }
542}
543
544let isChecking = false
545let wantsAnother = false
546// Whether a model turn runs: set by turn.start and turn.complete, and by the
547// band wherever it is drawn. wt moves nothing under one.
548let isTurnRunning = false
549// The move under way, if any: one at a time, and a prompt sent meanwhile
550// waits for it rather than start a turn on a branch in mid-rebase.
551let moving: Promise<void> | null = null
552// The fetch the person asked for, while it runs: a move waits for it.
553let fetching: Promise<void> | null = null
554
555// An argument as a shell reads it back unchanged.
556const quoted = (arg: string): string => (/^[A-Za-z0-9_/.:=@%+,-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`)
557
558// When the repository last fetched anything: the age of FETCH_HEAD in its git
559// directory, which every worktree of it shares. Read, never caused.
560const fetched = async ($: Engine, path: string): Promise<Pick<Plan, 'gitDir' | 'fetchedAt'>> => {
561 try {
562 const ran = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: path, timeoutMs: 5_000 })
563 const gitDir = ran.exitCode === 0 ? ran.stdout.trim() : ''
564
565 if (gitDir === '') {
566 return { gitDir: null, fetchedAt: null }
567 }
568
569 try {
570 return { gitDir, fetchedAt: (await $.fs.stat(`${gitDir}/FETCH_HEAD`)).mtimeMs }
571 } catch {
572 // Never fetched.
573 return { gitDir, fetchedAt: null }
574 }
575 } catch {
576 return { gitDir: null, fetchedAt: null }
577 }
578}
579
580// Reads the worktree again. One `wt status` at a time: a second ask while
581// one runs is served by one more run after it.
582const refresh = async ($: Engine): Promise<Plan | null> => {
583 if (isChecking) {
584 wantsAnother = true
585
586 return read($, plan)
587 }
588
589 isChecking = true
590
591 try {
592 // Read before the worktree is, so that what is read of the worktree is
593 // from after the run it came from.
594 const shown = await read($, outcome)
595 let found: Plan | null = null
596
597 try {
598 found = planOf((await $.process.run(['wt', 'status', '--json'], { timeoutMs: 20_000 })).stdout)
599 } catch {
600 // wt is not installed, or did not answer in time: nothing to show.
601 }
602
603 if (found !== null) {
604 found = { ...found, ...(await fetched($, found.path)) }
605 }
606
607 const held = await read($, plan)
608
609 if (JSON.stringify(held) !== JSON.stringify(found)) {
610 await update($, plan, () => found)
611 }
612
613 // A run that succeeded has nothing left to say once its branch is pushed.
614 if (
615 found !== null &&
616 shown !== null &&
617 shown.isOk &&
618 shown.pushCommand !== null &&
619 shown.path === found.path &&
620 found.ownRemote?.state === 'inSync' &&
621 JSON.stringify(await read($, outcome)) === JSON.stringify(shown)
622 ) {
623 await update($, outcome, () => null)
624 }
625
626 return found
627 } finally {
628 isChecking = false
629
630 if (wantsAnother) {
631 wantsAnother = false
632 refresh($).catch(() => undefined)
633 }
634 }
635}
636
637// The refs the worktree's standing rests on, as they stood at the last pulse.
638let pulse: string | null = null
639
640// Keeps what is shown current for someone coming back to this terminal from
641// another: every few seconds one cheap read of the branch, trunk and remote
642// refs, and a full `wt status` only when one of them has moved. A fetch, a
643// commit or a rebase made anywhere else shows here within one pulse.
644const beat = async ($: Engine): Promise<void> => {
645 const p = await read($, plan)
646
647 if (p === null || moving !== null || isChecking) {
648 return
649 }
650
651 const refs = [`refs/heads/${p.trunk}`, `refs/remotes/${p.trunkRef}`]
652
653 if (p.branch !== null) {
654 refs.push(`refs/heads/${p.branch}`)
655 }
656
657 if (p.ownRemote !== null && p.ownRemote.ref !== null) {
658 refs.push(`refs/remotes/${p.ownRemote.ref}`)
659 }
660
661 const ran = await $.process.run(['git', 'for-each-ref', '--format=%(refname) %(objectname)', ...refs], { cwd: p.path, timeoutMs: 5_000 })
662
663 if (ran.exitCode !== 0) {
664 return
665 }
666
667 // A fetch that moved no ref of ours still makes what is shown newer.
668 let fetchedAt = 0
669
670 if (p.gitDir !== null) {
671 try {
672 fetchedAt = (await $.fs.stat(`${p.gitDir}/FETCH_HEAD`)).mtimeMs
673 } catch {
674 // Never fetched.
675 }
676 }
677
678 const before = pulse
679 pulse = `${p.path}\n${fetchedAt}\n${ran.stdout}`
680
681 if (before !== null && before !== pulse) {
682 await refresh($)
683 }
684}
685
686// Tells the model what the person just did to the branch under it.
687const tell = async ($: Engine, text: string): Promise<void> => {
688 try {
689 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
690 } catch {
691 // A run no plugin may shape: the band still says it.
692 }
693}
694
695// The rows the dialog needs for what it has to show.
696const rowsOf = async ($: Engine): Promise<number> => {
697 const p = await read($, plan)
698 const last = await read($, outcome)
699
700 if (p === null) {
701 return 5
702 }
703
704 const mine = last !== null && last.path === p.path ? last : null
705 const detail = detailOf(p, mine)
706
707 return (
708 1 +
709 detail.notes.length +
710 (mine !== null ? 3 : 0) +
711 1 +
712 (detail.primary !== null ? 1 : 0) +
713 (detail.follow.length > 0 ? 1 : 0) +
714 1 +
715 (knobs.hasReview ? 3 : 0) +
716 2
717 )
718}
719
720// Opens the dialog with the keyboard in it, so its letter keys answer and Esc
721// closes it, as tall as what it has to show. With text in the prompt the keys
722// stay there; Esc closes it once the prompt is empty.
723const openPane = async ($: Engine): Promise<void> => {
724 await $.ui.open({ id: PANE, title: 'wt', focus: true, closeOnEscape: true, rows: await rowsOf($) })
725}
726
727// Whether the dialog is the pane on show. One open behind another pane, or
728// waiting undrawn, is not: asked for, that one is raised.
729const isOnShow = async ($: Engine): Promise<boolean> => (await $.ui.panes()).some(one => one.id === PANE && one.isShown && one.isPlaced)
730
731// Asks again for the rows an open dialog needs, now that a run has left a
732// result and its keys to show: the rows, and never the keyboard, so that a
733// letter typed at the prompt is not taken for one of its keys. Whether it is
734// still on show is asked last, so that one just closed stays closed.
735const resize = async ($: Engine): Promise<void> => {
736 try {
737 const rows = await rowsOf($)
738
739 if (await isOnShow($)) {
740 await $.ui.open({ id: PANE, title: 'wt', closeOnEscape: true, rows })
741 }
742 } catch {
743 // The dialog scrolls instead.
744 }
745}
746
747// Runs one action for the worktree. The moves leave an outcome to show.
748const act = async ($: Engine, action: Action): Promise<string> => {
749 if (action === 'details') {
750 // A click on the line toggles, as bare /wt does: on show, it closes.
751 if (await isOnShow($)) {
752 await $.ui.close({ id: PANE })
753
754 return 'wt pane closed.'
755 }
756
757 await openPane($)
758
759 return 'wt pane opened.'
760 }
761
762 if (action === 'close') {
763 await $.ui.close({ id: PANE })
764
765 return 'wt pane closed.'
766 }
767
768 const p = await read($, plan)
769 const last = await read($, outcome)
770
771 if (action === 'dismiss') {
772 // What the last run came to goes away; what is still to do comes back.
773 await update($, outcome, () => null)
774
775 return 'Dismissed.'
776 }
777
778 if (action === 'refresh') {
779 return statusText(await refresh($), await $.clock.now()) ?? 'No wt worktree here.'
780 }
781
782 if (action === 'gittree') {
783 // gittree shows the repository and worktree holding the path, launching
784 // the app or bringing it forward; outside a wt worktree, the session's own.
785 let text: string
786
787 try {
788 const ran = await $.process.run(['gittree', p?.path ?? '.'], { timeoutMs: 20_000 })
789 text = ran.exitCode === 0 ? `Opened ${p?.work ?? 'this folder'} in gittree.` : `gittree: ${lastLine(ran.stderr) || `exited ${ran.exitCode}`}`
790 } catch (error) {
791 text = `gittree did not start: ${error instanceof Error ? error.message : String(error)}`
792 }
793
794 $.ui.toast(text)
795
796 return text
797 }
798
799 if (p === null) {
800 return 'No wt worktree here.'
801 }
802
803 if (action === 'fetch') {
804 // wt up fetches too, and two fetches at once clash over a ref's lock.
805 if (moving !== null) {
806 return 'wt is already running here.'
807 }
808
809 if (fetching !== null) {
810 return 'A fetch is already running here.'
811 }
812
813 let text: string
814 let release = (): void => undefined
815
816 fetching = new Promise<void>(resolve => {
817 release = resolve
818 })
819
820 try {
821 const ran = await $.process.run(['git', 'fetch', '--quiet', 'origin', p.trunk], { cwd: p.path, timeoutMs: 120_000 })
822 text = ran.exitCode === 0 ? `Fetched ${p.trunkRef}.` : `The fetch failed: ${lastLine(ran.stderr) || `git exited ${ran.exitCode}`}`
823 } catch (error) {
824 text = `The fetch did not finish: ${error instanceof Error ? error.message : String(error)}`
825 } finally {
826 fetching = null
827 release()
828 }
829
830 await refresh($)
831 $.ui.toast(text)
832
833 return text
834 }
835
836 if (action === 'push') {
837 // The push is the person's to run: it goes in the prompt as a shell
838 // line, so they see it, press Enter, and the repository's git hooks run.
839 if (last === null || last.pushCommand === null || last.path !== p.path) {
840 return 'Nothing to push from here: run wt up first.'
841 }
842
843 const line = `! ${last.pushCommand.map(quoted).join(' ')}`
844 let isFilled = false
845
846 // The prompt takes nothing while a dialog holds the keys, and the push is
847 // run from the prompt: the dialog closes first.
848 try {
849 await $.ui.close({ id: PANE })
850 isFilled = (await $.prompt.fill({ text: line })).isFilled
851 } catch {
852 // Said below, with the command.
853 }
854
855 const text = isFilled ? 'The push is in the prompt: press Enter to run it.' : `The prompt did not take the push. Run it yourself: ${line}`
856 $.ui.toast(text)
857
858 return text
859 }
860
861 if (moving !== null) {
862 return 'wt is already running here.'
863 }
864
865 if (isTurnRunning) {
866 const text = `${LABEL[action]}: a turn is running in ${p.work}. wt can move it once the turn is done.`
867 $.ui.toast(text)
868
869 return text
870 }
871
872 let argv: string[]
873
874 switch (action) {
875 case 'up':
876 case 'upDiverged':
877 if (p.token === null) {
878 return `wt up would refuse ${p.work}: ${obstacle(p) ?? 'it has no plan'}`
879 }
880
881 argv = ['wt', 'up', '--yes', '--no-push', '--json', '--expect', p.token]
882
883 if (action === 'upDiverged') {
884 argv.push('--allow-diverged')
885 }
886
887 break
888 case 'undo':
889 // "." is the worktree the command runs in: a bare work name can be two.
890 argv = ['wt', 'sync', 'undo', '.', '--yes', '--json']
891 break
892 case 'resume':
893 argv = ['wt', 'sync', 'resume', '.', '--yes', '--no-push', '--json']
894 break
895 }
896
897 const kind: Outcome['action'] = action === 'upDiverged' ? 'up' : action
898 let release = (): void => undefined
899 let next: Outcome
900
901 // Taken before the first await, so a second press finds it.
902 moving = new Promise<void>(resolve => {
903 release = resolve
904 })
905
906 try {
907 await update($, busy, () => kind)
908
909 if (fetching !== null) {
910 await fetching
911 }
912
913 try {
914 next = outcomeOf(kind, await $.process.run(argv, { cwd: p.path, timeoutMs: TEN_MINUTES }), p)
915 } catch (error) {
916 next = failed(
917 kind,
918 p,
919 `${argv.slice(0, 3).join(' ')} did not finish: ${error instanceof Error ? error.message : String(error)}`,
920 `${argv.slice(0, 3).join(' ')} did not finish`,
921 )
922 }
923
924 await update($, outcome, () => next)
925 } finally {
926 await update($, busy, () => 'idle').catch(() => undefined)
927 moving = null
928 release()
929 }
930
931 await refresh($)
932 await resize($)
933 await tell(
934 $,
935 `[wt plugin] The person ran ${argv.slice(0, 3).join(' ')} on the worktree ${p.work} (${p.branch ?? 'detached'}). ${next.text}` +
936 (next.isOk ? ' Its files and commits may have changed: read again before editing.' : ''),
937 )
938
939 return next.text
940}
941
942export const register: Register = (on, options) => {
943 knobs.hasGittree = options.gittree === true
944 knobs.hasReview = options.reviewStatus === true
945 knobs.inputAfterMs = (typeof options.inputAfterMinutes === 'number' && options.inputAfterMinutes >= 0 ? options.inputAfterMinutes : 5) * 60_000
946 knobs.reviewAfterMs = (typeof options.reviewAfterMinutes === 'number' && options.reviewAfterMinutes >= 0 ? options.reviewAfterMinutes : 30) * 60_000
947
948 try {
949 knobs.reviewSkills = new RegExp(typeof options.reviewSkills === 'string' && options.reviewSkills !== '' ? options.reviewSkills : REVIEW_SKILLS, 'i')
950 } catch {
951 knobs.reviewSkills = new RegExp(REVIEW_SKILLS, 'i')
952 }
953
954 on('session.start', async ($, e, next) => {
955 await $.command.register({
956 name: 'wt',
957 description: 'The worktree you are in: its place against trunk, and wt up, undo, push from here',
958 argumentHint: knobs.hasGittree
959 ? '[up|undo|push|resume|fetch|refresh|dismiss|close|gittree]'
960 : '[up|undo|push|resume|fetch|refresh|dismiss|close]',
961 })
962 // A reload ends whatever this module was waiting on: nothing runs now.
963 await update($, busy, () => 'idle')
964 // Nothing of this mod's is pinned under the prompt; an older load's goes.
965 $.ui.status(undefined)
966 refresh($).catch(() => undefined)
967 // What is shown counts time: a minute later it says a minute more.
968 $.clock.every(60_000, () => {
969 $.ui.invalidate('ui.render')
970 })
971 $.clock.every(PULSE_MS, () => {
972 beat($).catch(() => undefined)
973 })
974 // What no ref shows (a file edited from outside, a session come or gone)
975 // is caught by the full read.
976 $.clock.every(180_000, () => {
977 refresh($).catch(() => undefined)
978 })
979
980 return next(e)
981 })
982
983 // Whatever a turn did to the checkout shows once it is done, and after each
984 // shell command that could have moved a branch.
985 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
986 const ran = await next(e)
987
988 if (/\b(git|wt|gh)\b/.test(e.command)) {
989 refresh($).catch(() => undefined)
990 }
991
992 return ran
993 }).catch(($, e, next) => next(e))
994
995 on('turn.start', ($, e, next) => {
996 isTurnRunning = true
997
998 return next(e)
999 })
1000
1001 on('turn.complete', ($, e, next) => {
1002 isTurnRunning = false
1003 refresh($).catch(() => undefined)
1004
1005 return next(e)
1006 })
1007
1008 // A new conversation starts with no review and no input behind it.
1009 on('session.end', async ($, e, next) => {
1010 if (e.reason === 'clear') {
1011 await update($, inputs, () => 0)
1012 await update($, lastInput, () => null)
1013 await update($, lastReview, () => null)
1014 await update($, firstInput, () => null)
1015 }
1016
1017 return next(e)
1018 })
1019
1020 // The review knob: the person's own inputs, counted and remembered by how
1021 // they began, and each review skill as it is expanded for the model, typed
1022 // as /name or called through the Skill tool alike.
1023 on('prompt.submit', async ($, e, next) => {
1024 // A prompt sent while wt moves the branch waits for the move to end.
1025 if (moving !== null) {
1026 await moving
1027 }
1028
1029 // The person's own: typed here, sent from a phone, or typed in an app
1030 // that hosts the session.
1031 const isOwn = e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk'
1032
1033 // A run that succeeded and left nothing to push has been seen once the
1034 // person types on: the line goes back to how the worktree stands.
1035 if (isOwn && !e.text.trimStart().startsWith('/')) {
1036 const last = await read($, outcome)
1037
1038 if (last !== null && last.isOk && last.pushCommand === null) {
1039 await update($, outcome, () => null)
1040 }
1041 }
1042
1043 if (knobs.hasReview && isOwn) {
1044 const letters = Array.from(e.text.replace(/\s+/g, ' ').trim())
1045 const now = await $.clock.now()
1046 const typed: Input = { at: now, head: letters.length > 20 ? `${letters.slice(0, 20).join('')}…` : letters.join('') }
1047 const count = (await read($, inputs)) + 1
1048 await update($, inputs, () => count)
1049 await update($, lastInput, () => typed)
1050
1051 if ((await read($, firstInput)) === null) {
1052 await update($, firstInput, () => now)
1053 }
1054
1055 // A review skill expanded just before its own /name arrived here belongs
1056 // to this input, not to the one before it.
1057 const review = await read($, lastReview)
1058
1059 if (review !== null && now - review.at < 5_000 && e.text.trimStart().startsWith(`/${review.skill}`)) {
1060 await update($, lastReview, () => ({ ...review, inputs: count }))
1061 }
1062 }
1063
1064 return next(e)
1065 }).catch(($, e, next) => next(e))
1066
1067 on('skill.prompt', async ($, e, next) => {
1068 if (knobs.hasReview && knobs.reviewSkills.test(e.skill)) {
1069 const ran: Review = { skill: e.skill, at: await $.clock.now(), inputs: await read($, inputs) }
1070 await update($, lastReview, () => ran)
1071 }
1072
1073 return next(e)
1074 })
1075
1076 on('command.run', { command: 'wt' }, async ($, e) => {
1077 const word = e.args.trim().split(/\s+/)[0] ?? ''
1078 const known: readonly Action[] = knobs.hasGittree
1079 ? ['up', 'undo', 'push', 'resume', 'fetch', 'refresh', 'dismiss', 'close', 'gittree']
1080 : ['up', 'undo', 'push', 'resume', 'fetch', 'refresh', 'dismiss', 'close']
1081 const action = known.find(one => one === word)
1082
1083 if (action !== undefined) {
1084 return { text: await act($, action) }
1085 }
1086
1087 if (word !== '') {
1088 return { text: `/wt ${word}: not one of ${known.join(', ')}.` }
1089 }
1090
1091 // Bare, it toggles: the dialog on show is the dialog closed.
1092 if (await isOnShow($)) {
1093 return { text: await act($, 'close') }
1094 }
1095
1096 const p = await refresh($)
1097 await openPane($)
1098
1099 return { text: statusText(p, await $.clock.now()) ?? 'No wt worktree here.' }
1100 })
1101
1102 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1103 isTurnRunning = e.props.isWorking
1104
1105 if (e.props.hasSurvey) {
1106 return next(e)
1107 }
1108
1109 const now = await $.clock.now()
1110 const line = lineOf(await read($, plan), await read($, outcome), await read($, busy), now)
1111 const linger = knobs.hasReview
1112 ? lingerOf(await read($, lastInput), await read($, lastReview), await read($, firstInput), await read($, inputs), now)
1113 : ''
1114
1115 // The line takes no keys: the only ones that would answer from the prompt
1116 // are digits, and a digit typed into an empty prompt is the person's to
1117 // type. It names the command instead, and a click on that toggles it.
1118 //
1119 // Outside a worktree of wt's, with nothing old to recall and no gittree,
1120 // nothing is drawn.
1121 if (line === null && linger === '' && !knobs.hasGittree) {
1122 return next(e)
1123 }
1124
1125 const { Box, Button, Text } = $.ui.resolve(e)
1126
1127 return (
1128 <Box gap={1}>
1129 {line !== null && <Button key="details" plain label={LABEL.details} onPress={() => act($, 'details')} />}
1130 <Text wrap="truncate-end">
1131 {line !== null &&
1132 line.map((part, at) => [
1133 at === 0 ? '· ' : ' · ',
1134 <Text color={part.tone === 'dim' ? undefined : part.tone} dimColor={part.tone === 'dim'}>
1135 {part.text}
1136 </Text>,
1137 ])}
1138 {linger !== '' && line !== null && ' · '}
1139 {linger !== '' && <Text dimColor>{linger}</Text>}
1140 </Text>
1141 <Box flexGrow={1} />
1142 {knobs.hasGittree && <Button key="gittree" plain label={GITTREE_OUT} onPress={() => act($, 'gittree')} />}
1143 </Box>
1144 )
1145 })
1146
1147 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1148 const { Box, Button, Text } = $.ui.resolve(e)
1149 const p = await read($, plan)
1150 const last = await read($, outcome)
1151 const state = await read($, busy)
1152 const keyFor = (one: Action) => <Button key={one} plain label={LABEL[one]} hotkey={HOTKEY[one]} onPress={() => act($, one)} />
1153 const footer = (
1154 <Box gap={2} marginTop={1}>
1155 <Button key="close" plain dimColor role="dismiss" label={LABEL.close} hotkey={HOTKEY.close} onPress={() => act($, 'close')} />
1156 <Text dimColor>{e.props.isFocused ? CLOSING : KEYLESS}</Text>
1157 </Box>
1158 )
1159
1160 if (p === null) {
1161 return (
1162 <Box flexDirection="column">
1163 <Text dimColor wrap="wrap">
1164 No wt worktree here: this folder is not in a repository wt knows, or wt is not installed.
1165 </Text>
1166 <Box gap={3} marginTop={1}>
1167 {keyFor('refresh')}
1168 {knobs.hasGittree && keyFor('gittree')}
1169 </Box>
1170 {footer}
1171 </Box>
1172 )
1173 }
1174
1175 const now = await $.clock.now()
1176 // The last outcome, when it is this worktree's.
1177 const mine = last !== null && last.path === p.path ? last : null
1178 const detail = detailOf(p, mine)
1179 const review = knobs.hasReview ? await read($, lastReview) : null
1180 const typed = knobs.hasReview ? await read($, lastInput) : null
1181 const count = knobs.hasReview ? await read($, inputs) : 0
1182
1183 return (
1184 <Box flexDirection="column">
1185 <Box gap={2}>
1186 <Text bold>{p.work}</Text>
1187 {p.isMain ? (
1188 <Text dimColor>main checkout</Text>
1189 ) : (
1190 <Text color={p.behind === null || p.behind > 0 ? 'warning' : 'success'}>{`${trunkMarks(p)} ${p.trunk}`}</Text>
1191 )}
1192 <Text dimColor>{p.fetchedAt === null ? 'fetch time unknown' : `fetched ${ago(now, p.fetchedAt)}`}</Text>
1193 </Box>
1194 {detail.notes.map(note => (
1195 <Text color={note.tone === 'dim' ? undefined : note.tone} dimColor={note.tone === 'dim'} wrap="wrap">
1196 {note.text}
1197 </Text>
1198 ))}
1199 {state !== 'idle' && (
1200 <Box marginTop={1}>types/index.d.ts 90 lines1/** A branch against the ref a push of it would replace, as `wt status --json` reports it. */
2export type OwnRemote = {
3 ref: string | null
4 /** none | gone | unknown | inSync | ahead | behind | rebased | diverged */
5 state: string
6 ahead: number | null
7 behind: number | null
8 /** null | diverged | operation | dirty | session: why a run would refuse. */
9 blocks: string | null
10}
11
12export type WtSession = { name: string; kind: string; state: string }
13
14/** The worktree the session stands in, digested from `wt status --json`. */
15export type Plan = {
16 work: string
17 branch: string | null
18 path: string
19 isMain: boolean
20 trunk: string
21 trunkRef: string
22 /** When the trunk ref last moved here (ISO), or null: status does not fetch. */
23 trunkFetchedAt: string | null
24 /** The repository's git directory, absolute; null when it could not be read. */
25 gitDir: string | null
26 /** When the repository last fetched anything (ms since the epoch), or null. */
27 fetchedAt: number | null
28 /** Commits origin's trunk has that local trunk lacks. */
29 trunkRemoteAhead: number
30 /** null when wt could not count them. */
31 behind: number | null
32 ahead: number | null
33 /** clean | dirty | unreadable */
34 tree: string
35 /** null from a wt older than status 1.6.0. */
36 ownRemote: OwnRemote | null
37 upEligible: boolean
38 upIneligibleCode: string | null
39 upIneligibleReason: string | null
40 /** Passed to `wt up --expect`. */
41 token: string | null
42 /** The other worktrees a run on this one would move, parents first. */
43 stack: string[]
44 /** Other agent sessions in the worktree. */
45 sessions: WtSession[]
46 /** Why the sessions could not be listed, when they could not. */
47 sessionsError: string | null
48 schemaVersion: string
49}
50
51/** What the last action from the dialog or `/wt` came to. */
52export type Outcome = {
53 action: 'up' | 'undo' | 'resume'
54 /** The worktree it was run on: shown and acted on for that one only. */
55 work: string
56 path: string
57 isOk: boolean
58 /** The whole of it, for the dialog. */
59 text: string
60 /** A few words led by ✓ or ✗, for the line above the prompt. */
61 brief: string
62 /** The lease-protected push wt printed for the rebased branch, as argv. */
63 pushCommand: string[] | null
64 canUndo: boolean
65}
66
67export type Busy = 'idle' | 'up' | 'undo' | 'resume'
68
69/** The person's last own input: when, and how it began (cut with an ellipsis). */
70export type Input = { at: number; head: string }
71
72/** The last review skill this conversation ran, and the input count then. */
73export type Review = { skill: string; at: number; inputs: number }
74
75declare module 'claude-code' {
76 interface PluginState {
77 wt: {
78 plan: Plan | null
79 busy: Busy
80 outcome: Outcome | null
81 /** The person's own inputs so far in this conversation. */
82 inputs: number
83 lastInput: Input | null
84 lastReview: Review | null
85 /** When the person's first own input of this conversation was. */
86 firstInput: number | null
87 }
88 }
89}
90