commit b2b8abe3b692865ae30a489ea3e4465f03fbcdd7
parent 78a25c89cfbe0ee48dd462bace8d87d0c74aac72
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 22 Sep 2026 16:13:21 -0400
wt: rm finds the directory add made
`add` flattens a branch name to a directory name (a branch may hold "/", a
sibling directory may not), so `tags/site` becomes `tags-site`. `rm` did not,
and pointed git at a path nothing had ever created — for exactly the string
somebody who just ran `add` will type. Two of the worktrees on this machine
right now (editor/debts, export/tags-followups) could only be removed by their
flattened name or their full path.
`worktreeDirFor(main, name)` is now the single answer to "where does this
worktree live", called by both. An absolute path still passes through
untouched: `rm` accepts one because that is what `list` prints, and sanitizing
it would eat its separators.
main() is guarded like archilyzer-ops.mjs so the pure part can be imported by
scripts/worktree.test.mjs without shelling out to git.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 75 insertions(+), 9 deletions(-)
diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs
@@ -67,6 +67,21 @@ function mainRoot(trees = listWorktrees()) {
return trees.length ? trees[0].path : git(["rev-parse", "--show-toplevel"]);
}
+// WHERE A WORKTREE LIVES, asked the same way by `add` and by `rm`.
+//
+// A branch name may contain "/" and a sibling directory name may not, so `add`
+// has always flattened `tags/site` to `tags-site`. `rm` did not, so
+// `pnpm wt rm tags/site` pointed git at a path nothing had ever created — and
+// that string is exactly what somebody who just ran `add` will type. One
+// function, both callers, so the two spellings cannot drift apart again.
+//
+// An absolute path passes through untouched: `rm` accepts one (it is what
+// `list` prints), and sanitizing it would eat its separators.
+export function worktreeDirFor(main, name) {
+ if (path.isAbsolute(name)) return name;
+ return path.join(path.dirname(main), name.replace(/[^A-Za-z0-9._-]/g, "-"));
+}
+
function offsetForIndex(index) {
return index * OFFSET_STEP;
}
@@ -211,8 +226,7 @@ function cmdAdd(args) {
}
const main = mainRoot();
- const name = branch.replace(/[^A-Za-z0-9._-]/g, "-");
- const dir = path.join(path.dirname(main), name);
+ const dir = worktreeDirFor(main, branch);
if (branchExists(branch)) {
git(["worktree", "add", dir, branch], { stdio: "inherit" });
@@ -280,7 +294,9 @@ function cmdRm(args) {
return 1;
}
const main = mainRoot();
- const dir = path.isAbsolute(name) ? name : path.join(path.dirname(main), name);
+ // Sanitized exactly as `add` sanitized it, so `rm <the branch you added>`
+ // finds the directory `add` actually made.
+ const dir = worktreeDirFor(main, name);
// --share-data worktrees always carry an untracked .worktree-env, so git
// refuses a plain remove; --force handles that (and any other local files).
const rmArgs = ["worktree", "remove", ...(force ? ["--force"] : []), dir];
@@ -328,9 +344,13 @@ async function main() {
}
}
-main()
- .then((code) => process.exit(code ?? 0))
- .catch((err) => {
- process.stderr.write(`${err?.stack ?? err}\n`);
- process.exit(1);
- });
+// Importable for the unit tests (worktreeDirFor is pure and needs no repo);
+// only the CLI entry point runs main(), which shells out to git.
+if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
+ main()
+ .then((code) => process.exit(code ?? 0))
+ .catch((err) => {
+ process.stderr.write(`${err?.stack ?? err}\n`);
+ process.exit(1);
+ });
+}
diff --git a/scripts/worktree.test.mjs b/scripts/worktree.test.mjs
@@ -0,0 +1,46 @@
+// Where `pnpm wt` puts a worktree — the one thing `add` and `rm` have to agree
+// about. Pure: no repo, no git, no filesystem.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+import path from "node:path";
+import { worktreeDirFor } from "./worktree.mjs";
+
+const MAIN = "/home/u/Projects/yt-dlp-transcript-browser";
+const SIBLING = path.dirname(MAIN);
+
+test("a branch with a slash becomes one sibling directory", () => {
+ // THE BUG: `add` flattened and `rm` did not, so `pnpm wt rm tags/site` —
+ // the exact string the person had just passed to `add` — pointed git at a
+ // path that had never existed.
+ assert.equal(worktreeDirFor(MAIN, "tags/site"), path.join(SIBLING, "tags-site"));
+ assert.equal(
+ worktreeDirFor(MAIN, "one-core/phase-2/gate-a"),
+ path.join(SIBLING, "one-core-phase-2-gate-a"),
+ );
+});
+
+test("a plain name is unchanged, dots and dashes survive", () => {
+ assert.equal(worktreeDirFor(MAIN, "hotfix"), path.join(SIBLING, "hotfix"));
+ assert.equal(
+ worktreeDirFor(MAIN, "release-1.2_rc"),
+ path.join(SIBLING, "release-1.2_rc"),
+ );
+});
+
+test("an absolute path passes through untouched", () => {
+ // `rm` accepts one because it is what `list` prints; sanitizing it would eat
+ // its separators and aim git at a directory that does not exist.
+ const abs = path.join(SIBLING, "tags-site");
+ assert.equal(worktreeDirFor(MAIN, abs), abs);
+ assert.equal(worktreeDirFor(MAIN, "/tmp/wt/some thing"), "/tmp/wt/some thing");
+});
+
+test("nothing escapes the sibling directory", () => {
+ // A relative name cannot climb out: every "/" and "." run that could form a
+ // traversal is flattened to dashes first.
+ const dir = worktreeDirFor(MAIN, "../../etc/passwd");
+ assert.equal(dir, path.join(SIBLING, "..-..-etc-passwd"));
+ assert.equal(path.dirname(dir), SIBLING);
+});