diff --git a/README.md b/README.md index fdaea3a..6d52656 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,19 @@ `wt` opens an interactive table of the current repo's worktrees and `cd`s into the one you pick. -``` - front · 7 worktrees ⏎ actions - ❯ cal 2/7 - - BRANCH WORKTREE CHANGES MASTER PR AGE - copilot-calendar-timezones copilot-calendar-timezones +2 ↑10 #68959 open 9m ◆ claude - copilot-teammate-calendars copilot-teammate-calendars · ↑7 ↓74 #68772 merged 3d -``` - ## Install -Copy this into a terminal (macOS or Linux, with bash or zsh, git and [Node.js](https://nodejs.org) 18 or newer): +Copy this into a terminal: ```sh curl -fsSL https://raw.githubusercontent.com/frontapp/front-wt/main/install.sh | bash ``` -Then open a new terminal (or `source ~/.zshrc`) and run `wt`. +Then open a new terminal and run `wt`. -The installer downloads the [latest release](https://github.com/frontapp/front-wt/releases/latest) of `wt`, a single file with no dependencies, to `~/.local/bin/wt`, then runs `wt install --yes`, which adds a marked `# >>> wt >>>` block to your shell's startup file (`~/.zshrc`; `~/.bashrc`, or `~/.bash_profile` on macOS). The block defines the `wt` shell function and its tab completion: a program can't change the directory of the shell that runs it, so `wt` has to be a function that runs the picker and `cd`s into the worktree you pick. It calls the script by its full path, so `~/.local/bin` needn't be on your `PATH`. Optional: [`gh`](https://cli.github.com), signed in, for the PR column and opening things on GitHub. +It needs git, [Node.js](https://nodejs.org) 18 or newer, and bash or zsh, on macOS or Linux (on Windows, in WSL). Optional: [`gh`](https://cli.github.com), signed in, for the PR column and opening things on GitHub. + +The installer downloads the [latest release](https://github.com/frontapp/front-wt/releases/latest) of `wt`, a single file with no dependencies, to `~/.local/bin/wt`, then runs `wt install --yes`, which adds a marked `# >>> wt >>>` block to your shell's startup file (`~/.bashrc`, `~/.bash_profile` or `~/.zshrc`, depending on your shell and system). The block defines the `wt` shell function and its tab completion: a program can't change the directory of the shell that runs it, so `wt` has to be a function that runs the picker and `cd`s into the worktree you pick. It calls the script by its full path, so `~/.local/bin` needn't be on your `PATH`. **Updates.** Once a day, a launch of `wt` looks at the latest release in the background and, when it's newer, downloads it over `~/.local/bin/wt`; the next launch runs it and says so in the header. `wt update` does it now; `WT_NO_UPDATE=1` turns the background check off. @@ -29,6 +22,15 @@ The installer downloads the [latest release](https://github.com/frontapp/front-w ## Usage +``` + myapp · 4 worktrees ⏎ actions + ❯ cal 2/4 + + BRANCH WORKTREE CHANGES MAIN PR AGE + calendar-timezones calendar-timezones +2 ↑10 #1259 open 9m ◆ claude + teammate-calendars teammate-calendars · ↑7 ↓74 #1182 merged 3d +``` + ``` wt open the picker wt cal open it pre-filtered, or jump straight there if only one worktree matches @@ -52,29 +54,38 @@ The actions menu (↑ ↓ and ⏎, or press the action's key; esc goes back). It |---|---| | ⏎ | Go to worktree (cd into it) | | g | Open PR on GitHub (or the branch, if there's no PR) | -| r | Reveal in Finder (macOS) or open in the file manager (`xdg-open`) | +| r | Reveal in the file manager (Finder, or `xdg-open`) | | e | Open in `$EDITOR` (named in the menu, e.g. "Open in VS Code") | | d | Delete worktree… | -**Delete** removes the worktree and deletes its local branch, after a confirmation menu (`y` Delete, `n` Cancel; it opens on Cancel, esc goes back) that says what you'd lose: uncommitted changes, commits that aren't pushed anywhere, and whether the branch is merged. Squash-merged branches count as merged when their PR is merged and its head is the branch tip. It runs in the background (big worktrees take a few seconds), and if you were in that worktree, `wt` takes you to the main one. It's not offered for the main worktree, and is blocked while Claude Code is running in the worktree. +**Delete** removes the worktree and deletes its local branch, after a confirmation menu (`y` Delete, `n` Cancel; it opens on Cancel, esc goes back) that says what you'd lose: uncommitted changes, commits that aren't pushed anywhere, and whether the branch is merged. Squash-merged branches count as merged when their PR is merged and its head is the branch tip. It runs in the background (big worktrees take a few seconds), and if you were in that worktree, `wt` takes you to the main one. It's not offered for the main worktree, and is blocked while a coding agent is running in the worktree. The filter supports readline editing: ⌃a ⌃e, ⌃b ⌃f, ⌥b ⌥f, ⌃w, ⌥d, ⌃k, ⌃u, ⌃y, ⌃t, ⌃d. -Columns: `●` is the worktree you're in, CHANGES is `~modified +untracked`, MASTER is commits ahead ↑ / behind ↓ `origin/master` (`✓` instead of ↑ once the branch's PR is merged, since squash-merged commits never show up in master's history), PR is the branch's latest pull request (open, draft, merged or closed; `·` for none), and `◆ claude` marks a worktree with a running Claude Code session. +Columns: `●` is the worktree you're in, CHANGES is `~modified +untracked`, the column named after the base branch (MAIN, MASTER…) is commits ahead ↑ / behind ↓ origin's default branch (`✓` instead of ↑ once the branch's PR is merged, since squash-merged commits never show up in the base branch's history), PR is the branch's latest pull request (open, draft, merged or closed; `·` for none), and the last column names the coding agent at work in the worktree. PRs come from one GitHub API call per launch (about a second), made after the picker opens. They're cached in `~/.cache/wt/prs.json`, so the column shows the last known state right away and refreshes in place. +### Coding agents + +Claude Code, Codex and Cursor make worktrees of their own to work in. `wt` knows where each keeps them, and tells a worktree an agent is running in from one it made and left: + +- `◆ claude`, `◆ codex`, `◆ cursor`: the agent is running in it, so it can't be deleted from `wt`. Claude Code locks its worktree while it runs; for the others, `wt` looks at the agents' processes after the picker opens. +- `◇ codex`, `◇ claude`, `◇ cursor`: the agent made it, and isn't running in it now. + +Claude Code's worktrees live in the repo, under `.claude/worktrees/`, on branches prefixed `worktree-`; the prefix is dropped from what you type to reach them. Codex's live outside, in `~/.codex/worktrees//` (or the root set in its settings), with no branch of their own: `wt` names them `codex/`. Cursor's live in `~/.cursor/worktrees`. + ## Develop `wt` is one file, [`src/wt.js`](src/wt.js): Node's core modules and git only, no dependencies, and startup must stay under 100ms. Keep it that way. ```sh -nvm use # Node 22, from .nvmrc; any Node 18+ runs it -pnpm install # oxlint, the only dev dependency -WT_SHELL=zsh node src/wt.js # the picker, from the source (prints the chosen path instead of cd-ing) -pnpm test # node --test, on the pure parts the script exports -pnpm lint # oxlint -pnpm build && pnpm smoke # dist/wt, with the version from package.json, then run as the release ships it +nvm use # Node 22, from .nvmrc; any Node 18+ runs it +pnpm install # oxlint, the only dev dependency +WT_SHELL=1 node src/wt.js # the picker, from the source: prints the chosen path instead of cd-ing +pnpm test # node --test, on the pure parts the script exports +pnpm lint # oxlint +pnpm build && pnpm smoke # dist/wt, with the version from package.json, then run as the release ships it ``` To use your checkout as your `wt`: `node src/wt.js install` points the shell function at it. A checkout reports version `0.0.0-dev` and never updates itself. diff --git a/package.json b/package.json index 92b5236..7a9d7ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@frontapp/front-wt", - "version": "0.1.1", + "version": "0.2.0", "description": "Switch between the git worktrees of the current repo", "repository": { "type": "git", diff --git a/src/wt.js b/src/wt.js index d0d1657..e5f6476 100644 --- a/src/wt.js +++ b/src/wt.js @@ -41,6 +41,16 @@ const EDITOR_NAMES = { subl: 'Sublime Text', mate: 'TextMate', idea: 'IntelliJ IDEA', webstorm: 'WebStorm', vi: 'vi', vim: 'Vim', nvim: 'Neovim', emacs: 'Emacs', hx: 'Helix', helix: 'Helix', }; +// Coding agents that make worktrees to work in: where each keeps them, and the names of its processes, to tell the +// worktree it's running in by their working directory. Claude Code also locks its worktree while it runs, naming its +// pid. Claude Code's worktrees live in the repo, Codex's and Cursor's in the home directory. +const AGENTS = { + claude: { name: 'Claude Code', procs: ['claude'], roots: (repo) => [path.join(repo, '.claude', 'worktrees')] }, + codex: { name: 'Codex', procs: ['codex'], roots: () => [codexWorktreeRoot()] }, + cursor: { name: 'Cursor', procs: ['cursor-agent'], roots: () => [path.join(os.homedir(), '.cursor', 'worktrees')] }, +}; +const CODEX_MARKER = 'codex-thread.json'; // in the git directory of each worktree Codex made + const CACHE_DIR = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'wt'); const PR_CACHE = path.join(CACHE_DIR, 'prs.json'); const UPDATE_STATE = path.join(CACHE_DIR, 'update.json'); @@ -90,6 +100,7 @@ function readWorktrees() { const items = raw.filter((w) => !('bare' in w)).map((w, i) => { const branch = w.branch ? w.branch.replace(/^refs\/heads\//, '') : null; const prefixLen = branch && branch.startsWith(CLAUDE_PREFIX) ? CLAUDE_PREFIX.length : 0; + const agent = i === 0 ? null : agentOf(w.path, raw[0].path, gitDirOf(w.path)); const it = { path: w.path, sha: w.HEAD, @@ -98,9 +109,11 @@ function readWorktrees() { key: branch ? branch.slice(prefixLen) : null, prefixLen, isMain: i === 0, - name: i === 0 ? '(main)' : path.basename(w.path), + // Codex names every worktree after the repo: the bucket it's in tells them apart. + name: i === 0 ? '(main)' : agent === 'codex' ? `codex/${path.basename(path.dirname(w.path))}` : path.basename(w.path), locked: 'locked' in w ? w.locked || 'no reason given' : null, - claude: 'locked' in w && /claude/.test(w.locked) && isAlive(w.locked), + agent, // the agent that made it + busy: 'locked' in w && /claude/.test(w.locked) && isAlive(w.locked) ? 'claude' : null, // the agent running in it order: i, }; const p = realpath(w.path); @@ -121,6 +134,99 @@ function isAlive(lockReason) { try { process.kill(Number(m[1]), 0); return true; } catch (e) { return e.code === 'EPERM'; } } +// Where a linked worktree's git directory is, from its `.git` file; null for the main worktree, whose `.git` is a directory. +function gitDirOf(wtPath) { + const m = /^gitdir: (.+)$/m.exec(readText(path.join(wtPath, '.git'))); + return m ? path.resolve(wtPath, m[1].trim()) : null; +} + +// $CODEX_HOME/worktrees, unless Codex's config.toml sets desktop.git-worktree-root (under [desktop], or dotted). +function codexWorktreeRoot(env = process.env) { + const home = env.CODEX_HOME || path.join(os.homedir(), '.codex'); + const m = /^\s*(?:desktop\.)?git-worktree-root\s*=\s*"([^"]+)"/m.exec(readText(path.join(home, 'config.toml'))); + return m ? m[1].replace(/^~(?=\/|$)/, os.homedir()) : path.join(home, 'worktrees'); +} + +// Which agent made a worktree: Codex marks its own in their git directory; otherwise the directory it's in says. +function agentOf(wtPath, repoRoot, gitDir) { + if (gitDir && fs.existsSync(path.join(gitDir, CODEX_MARKER))) return 'codex'; + const p = realpath(wtPath); + for (const [key, agent] of Object.entries(AGENTS)) { + if (agent.roots(repoRoot).some((root) => root && p.startsWith(realpath(root) + '/'))) return key; + } + return null; +} + +// The deepest of `paths` holding `cwd`, as an index, or -1: a worktree can sit inside another, as Claude Code's do. +function worktreeHolding(paths, cwd) { + let best = -1; + paths.forEach((p, i) => { + if ((cwd === p || cwd.startsWith(p + '/')) && (best < 0 || p.length > paths[best].length)) best = i; + }); + return best; +} + +// `ps -axo pid=,comm=` lines, as [pid, process name]: the name can be a full path. +function parsePs(text) { + return text.split('\n').map((l) => /^\s*(\d+)\s+(.+?)\s*$/.exec(l)).filter(Boolean).map((m) => [m[1], path.basename(m[2])]); +} + +// `lsof -F pn` output, as [pid, path]: a `p` line starts each process, `n` lines follow with its files. +function parseLsof(text) { + const out = []; + let pid = null; + for (const line of text.split('\n')) { + if (line[0] === 'p') pid = line.slice(1); + else if (line[0] === 'n' && pid) out.push([pid, line.slice(1)]); + } + return out; +} + +// Which worktree each coding agent is running in, from its processes' working directories: /proc on Linux, ps and +// lsof on macOS. After the first render, like the statuses; Claude Code's lock already says so without it. +function loadAgents(items, children, onUpdate) { + const byProc = new Map(Object.entries(AGENTS).flatMap(([key, a]) => a.procs.map((p) => [p, key]))); + const paths = items.map((it) => realpath(it.path)); + const mark = (found) => { // [[agent, cwd]] + let changed = false; + for (const [key, cwd] of found) { + const it = items[worktreeHolding(paths, realpath(cwd))]; + if (it && it.busy !== key) { it.busy = key; changed = true; } + } + if (changed) onUpdate(); + }; + + if (process.platform === 'linux') { + const found = []; + for (const pid of fs.readdirSync('/proc')) { + if (!/^\d+$/.test(pid)) continue; + const key = byProc.get(readText(`/proc/${pid}/comm`).trim()); + if (!key) continue; + try { found.push([key, fs.readlinkSync(`/proc/${pid}/cwd`)]); } catch {} // another user's: not ours to see + } + return mark(found); + } + if (process.platform !== 'darwin') return; + const lsof = ['/usr/sbin/lsof', '/usr/bin/lsof'].find(isExecutable) ?? (onPath('lsof') ? 'lsof' : null); + if (!lsof) return; + // ps names the processes; lsof gives their working directories (its own names are the binaries', which can differ). + const collect = (cmd, args, cb) => { + const child = cp.spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] }); + let buf = ''; + child.stdout.setEncoding('utf8').on('data', (d) => (buf += d)); + child.on('error', () => {}); + child.on('close', () => cb(buf)); + children.push(child); + }; + collect('ps', ['-axo', 'pid=,comm='], (out) => { + const agents = new Map(parsePs(out).filter(([, comm]) => byProc.has(comm)).map(([pid, comm]) => [pid, byProc.get(comm)])); + if (!agents.size) return; + collect(lsof, ['-a', '-d', 'cwd', '-p', [...agents.keys()].join(','), '-F', 'pn'], (out2) => { + mark(parseLsof(out2).map(([pid, cwd]) => [agents.get(pid), cwd]).filter(([key]) => key)); + }); + }); +} + // The branch we compare against: origin's default branch, else the main worktree's branch. function baseRef(root, items) { for (const dir of [path.join(root, '.git'), root]) { @@ -280,6 +386,7 @@ function deletionRisks(it, root, base, prPending) { } else if (it.ahead === 0) risks.push(['✓', `merged into ${into}`, S.green]); if (it.locked && !/claude/.test(it.locked)) risks.push(['!', `it's locked: ${it.locked}`, S.yellow]); + if (it.agent && !it.busy) risks.push(['·', `made by ${AGENTS[it.agent].name}, which isn't running in it`, S.dim]); return risks; } @@ -443,9 +550,9 @@ function columns(items, width, showPR) { ab: Math.max(6, ...items.map((it) => segsLen(aheadBehindSegs(it)))), pr: showPR ? Math.max(13, ...items.map((it) => segsLen(prSegs(it.pr)))) : 0, // fits '#12345 merged': no jump when PRs load age: 4, - claude: items.some((it) => it.claude || it.deleting) ? GAP.length + 10 : 0, + agent: items.some((it) => it.agent || it.busy || it.deleting) ? GAP.length + 10 : 0, }; - const fixed = 4 + 4 * GAP.length + w.changes + w.ab + (showPR ? GAP.length + w.pr : 0) + w.age + w.claude; + const fixed = 4 + 4 * GAP.length + w.changes + w.ab + (showPR ? GAP.length + w.pr : 0) + w.age + w.agent; // Too wide: shrink the worktree column first, the branch leads. let over = w.branch + w.name - (width - fixed); if (over > 0) { @@ -575,7 +682,8 @@ function pick(items, root, base, initialQuery, startNotice = null) { ...cell(age(it.date), w.age, S.dim), ]; if (it.deleting) segs.push([GAP, ''], ['deleting…', S.red]); - else if (it.claude) segs.push([GAP, ''], ['◆ claude', S.magenta]); + else if (it.busy) segs.push([GAP, ''], [`◆ ${it.busy}`, S.magenta]); // an agent is running in it + else if (it.agent) segs.push([GAP, ''], [`◇ ${it.agent}`, S.dim]); // an agent made it const rest = width - segsLen(marks); return paint(marks, width) + (sel ? paint(segs.map(([t, st]) => [t, plain(st)]), rest, S.selected) : paint(segs, rest)); } @@ -673,6 +781,7 @@ function pick(items, root, base, initialQuery, startNotice = null) { render(); const children = loadStatuses(items, render); if (prChild) children.push(prChild); + loadAgents(items, children, render); process.on('SIGWINCH', render); if (startNotice) notify(...startNotice); @@ -736,7 +845,7 @@ function pick(items, root, base, initialQuery, startNotice = null) { !it.isMain && { key: 'd', label: 'Delete worktree…', - disabled: it.claude && 'Claude Code is running in it', + disabled: it.busy && `${AGENTS[it.busy].name} is running in it`, // The confirmation opens on "Cancel", so ⏎ ⏎ ⏎ can't delete run: (m) => (menu = { ...m, confirm: { risks: deletionRisks(it, root, base, prPending), cursor: 1 } }), }, @@ -1306,5 +1415,6 @@ else { module.exports = { VERSION, fuzzy, match, filter, exactMatch, keysOf, parseStatus, age, tilde, keys, cell, paint, columns, shellBlock, withBlock, rcFile, shellQuote, compareVersions, isNewer, releaseVersion, + AGENTS, agentOf, codexWorktreeRoot, gitDirOf, worktreeHolding, parsePs, parseLsof, loadAgents, }; } diff --git a/test/agents.test.js b/test/agents.test.js new file mode 100644 index 0000000..cc9b1e0 --- /dev/null +++ b/test/agents.test.js @@ -0,0 +1,73 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { AGENTS, agentOf, codexWorktreeRoot, gitDirOf, worktreeHolding, parsePs, parseLsof } = require('../src/wt.js'); + +const tmp = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-agents-'))); + +test('codexWorktreeRoot: $CODEX_HOME/worktrees, or the root config.toml sets', () => { + const home = tmp(); + assert.equal(codexWorktreeRoot({ CODEX_HOME: home }), path.join(home, 'worktrees')); + fs.writeFileSync(path.join(home, 'config.toml'), 'model = "x"\n\n[desktop]\ngit-worktree-root = "/srv/trees"\n'); + assert.equal(codexWorktreeRoot({ CODEX_HOME: home }), '/srv/trees'); + fs.writeFileSync(path.join(home, 'config.toml'), 'desktop.git-worktree-root = "~/trees"\n'); + assert.equal(codexWorktreeRoot({ CODEX_HOME: home }), path.join(os.homedir(), 'trees')); +}); + +test('agentOf: by the directory a worktree is in, or by the marker Codex leaves', () => { + const home = tmp(); + const repo = path.join(home, 'repo'); + fs.mkdirSync(repo); + process.env.CODEX_HOME = path.join(home, '.codex'); + + assert.equal(agentOf(path.join(repo, '.claude', 'worktrees', 'fix'), repo, null), 'claude'); + assert.equal(agentOf(path.join(home, '.codex', 'worktrees', 'ab12', 'repo'), repo, null), 'codex'); + assert.equal(agentOf(path.join(home, 'elsewhere', 'repo'), repo, null), null); + assert.equal(agentOf(repo, repo, null), null); + + // The marker wins over the directory: a Codex worktree under a root wt doesn't know + const gitDir = path.join(home, 'gitdir'); + fs.mkdirSync(gitDir); + assert.equal(agentOf(path.join(home, 'elsewhere', 'repo'), repo, gitDir), null); + fs.writeFileSync(path.join(gitDir, 'codex-thread.json'), '{"version":1,"ownerThreadId":"t"}'); + assert.equal(agentOf(path.join(home, 'elsewhere', 'repo'), repo, gitDir), 'codex'); + delete process.env.CODEX_HOME; +}); + +test('gitDirOf: a linked worktree\'s .git file; null for a .git directory', () => { + const dir = tmp(); + fs.mkdirSync(path.join(dir, 'linked')); + fs.writeFileSync(path.join(dir, 'linked', '.git'), 'gitdir: /repo/.git/worktrees/linked\n'); + assert.equal(gitDirOf(path.join(dir, 'linked')), '/repo/.git/worktrees/linked'); + fs.mkdirSync(path.join(dir, 'main', '.git'), { recursive: true }); + assert.equal(gitDirOf(path.join(dir, 'main')), null); + assert.equal(gitDirOf(path.join(dir, 'missing')), null); +}); + +test('worktreeHolding: the deepest worktree a directory is in', () => { + const paths = ['/repo', '/repo/.claude/worktrees/a', '/other']; + assert.equal(worktreeHolding(paths, '/repo/src'), 0); + assert.equal(worktreeHolding(paths, '/repo/.claude/worktrees/a/src'), 1); + assert.equal(worktreeHolding(paths, '/repo/.claude/worktrees/a'), 1); + assert.equal(worktreeHolding(paths, '/other'), 2); + assert.equal(worktreeHolding(paths, '/repository'), -1); + assert.equal(worktreeHolding(paths, '/'), -1); +}); + +test('parsePs and parseLsof', () => { + assert.deepEqual(parsePs(' 123 claude\n 4567 /Applications/Codex.app/Contents/MacOS/codex\nbad line\n'), [ + ['123', 'claude'], ['4567', 'codex'], + ]); + assert.deepEqual(parseLsof('p123\nfcwd\nn/repo/a\np456\nfcwd\nn/repo/b\n'), [['123', '/repo/a'], ['456', '/repo/b']]); + assert.deepEqual(parseLsof(''), []); +}); + +test('every agent names its processes and where its worktrees live', () => { + for (const agent of Object.values(AGENTS)) { + assert.ok(agent.name && agent.procs.length > 0); + assert.ok(agent.roots('/repo').every((r) => path.isAbsolute(r))); + } +});