Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mscripts/worktree.mjs | 38+++++++++++++++++++++++++++++---------
Ascripts/worktree.test.mjs | 46++++++++++++++++++++++++++++++++++++++++++++++
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); +});