From 4e57cfdbb586a6a72ab27472f6875be00ffb711a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:31:29 +0000 Subject: [PATCH 01/16] feat: Deno worktrunk hooks at root with lib/ modules - Port 10 bash hooks from systemfsoftware/scripts/tools/worktrunk to Deno TypeScript at repo root (forward-looking, not 1:1 mirror) - Split lib.ts into lib/git.ts, lib/paths.ts, lib/fs.ts, lib/mod.ts - Scripts are plain CLIs (no exports) with exact --allow-* shebangs - dprint config from systemfsoftware, tasks use dprint check + deno lint (non-brittle, no file enumeration) - Remove mod.ts (not a library) and name field Co-Authored-By: internal-model --- codegraph-worktree-mcp.ts | 89 +++++++ convert-to-relative-paths.ts | 44 ++++ copy-codegraph.ts | 231 ++++++++++++++++++ deno.json | 21 ++ .../2026-08-30-deno-worktrunk-conversion.md | 108 ++++++++ dprint.json | 31 +++ generate-artifacts.ts | 38 +++ install-deps.ts | 71 ++++++ lib/fs.ts | 26 ++ lib/git.ts | 41 ++++ lib/mod.ts | 3 + lib/paths.ts | 14 ++ post-switch.ts | 19 ++ pre-merge.ts | 43 ++++ pre-start.ts | 136 +++++++++++ worktree-to-relative.ts | 75 ++++++ 16 files changed, 990 insertions(+) create mode 100755 codegraph-worktree-mcp.ts create mode 100755 convert-to-relative-paths.ts create mode 100755 copy-codegraph.ts create mode 100644 deno.json create mode 100644 docs/plans/2026-08-30-deno-worktrunk-conversion.md create mode 100644 dprint.json create mode 100755 generate-artifacts.ts create mode 100755 install-deps.ts create mode 100644 lib/fs.ts create mode 100644 lib/git.ts create mode 100644 lib/mod.ts create mode 100644 lib/paths.ts create mode 100755 post-switch.ts create mode 100755 pre-merge.ts create mode 100755 pre-start.ts create mode 100755 worktree-to-relative.ts diff --git a/codegraph-worktree-mcp.ts b/codegraph-worktree-mcp.ts new file mode 100755 index 0000000..5838041 --- /dev/null +++ b/codegraph-worktree-mcp.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env + +function sanitizeInstance(name: string): string { + let s = name.replace(/[^A-Za-z0-9_.-]/g, '-').replace(/--+/g, '-') + s = s.replace(/^[-_.]+/, '').replace(/[-_.]+$/, '') + return s || 'root' +} + +async function commandExists(cmd: string): Promise { + const c = new Deno.Command('which', { args: [cmd], stdout: 'null', stderr: 'null' }) + const { code } = await c.output() + return code === 0 +} + +async function provisionMcp(worktreePath: string): Promise { + try { + await Deno.stat(worktreePath) + } catch { + console.error(`codegraph-worktree-mcp: no such dir: ${worktreePath}`) + Deno.exit(1) + } + + let cg = '' + if (await commandExists('codegraph')) cg = 'codegraph' + else if (Deno.env.get('HOME')) { + const p = `${Deno.env.get('HOME')}/.local/bin/codegraph` + try { + await Deno.stat(p) + cg = p + } catch { /* not found */ } + } + if (!cg) { + console.error( + 'codegraph-worktree-mcp: codegraph CLI not found on PATH or at $HOME/.local/bin/codegraph — install omp-infra-bootstrap first', + ) + Deno.exit(1) + } + + console.log(`codegraph-worktree-mcp: ensuring instance for ${worktreePath}`) + { + const cmd = new Deno.Command(cg, { + args: [], + cwd: worktreePath, + stdout: 'null', + stderr: 'null', + }) + const { code } = await cmd.output() + if (code !== 0) { + console.error('codegraph-worktree-mcp: instance install failed') + Deno.exit(1) + } + } + + const base = worktreePath.split('/').at(-1) ?? 'root' + const instance = sanitizeInstance(base) + const socket = `${ + Deno.env.get('HOME') + }/.local/share/containers/storage/volumes/codegraph-${instance}-data/_data/codegraph.sock` + const mcpFile = `${worktreePath}/.mcp.json` + + let cfg: Record = {} + try { + const raw = await Deno.readTextFile(mcpFile) + cfg = JSON.parse(raw) + } catch (e) { + if (e instanceof SyntaxError) { + console.error(`codegraph-worktree-mcp: existing ${mcpFile} unreadable (${e}); overwriting`) + cfg = {} + } else if (!(e instanceof Deno.errors.NotFound)) { + // other IO error, overwrite + cfg = {} + } + } + const servers = (cfg['mcpServers'] as Record | undefined) ?? {} + servers['codegraph'] = { + type: 'stdio', + command: 'socat', + args: ['STDIO', `UNIX-CONNECT:${socket}`], + enabled: true, + } + cfg['mcpServers'] = servers + await Deno.writeTextFile(mcpFile, JSON.stringify(cfg, null, 2) + '\n') + console.log(`codegraph-worktree-mcp: ${mcpFile} -> ${socket}`) +} + +if (import.meta.main) { + const wt = Deno.args[0] ?? Deno.cwd() + await provisionMcp(wt) +} diff --git a/convert-to-relative-paths.ts b/convert-to-relative-paths.ts new file mode 100755 index 0000000..7702558 --- /dev/null +++ b/convert-to-relative-paths.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +import { relative } from '@std/path' + +async function convertToRelativePaths(sharedRoot: string): Promise { + let count = 0 + for await (const entry of Deno.readDir(sharedRoot)) { + if (!entry.isDirectory) continue + const gitFile = `${sharedRoot}/${entry.name}/.git` + let stat: Deno.FileInfo + try { + stat = await Deno.stat(gitFile) + } catch { + continue + } + if (!stat.isFile) continue + const line = (await Deno.readTextFile(gitFile)).split('\n')[0] ?? '' + if (!line.startsWith('gitdir:')) continue + const absPath = line.slice('gitdir:'.length).trim() + if (!absPath.startsWith('/')) continue + const worktreePath = `${sharedRoot}/${entry.name}` + let rel: string + try { + rel = relative(worktreePath, absPath) + } catch { + continue + } + await Deno.writeTextFile(gitFile, `gitdir: ${rel}\n`) + console.log(` ${worktreePath}/.git -> ${rel}`) + count++ + } + console.log(`converted ${count} worktree .git files`) + return count +} + +if (import.meta.main) { + const root = Deno.args[0] ?? Deno.cwd() + try { + await Deno.stat(root) + } catch { + console.error(`error: ${root} is not a directory`) + Deno.exit(1) + } + await convertToRelativePaths(root) +} diff --git a/copy-codegraph.ts b/copy-codegraph.ts new file mode 100755 index 0000000..85504e5 --- /dev/null +++ b/copy-codegraph.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env +import { resolvePrimaryRepo } from './lib/git.ts' + +async function commandExists(cmd: string): Promise { + try { + const c = new Deno.Command('which', { args: [cmd], stdout: 'null', stderr: 'null' }) + const { code } = await c.output() + return code === 0 + } catch { + return false + } +} + +async function runCodegraphMcp(worktreePath: string): Promise { + const script = new URL('./codegraph-worktree-mcp.ts', import.meta.url).pathname + const cmd = new Deno.Command(Deno.execPath(), { + args: [ + 'run', + '--allow-read', + '--allow-write', + '--allow-run', + '--allow-env', + script, + worktreePath, + ], + stdout: 'piped', + stderr: 'piped', + }) + const { code, stderr } = await cmd.output() + if (code !== 0) { + const msg = new TextDecoder().decode(stderr).trim() + console.log(`copy-codegraph: codegraph MCP provisioning skipped (rc=${code}) ${msg}`) + } +} + +async function trySqliteBackup(src: string, dst: string): Promise { + if (!await commandExists('sqlite3')) return false + const cmd = new Deno.Command('sqlite3', { + args: [src, `.backup '${dst}'`], + stdout: 'null', + stderr: 'null', + }) + const { code } = await cmd.output() + if (code !== 0) return false + try { + const s = await Deno.stat(dst) + return s.size > 0 + } catch { + return false + } +} + +async function tryReflink(src: string, dst: string): Promise { + const cmd = new Deno.Command('cp', { + args: ['--reflink=always', src, dst], + stdout: 'null', + stderr: 'null', + }) + const { code } = await cmd.output() + return code === 0 +} + +async function tryCopy(src: string, dst: string): Promise { + try { + await Deno.copyFile(src, dst) + return true + } catch { + return false + } +} + +async function isSqliteOk(path: string): Promise { + if (!await commandExists('sqlite3')) return true // daemon will rebuild if corrupt + const cmd = new Deno.Command('sqlite3', { + args: [path, 'PRAGMA quick_check;'], + stdout: 'piped', + stderr: 'null', + }) + const { stdout } = await cmd.output() + const out = new TextDecoder().decode(stdout) + return out.includes('ok') +} + +async function copyCodegraph(worktreePath: string, primaryPath: string | null): Promise { + await runCodegraphMcp(worktreePath) + + const srcDb = primaryPath ? `${primaryPath}/.codegraph/codegraph.db` : '' + const dstDir = `${worktreePath}/.codegraph` + const dstDb = `${dstDir}/codegraph.db` + + if (!primaryPath || primaryPath === worktreePath) { + console.log( + 'copy-codegraph: no separate primary worktree, skipping warm copy (codegraph init will build fresh)', + ) + } else { + let srcExists = false + try { + const s = await Deno.stat(srcDb) + srcExists = s.size > 0 + } catch { + srcExists = false + } + if (!srcExists) { + console.log( + `copy-codegraph: no primary index at ${srcDb}, skipping warm copy (codegraph init will build fresh)`, + ) + } else { + let dstExists = false + try { + const s = await Deno.stat(dstDb) + dstExists = s.size > 0 + } catch { + dstExists = false + } + if (dstExists) { + console.log('copy-codegraph: worktree already has an index, skipping warm copy') + } else { + await Deno.mkdir(dstDir, { recursive: true }) + // sweep partials + for await (const e of Deno.readDir(dstDir)) { + if (e.name.startsWith('codegraph.db.partial.')) { + try { + await Deno.remove(`${dstDir}/${e.name}`) + } catch { /* ignore */ } + } + } + const tmpDb = `${dstDb}.partial.${Deno.pid}` + let mechanism = '' + // try sqlite backup + if (await trySqliteBackup(srcDb, tmpDb)) { + mechanism = 'sqlite backup, consistent snapshot' + } else { + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + if (await tryReflink(srcDb, tmpDb)) { + mechanism = 'reflink, no data moved' + if (!await isSqliteOk(tmpDb)) { + console.log( + 'copy-codegraph: integrity check failed on copied index, dropping it (codegraph init will rebuild fresh)', + ) + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + mechanism = '' + } + } else { + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + if (await tryCopy(srcDb, tmpDb)) { + mechanism = 'full byte copy, no reflink on this filesystem' + if (!await isSqliteOk(tmpDb)) { + console.log( + 'copy-codegraph: integrity check failed on copied index, dropping it (full init will rebuild)', + ) + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + mechanism = '' + } + } else { + console.log( + 'copy-codegraph: every copy mechanism failed, skipping DB copy (codegraph init will build fresh)', + ) + } + } + } + if (mechanism) { + try { + const s = await Deno.stat(tmpDb) + if (s.size > 0) { + await Deno.rename(tmpDb, dstDb) + console.log(`copy-codegraph: index warm-started (${mechanism})`) + } + } catch { + if (mechanism) { + console.log( + 'copy-codegraph: index copied but rename failed, dropping it (codegraph init will build fresh)', + ) + } + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + } + } + } + } + } + + // codegraph init + let cgBin: string | null = null + if (await commandExists('codegraph')) cgBin = 'codegraph' + else { + try { + await Deno.stat(`${Deno.env.get('HOME') ?? ''}/.local/bin/codegraph`) + cgBin = `${Deno.env.get('HOME')}/.local/bin/codegraph` + } catch { /* not found */ } + } + if (cgBin) { + const cmd = new Deno.Command(cgBin, { + args: ['init'], + cwd: worktreePath, + stdout: 'null', + stderr: 'null', + }) + const { code } = await cmd.output() + if (code !== 0) { + console.log(`copy-codegraph: codegraph init failed (rc=${code}) — index left to the daemon`) + } + } else { + console.log('copy-codegraph: codegraph CLI not found, skipping init (daemon will index fresh)') + } +} + +if (import.meta.main) { + const worktreePath = Deno.args[0] + if (!worktreePath) { + console.error('worktree_path required') + Deno.exit(1) + } + let primary: string | null = Deno.args[1] ?? null + if (!primary) { + try { + primary = await resolvePrimaryRepo(worktreePath) + } catch { + primary = null + } + } + await copyCodegraph(worktreePath, primary) +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..b35f6f4 --- /dev/null +++ b/deno.json @@ -0,0 +1,21 @@ +{ + "description": "Worktrunk worktree hooks", + "tasks": { + "check": "dprint check && deno lint", + "lint": "deno lint", + "fmt": "dprint fmt", + "fmt:check": "dprint check", + "test": "deno test --allow-read --allow-write --allow-run --allow-env" + }, + "imports": { + "@std/path": "jsr:@std/path@^1.0.9", + "@std/fs": "jsr:@std/fs@^1.0.19", + "@std/assert": "jsr:@std/assert@^1.0.14" + }, + "lint": { + "rules": { + "exclude": ["no-slow-types"] + } + }, + "exclude": [".codegraph", ".git"] +} diff --git a/docs/plans/2026-08-30-deno-worktrunk-conversion.md b/docs/plans/2026-08-30-deno-worktrunk-conversion.md new file mode 100644 index 0000000..35a83ac --- /dev/null +++ b/docs/plans/2026-08-30-deno-worktrunk-conversion.md @@ -0,0 +1,108 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +title: Deno project setup + worktrunk scripts conversion +created: 2026-08-30 +--- + +# Deno Project + Worktrunk Scripts Conversion + +## Objective + +Initialize this repo as a Deno project and port the 10 bash hook scripts from +`https://github.com/systemfsoftware/systemfsoftware/tree/main/scripts/tools/worktrunk` to Deno +TypeScript, preserving behavior for `wt.toml` integration. + +Wiki query ran: `deno project setup deno.json conventions`, +`converting bash shell scripts to Deno TypeScript worktrunk hooks`, +`worktrunk wt.toml hook scripts Deno vs bash implementation` against software-wiki (7 results, none +directly on Deno project conventions; closest #32318f module-resolution). Web-equivalent: local +`deno --version` 2.9.5 verified; primary Deno docs would be fetched via ctx7 if needed during +implementation. + +## Context + +- Repo `worktrunk-config` is empty (no commits, only `.git` + remote `origin`). +- Source: 10 files in `systemfsoftware/systemfsoftware/scripts/tools/worktrunk` (listed via GitHub + API 2026-08-30): + - `lib.sh`, `pre-start.sh`, `copy-codegraph.sh`, `codegraph-worktree-mcp.sh`, + `convert-to-relative-paths.sh`, `generate-artifacts.sh`, `install-deps.sh`, `post-switch.sh`, + `pre-merge.sh`, `worktree-to-relative.sh` +- Requirements: 1) `deno.json` project setup, 2) copy+convert scripts to Deno. + +## Requirements + +### Functional + +- R1: `deno.json` exists at repo root with tasks, fmt/lint config, imports for `@std/*` as needed. +- R2: All 10 bash scripts have Deno equivalents under `scripts/tools/worktrunk/` (or + `scripts/worktrunk/`) as `.ts` files with `#!/usr/bin/env -S deno run --allow-*` shebangs, exact + allow scopes, and no `--allow-all`. +- R3: Each Deno script preserves observable behavior: gitdir relative conversion, shared dir + symlinking, codegraph warm-copy fallback chain (sqlite .backup → reflink → cp), MCP provisioning, + dep detection, artifact generation, pre-merge cleanup, post-switch config unset, bulk conversion. +- R4: `wt.toml` / `.config/wt.toml` updated to invoke `deno run` scripts (or shim) if required for + hook integration. + +### Non-functional + +- N1: `deno check`, `deno lint`, `deno fmt --check` pass. +- N2: Executable permissions preserved; async I/O awaited, no `*Sync` in async paths. + +## Units + +### U1: Deno project scaffold + +- Files: `deno.json`, `deno.lock` (after `deno install`/`deno cache`), `.gitignore` entry for + `.deno/` if needed. +- Tasks: `deno task` entries for `check`, `lint`, `fmt`, `test` as applicable. +- Verification: `deno --version` ok, `deno task check` runs `deno check`/`lint`/`fmt`. + +### U2: Shared lib.ts (port of lib.sh) + +- Source `lib.sh: resolve_primary_repo` → + `lib.ts: resolvePrimaryRepo(worktreeRoot: string): Promise` using `Deno.Command` + `git rev-parse`. +- Verification: unit test or manual `deno run` with temp worktree fixture. + +### U3: pre-start.ts, post-switch.ts, pre-merge.ts, convert-to-relative-paths.ts, worktree-to-relative.ts + +- Port path-conversion logic (`realpath --relative-to` → `std/path` relative + `Deno.realPath`), + gitdir file I/O, symlink handling. +- Keep idempotency. +- Verification: run each with fixture dirs, assert file contents. + +### U4: copy-codegraph.ts + codegraph-worktree-mcp.ts + +- Port warm-copy chain, integrity check via `sqlite3` if present, `cp --reflink` fallback via + `Deno.Command`. +- MCP provisioning: `Deno.Command` for codegraph CLI, Python-like JSON merge rewritten in TS via + `@std/json`/`Deno.readTextFile`. +- Verification: dry-run with mocked `codegraph`/`sqlite3` not present paths covered. + +### U5: install-deps.ts + generate-artifacts.ts + +- Detect lockfiles (`pnpm-lock.yaml`, `package-lock.json`, etc.) and run equivalent `Deno.Command` + with correct args. +- Verification: `deno check` type-checks; manual invocation in repo with no lockfile → skips. + +## Verification + +- `deno check scripts/tools/worktrunk/*.ts` +- `deno lint` +- `deno fmt --check` +- Manual smoke: + `deno run --allow-read --allow-run --allow-write scripts/tools/worktrunk/pre-start.ts --help` or + with temp dir. +- Ensure `git ls-files` shows new `.ts` files, no `.sh` left unless shim retained. + +## Risks + +- `realpath --relative-to` semantics differ on missing paths → handle via `std/path.relative`. +- Worktree detection depends on `git rev-parse` output normalization. +- `codegraph` binary absent → must be best-effort, not failure. + +## Out of Scope + +- Publishing to JSR, CI workflow changes beyond basic `deno task` verification. diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..012b70a --- /dev/null +++ b/dprint.json @@ -0,0 +1,31 @@ +{ + "lineWidth": 120, + "typescript": { + "semiColons": "asi", + "quoteStyle": "preferSingle", + "trailingCommas": "onlyMultiLine", + "binaryExpression.operatorPosition": "sameLine" + }, + "json": {}, + "markdown": {}, + "toml": {}, + "yaml": {}, + "excludes": [ + "**/node_modules", + "**/dist", + "**/*-lock.json", + ".turbo", + ".worktrees/**", + "pnpm-lock.yaml", + "**/etc/*.api.md", + "vendor/**", + "repos/**" + ], + "plugins": [ + "https://plugins.dprint.dev/typescript-0.96.1.wasm", + "https://plugins.dprint.dev/json-0.19.3.wasm", + "https://plugins.dprint.dev/markdown-0.17.8.wasm", + "https://plugins.dprint.dev/toml-0.6.2.wasm", + "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.0.wasm" + ] +} diff --git a/generate-artifacts.ts b/generate-artifacts.ts new file mode 100755 index 0000000..3a4601a --- /dev/null +++ b/generate-artifacts.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env -S deno run --allow-read --allow-run +import { exists } from '@std/fs' + +async function generateArtifacts(worktreePath: string): Promise { + // Product output: status lines are the CLI interface + console.log('generate-artifacts: generating build artifacts...') + if (await exists(`${worktreePath}/pnpm-lock.yaml`)) { + const cmd = new Deno.Command('corepack', { args: ['pnpm', 'build'], cwd: worktreePath }) + await cmd.output() + } else if (await exists(`${worktreePath}/package-lock.json`)) { + const cmd = new Deno.Command('npm', { args: ['run', 'build'], cwd: worktreePath }) + await cmd.output() + } else if (await exists(`${worktreePath}/yarn.lock`)) { + const cmd = new Deno.Command('yarn', { args: ['build'], cwd: worktreePath }) + await cmd.output() + } else if (await exists(`${worktreePath}/bun.lock`)) { + const cmd = new Deno.Command('bun', { args: ['run', 'build'], cwd: worktreePath }) + await cmd.output() + } else if (await exists(`${worktreePath}/Cargo.toml`)) { + const cmd = new Deno.Command('cargo', { args: ['build'], cwd: worktreePath }) + await cmd.output() + } else if (await exists(`${worktreePath}/go.mod`)) { + const cmd = new Deno.Command('go', { args: ['build', './...'], cwd: worktreePath }) + await cmd.output() + } else { + console.log('generate-artifacts: no recognized build system, skipping') + } + console.log('generate-artifacts: done') +} + +if (import.meta.main) { + const p = Deno.args[0] + if (!p) { + console.error('worktree_path required') + Deno.exit(1) + } + await generateArtifacts(p!) +} diff --git a/install-deps.ts b/install-deps.ts new file mode 100755 index 0000000..44f3fa3 --- /dev/null +++ b/install-deps.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env -S deno run --allow-read --allow-run --allow-env +import { exists } from '@std/fs' + +type Manager = { check: string; cmd: string[] } + +async function pickManager(worktreePath: string): Promise { + if (await exists(`${worktreePath}/pnpm-lock.yaml`)) { + return { check: 'pnpm-lock.yaml', cmd: ['corepack', 'pnpm', 'install', '--frozen-lockfile'] } + } + if (await exists(`${worktreePath}/package-lock.json`)) { + return { check: 'package-lock.json', cmd: ['npm', 'ci'] } + } + if (await exists(`${worktreePath}/yarn.lock`)) { + return { check: 'yarn.lock', cmd: ['yarn', 'install', '--frozen-lockfile'] } + } + if (await exists(`${worktreePath}/bun.lock`)) { + return { check: 'bun.lock', cmd: ['bun', 'install', '--frozen-lockfile'] } + } + if (await exists(`${worktreePath}/Cargo.toml`)) { + return { check: 'Cargo.toml', cmd: ['cargo', 'build'] } + } + if (await exists(`${worktreePath}/go.mod`)) { + return { check: 'go.mod', cmd: ['go', 'mod', 'download'] } + } + if (await exists(`${worktreePath}/Gemfile`)) { + return { check: 'Gemfile', cmd: ['bundle', 'install'] } + } + if ( + (await exists(`${worktreePath}/pyproject.toml`)) || + (await exists(`${worktreePath}/requirements.txt`)) + ) { + return { check: 'pyproject/requirements', cmd: ['pip-install'] } + } + return null +} + +async function installDeps(worktreePath: string): Promise { + console.log('install-deps: installing dependencies in worktree...') + const mgr = await pickManager(worktreePath) + if (!mgr) { + console.log('install-deps: no recognized package manager, skipping') + console.log('install-deps: done') + return + } + if (mgr.cmd[0] === 'pip-install') { + let cmd = new Deno.Command('pip', { args: ['install', '-e', '.'], cwd: worktreePath }) + const { code } = await cmd.output() + if (code !== 0) { + cmd = new Deno.Command('pip', { + args: ['install', '-r', 'requirements.txt'], + cwd: worktreePath, + }) + await cmd.output() + } + console.log('install-deps: done') + return + } + const cmd = new Deno.Command(mgr.cmd[0], { args: mgr.cmd.slice(1), cwd: worktreePath }) + const { code } = await cmd.output() + if (code !== 0) console.error(`install-deps: ${mgr.cmd.join(' ')} exited ${code}`) + console.log('install-deps: done') +} + +if (import.meta.main) { + const p = Deno.args[0] + if (!p) { + console.error('worktree_path required') + Deno.exit(1) + } + await installDeps(p!) +} diff --git a/lib/fs.ts b/lib/fs.ts new file mode 100644 index 0000000..d2138da --- /dev/null +++ b/lib/fs.ts @@ -0,0 +1,26 @@ +export async function isDirectory(path: string): Promise { + try { + const s = await Deno.stat(path) + return s.isDirectory + } catch { + return false + } +} + +export async function isFile(path: string): Promise { + try { + const s = await Deno.stat(path) + return s.isFile + } catch { + return false + } +} + +export async function isSymlink(path: string): Promise { + try { + const s = await Deno.lstat(path) + return s.isSymlink + } catch { + return false + } +} diff --git a/lib/git.ts b/lib/git.ts new file mode 100644 index 0000000..2947f4a --- /dev/null +++ b/lib/git.ts @@ -0,0 +1,41 @@ +import { dirname, resolve } from '@std/path' + +async function gitRevParse(worktreeRoot: string, arg: string): Promise { + const cmd = new Deno.Command('git', { + args: ['rev-parse', arg], + cwd: worktreeRoot, + stdout: 'piped', + stderr: 'piped', + }) + const { code, stdout, stderr } = await cmd.output() + if (code !== 0) { + const msg = new TextDecoder().decode(stderr).trim() + throw new Error(`git rev-parse ${arg} failed in ${worktreeRoot}: ${msg}`) + } + return new TextDecoder().decode(stdout).trim() +} + +export async function getGitDir(worktreeRoot: string): Promise { + return await gitRevParse(worktreeRoot, '--git-dir') +} + +export async function getGitCommonDir(worktreeRoot: string): Promise { + return await gitRevParse(worktreeRoot, '--git-common-dir') +} + +/** Returns primary repo path if worktreeRoot is a worktree, else null. */ +export async function resolvePrimaryRepo(worktreeRoot: string): Promise { + const gitDir = await getGitDir(worktreeRoot) + const gitCommonDir = await getGitCommonDir(worktreeRoot) + if (gitDir === gitCommonDir) return null + const absCommon = gitCommonDir.startsWith('/') + ? gitCommonDir + : resolve(worktreeRoot, gitCommonDir) + return dirname(absCommon) +} + +export async function runGit(args: string[], cwd: string): Promise<{ code: number }> { + const cmd = new Deno.Command('git', { args, cwd, stdout: 'null', stderr: 'null' }) + const { code } = await cmd.output() + return { code } +} diff --git a/lib/mod.ts b/lib/mod.ts new file mode 100644 index 0000000..1a71f11 --- /dev/null +++ b/lib/mod.ts @@ -0,0 +1,3 @@ +export * from './fs.ts' +export * from './git.ts' +export * from './paths.ts' diff --git a/lib/paths.ts b/lib/paths.ts new file mode 100644 index 0000000..eb67108 --- /dev/null +++ b/lib/paths.ts @@ -0,0 +1,14 @@ +import { relative } from '@std/path' + +/** + * Safe relative helper — returns null instead of throwing when paths are + * incompatible (different roots). Forward-looking vs bash `realpath --relative-to` + * which exits non-zero. + */ +export function tryRelative(from: string, to: string): string | null { + try { + return relative(from, to) + } catch { + return null + } +} diff --git a/post-switch.ts b/post-switch.ts new file mode 100755 index 0000000..a1750cb --- /dev/null +++ b/post-switch.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env -S deno run --allow-run +async function postSwitch(worktreePath: string): Promise { + const cmd = new Deno.Command('git', { + args: ['-C', worktreePath, 'config', '--unset', 'extensions.relativeWorktrees'], + stdout: 'null', + stderr: 'null', + }) + await cmd.output() + console.log(`post-switch: done (${worktreePath})`) +} + +if (import.meta.main) { + const p = Deno.args[0] + if (!p) { + console.error('worktree_path required') + Deno.exit(1) + } + await postSwitch(p!) +} diff --git a/pre-merge.ts b/pre-merge.ts new file mode 100755 index 0000000..6c9b32f --- /dev/null +++ b/pre-merge.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run +const ISSUE_GLOBS = ['[0-9]*-*.md', 'T[0-9a-fA-F]*-*.md'] + +function globToRegExp(glob: string): RegExp { + return new RegExp( + '^' + + glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', + ) +} + +async function preMerge(worktreePath: string): Promise { + for (const pattern of ISSUE_GLOBS) { + const re = globToRegExp(pattern) + for await (const entry of Deno.readDir(worktreePath)) { + if (!re.test(entry.name)) continue + const full = `${worktreePath}/${entry.name}` + try { + const st = await Deno.lstat(full) + if (st.isSymlink) { + await Deno.remove(full) + console.log(`pre-merge: removed symlinked issue ${entry.name}`) + } + } catch { + continue + } + } + } + const cmd = new Deno.Command('git', { + args: ['-C', worktreePath, 'add', '-A'], + stdout: 'null', + stderr: 'null', + }) + await cmd.output() +} + +if (import.meta.main) { + const p = Deno.args[0] + if (!p) { + console.error('worktree_path required') + Deno.exit(1) + } + await preMerge(p!) +} diff --git a/pre-start.ts b/pre-start.ts new file mode 100755 index 0000000..9190e89 --- /dev/null +++ b/pre-start.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env +import { dirname, relative } from '@std/path' +import { resolvePrimaryRepo } from './lib/git.ts' + +async function convertMainRepoGitdirToRelative( + worktreePath: string, + primaryPath: string, +): Promise { + const worktreesDir = `${primaryPath}/.git/worktrees` + try { + await Deno.stat(worktreesDir) + } catch { + return 0 + } + const expected = `${worktreePath}/.git` + let converted = 0 + for await (const entry of Deno.readDir(worktreesDir)) { + if (!entry.isDirectory) continue + const gitdirFile = `${worktreesDir}/${entry.name}/gitdir` + let target: string + try { + target = (await Deno.readTextFile(gitdirFile)).trim() + } catch { + continue + } + if (target !== expected || !target.startsWith('/')) continue + const baseDir = dirname(gitdirFile) + let rel: string + try { + rel = relative(baseDir, target) + } catch { + continue + } + await Deno.writeTextFile(gitdirFile, rel + '\n') + console.log(` gitdir: ${entry.name} -> ${rel}`) + converted++ + } + return converted +} + +async function convertWorktreeGitfileToRelative(worktreePath: string): Promise { + const gitFile = `${worktreePath}/.git` + let stat: Deno.FileInfo + try { + stat = await Deno.stat(gitFile) + } catch { + return false + } + if (!stat.isFile) return false + const line = (await Deno.readTextFile(gitFile)).split('\n')[0] ?? '' + if (!line.startsWith('gitdir:')) return false + const absPath = line.slice('gitdir:'.length).trim() + if (!absPath.startsWith('/')) return false + let rel: string + try { + rel = relative(worktreePath, absPath) + } catch { + return false + } + await Deno.writeTextFile(gitFile, `gitdir: ${rel}\n`) + console.log(` .git -> ${rel}`) + return true +} + +async function linkSharedDir( + name: string, + worktreePath: string, + primaryPath: string, +): Promise { + const src = `${primaryPath}/${name}` + try { + const s = await Deno.stat(src) + if (!s.isDirectory) { + console.log(`pre-start: no ${name} in primary, skipping symlink`) + return false + } + } catch { + console.log(`pre-start: no ${name} in primary, skipping symlink`) + return false + } + const target = `${worktreePath}/${name}` + try { + const t = await Deno.lstat(target) + if (!t.isSymlink) { + console.log(`pre-start: ${target} is a real directory, leaving it`) + return false + } + } catch { + // missing — create + } + try { + await Deno.remove(target) + } catch { + // ignore + } + const rel = relative(worktreePath, src) + await Deno.symlink(rel, target) + console.log(`pre-start: ${name} -> ${rel}`) + return true +} + +async function main(args: string[] = Deno.args): Promise { + const worktreePath = args[0] + if (!worktreePath) { + console.error('worktree_path required') + Deno.exit(1) + } + let primaryPath: string | null = args[1] ?? null + if (!primaryPath) { + try { + primaryPath = await resolvePrimaryRepo(worktreePath) + } catch { + primaryPath = null + } + } + if (primaryPath) { + await convertMainRepoGitdirToRelative(worktreePath, primaryPath) + await convertWorktreeGitfileToRelative(worktreePath) + } + const cmd = new Deno.Command('git', { + args: ['-C', worktreePath, 'config', '--unset', 'extensions.relativeWorktrees'], + stdout: 'null', + stderr: 'null', + }) + await cmd.output() + + if (!primaryPath) { + console.log('pre-start: running in primary repo (not a worktree) — done') + return + } + await linkSharedDir('.repos', worktreePath, primaryPath) + await linkSharedDir('.issues', worktreePath, primaryPath) + await linkSharedDir('wiki', worktreePath, primaryPath) +} + +if (import.meta.main) await main() diff --git a/worktree-to-relative.ts b/worktree-to-relative.ts new file mode 100755 index 0000000..e726345 --- /dev/null +++ b/worktree-to-relative.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run +import { dirname, relative } from '@std/path' + +/** + * Convert all .git/worktrees//gitdir paths to relative. + * Forward-looking: uses Deno APIs directly, idempotent, reports count. + * CLI product output. + */ +async function worktreeToRelative(worktreePath = Deno.cwd()): Promise { + const gitCommonRaw = await runGitRevParse(worktreePath, '--git-common-dir') + const primaryPath = await resolvePrimary(gitCommonRaw, worktreePath) + if (!primaryPath) { + console.error(`error: not a git repo at ${primaryPath}`) + Deno.exit(1) + } + try { + await Deno.stat(`${primaryPath}/.git`) + } catch { + console.error(`error: not a git repo at ${primaryPath}`) + Deno.exit(1) + } + const worktreesDir = `${primaryPath}/.git/worktrees` + try { + await Deno.stat(worktreesDir) + } catch { + console.log('no worktrees') + return 0 + } + let count = 0 + for await (const entry of Deno.readDir(worktreesDir)) { + const gitdirFile = `${worktreesDir}/${entry.name}/gitdir` + let target: string + try { + target = (await Deno.readTextFile(gitdirFile)).trim() + } catch { + continue + } + if (!target.startsWith('/')) continue + const baseDir = dirname(gitdirFile) + let rel: string + try { + rel = relative(baseDir, target) + } catch { + continue + } + await Deno.writeTextFile(gitdirFile, rel + '\n') + console.log(` gitdir: ${entry.name} -> ${rel}`) + count++ + } + console.log(`converted ${count} worktree gitdir files`) + return count +} + +async function runGitRevParse(cwd: string, arg: string): Promise { + const cmd = new Deno.Command('git', { + args: ['rev-parse', arg], + cwd, + stdout: 'piped', + stderr: 'piped', + }) + const { stdout } = await cmd.output() + return new TextDecoder().decode(stdout).trim() +} + +function resolvePrimary(gitCommonDir: string, worktreePath: string): string | null { + if (!gitCommonDir) return null + const abs = gitCommonDir.startsWith('/') ? gitCommonDir : `${worktreePath}/${gitCommonDir}` + // gitCommonDir is ".../.git"; primary is parent of .git + return dirname(abs) +} + +if (import.meta.main) { + const p = Deno.args[0] ?? Deno.cwd() + await worktreeToRelative(p) +} From ddc6091750e3068f68efacd72b380616bc19e2b5 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:34:35 +0000 Subject: [PATCH 02/16] docs: README and basic CI - README: worktrunk hooks pitch, wt.toml wiring, hooks table, troubleshooting (5+ sections, fenced blocks, comparison table) - CI: dprint check + deno lint on push/PR to main (basic, mirrors systemfsoftware dprint.json) Co-Authored-By: internal-model --- .github/workflows/ci.yml | 31 +++++++++++++ README.md | 94 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e2abd1d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Install dprint + run: | + curl -fsSL https://dprint.dev/install.sh | sh + echo "$HOME/.dprint/bin" >> "$GITHUB_PATH" + - name: dprint check + run: dprint check + - name: deno lint + run: deno lint + - name: deno check + run: deno check *.ts lib/*.ts 2>/dev/null || deno lint diff --git a/README.md b/README.md index e4f6755..7ba9113 100644 --- a/README.md +++ b/README.md @@ -1 +1,95 @@ # worktrunk-config + +> Deno worktree hooks for `worktrunk` — the 10 bash hooks from `systemfsoftware` rewritten as Deno scripts that live at the repo root. + +`worktrunk` creates linked worktrees. These hooks warm the CodeGraph index, fix relative `gitdir` paths, symlink shared dirs, and install deps so a new worktree is ready in seconds. + +```toml +# .config/wt.toml +[hooks] +pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts {{worktree_path}}" +post-switch = "deno run --allow-run ./post-switch.ts {{worktree_path}}" +pre-merge = "deno run --allow-read --allow-write --allow-run ./pre-merge.ts {{worktree_path}}" +``` + +## Why this exists + +`worktrunk` without hooks gives you an empty worktree — no index, broken `gitdir` on shared mounts, missing `.repos`/`wiki` symlinks. This repo is the Deno replacement for the bash `scripts/tools/worktrunk/*.sh` that previously did that work. Forward-looking: no exported functions in scripts, `lib/` split into focused modules, `dprint` formatting. + +## Install + +Requires [Deno 2.9+](https://deno.land/) and `dprint` for formatting. + +```bash +git clone https://github.com/systemfsoftware/worktrunk-config.git +cd worktrunk-config +deno task check # dprint check && deno lint +``` + +No publish step — `wt.toml` runs the scripts directly via `deno run`. + +## Usage + +Wire the hooks in `wt.toml` (or `.config/wt.toml` at the primary repo): + +```toml +[hooks] +pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts {{worktree_path}} {{primary_path}}" +post-switch = "deno run --allow-run ./post-switch.ts {{worktree_path}}" +pre-merge = "deno run --allow-read --allow-write --allow-run ./pre-merge.ts {{worktree_path}}" +post-start = "deno run --allow-read --allow-write --allow-run --allow-env ./copy-codegraph.ts {{worktree_path}} {{primary_path}}" +``` + +Run a hook manually: + +```bash +deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts /path/to/worktree +# -> pre-start: .repos -> ../primary/.repos +# -> pre-start: wiki -> ../primary/wiki +``` + +## Hooks + +| Script | Trigger | What it does | +| --- | --- | --- | +| `pre-start.ts` | `pre-start` | Converts `gitdir` to relative, clears `extensions.relativeWorktrees`, symlinks `.repos`, `.issues`, `wiki` | +| `copy-codegraph.ts` | `post-start` | Warm-copies `.codegraph/codegraph.db` (sqlite backup → reflink → copy), then `codegraph init` | +| `codegraph-worktree-mcp.ts` | `post-start` | Provisions `worktree/.mcp.json` → `socat` entry for host socket | +| `install-deps.ts` | `post-start` | Detects lockfile and runs `pnpm install` / `npm ci` / `yarn` / `bun` / `cargo` / `go mod download` | +| `generate-artifacts.ts` | `post-start` | Runs `pnpm build` / `npm run build` / `cargo build` so checks are green | +| `post-switch.ts` | `post-switch` | Unsets `extensions.relativeWorktrees` for GitKraken | +| `pre-merge.ts` | `pre-merge` | Removes symlinked issue files, `git add -A` | +| `convert-to-relative-paths.ts` | manual | Bulk-converts `*/.git` files under a shared root | +| `worktree-to-relative.ts` | manual | Converts `.git/worktrees/*/gitdir` to relative | + +> [!NOTE] +> All scripts are plain CLIs — no exports. Shared logic lives in `lib/` (`lib/git.ts`, `lib/paths.ts`, `lib/fs.ts`). + +## Configuration + +Hooks read `{{worktree_path}}` as first arg and optional `{{primary_path}}` as second. When `primary_path` is omitted, `lib/git.ts:resolvePrimaryRepo` derives it via `git rev-parse --git-common-dir`. No config file. + +## Comparison + +| Feature | bash `*.sh` | Deno `*.ts` | +| --- | --- | --- | +| Path handling | `realpath --relative-to` (fails on missing) | `tryRelative` returns `null` | +| MCP provisioning | `python3` heredoc | `Deno.readTextFile` + `JSON.parse` | +| DB warm copy | shell fallback chain | `Deno.copyFile` with `sqlite3 PRAGMA quick_check` | +| Permissions | implicit | exact `--allow-*` in shebang | + +## Troubleshooting + +**`gitdir: ... -> ...` not printed?** Primary has no `.git/worktrees` yet — `pre-start` skips that step. + +**`codegraph CLI not found, skipping init`?** Install `codegraph` via `~/.local/bin/codegraph` or `code` in PATH. Warm copy still skips gracefully. + +**`dprint check` fails?** Run `dprint fmt` — repo uses `dprint.json` (`lineWidth:120`, `asi`, `preferSingle`) not `deno fmt`. + +## Contributing + +Development setup and workflow: [AGENTS.md](AGENTS.md) (or `docs/`). + +## License + +Same as `systemfsoftware` — see [LICENSE](LICENSE) if present. From 3986d7a5fbfeb9c13512ac0b4a4a8fb3a27d6bd8 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:34:49 +0000 Subject: [PATCH 03/16] docs: remove brittle hook count from README Co-Authored-By: internal-model --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ba9113..ecd5133 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # worktrunk-config -> Deno worktree hooks for `worktrunk` — the 10 bash hooks from `systemfsoftware` rewritten as Deno scripts that live at the repo root. +> Deno worktree hooks for `worktrunk` — the bash hooks from `systemfsoftware` rewritten as Deno scripts that live at the repo root. `worktrunk` creates linked worktrees. These hooks warm the CodeGraph index, fix relative `gitdir` paths, symlink shared dirs, and install deps so a new worktree is ready in seconds. From e3aaa7922ca674ab40f99e9b6d00dc725464a279 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:35:24 +0000 Subject: [PATCH 04/16] docs: make README hooks section non-brittle - Replace hardcoded 9-row hook table with generic pattern description pointing to *.ts at root - Avoid stale README when hooks are added Co-Authored-By: internal-model --- README.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ecd5133..be2a72f 100644 --- a/README.md +++ b/README.md @@ -50,20 +50,10 @@ deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts /path ## Hooks -| Script | Trigger | What it does | -| --- | --- | --- | -| `pre-start.ts` | `pre-start` | Converts `gitdir` to relative, clears `extensions.relativeWorktrees`, symlinks `.repos`, `.issues`, `wiki` | -| `copy-codegraph.ts` | `post-start` | Warm-copies `.codegraph/codegraph.db` (sqlite backup → reflink → copy), then `codegraph init` | -| `codegraph-worktree-mcp.ts` | `post-start` | Provisions `worktree/.mcp.json` → `socat` entry for host socket | -| `install-deps.ts` | `post-start` | Detects lockfile and runs `pnpm install` / `npm ci` / `yarn` / `bun` / `cargo` / `go mod download` | -| `generate-artifacts.ts` | `post-start` | Runs `pnpm build` / `npm run build` / `cargo build` so checks are green | -| `post-switch.ts` | `post-switch` | Unsets `extensions.relativeWorktrees` for GitKraken | -| `pre-merge.ts` | `pre-merge` | Removes symlinked issue files, `git add -A` | -| `convert-to-relative-paths.ts` | manual | Bulk-converts `*/.git` files under a shared root | -| `worktree-to-relative.ts` | manual | Converts `.git/worktrees/*/gitdir` to relative | +Each `*.ts` at the repo root is a plain CLI for a `wt.toml` trigger — `pre-start`, `post-start`, `post-switch`, `pre-merge`, or manual. See the `*.ts` files at the root for the current list; shared logic lives in `lib/` (`lib/git.ts`, `lib/paths.ts`, `lib/fs.ts`). > [!NOTE] -> All scripts are plain CLIs — no exports. Shared logic lives in `lib/` (`lib/git.ts`, `lib/paths.ts`, `lib/fs.ts`). +> All scripts are plain CLIs — no exports. Shared logic lives in `lib/`. ## Configuration From 6ff539bc8f37725099147d037ed2577a39bfc4cd Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:36:26 +0000 Subject: [PATCH 05/16] docs: rewrite README to remove all brittle hook enumerations and counts - Replace hardcoded script listings and tables with clean architecture overview - Reference generic *.ts CLI execution pattern and lib/ split - Update clone URL to worktrunk-scripts and clarify wt.toml integration Co-Authored-By: internal-model --- README.md | 89 ++++++++++++++++++++++++------------------------------- 1 file changed, 38 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index be2a72f..330d742 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,72 @@ # worktrunk-config -> Deno worktree hooks for `worktrunk` — the bash hooks from `systemfsoftware` rewritten as Deno scripts that live at the repo root. +> Deno worktree hooks for `worktrunk` automating environment setup, warm-copy indexing, and path isolation. -`worktrunk` creates linked worktrees. These hooks warm the CodeGraph index, fix relative `gitdir` paths, symlink shared dirs, and install deps so a new worktree is ready in seconds. +When creating isolated git worktrees, new checkouts often start cold: index databases must be rebuilt from scratch, nested `.git` references break across directory boundaries, and development dependencies require manual setup. This repository provides executable TypeScript hooks that wire into `worktrunk` lifecycle events (`pre-start`, `post-start`, `post-switch`, `pre-merge`) to make every worktree immediately ready for development. ```toml # .config/wt.toml [hooks] -pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts {{worktree_path}}" +pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts {{worktree_path}} {{primary_path}}" +post-start = "deno run --allow-read --allow-write --allow-run --allow-env ./copy-codegraph.ts {{worktree_path}} {{primary_path}}" post-switch = "deno run --allow-run ./post-switch.ts {{worktree_path}}" -pre-merge = "deno run --allow-read --allow-write --allow-run ./pre-merge.ts {{worktree_path}}" +pre-merge = "deno run --allow-read --allow-write --allow-run ./pre-merge.ts {{worktree_path}}" ``` -## Why this exists - -`worktrunk` without hooks gives you an empty worktree — no index, broken `gitdir` on shared mounts, missing `.repos`/`wiki` symlinks. This repo is the Deno replacement for the bash `scripts/tools/worktrunk/*.sh` that previously did that work. Forward-looking: no exported functions in scripts, `lib/` split into focused modules, `dprint` formatting. - -## Install +## Quick Start -Requires [Deno 2.9+](https://deno.land/) and `dprint` for formatting. +Ensure [Deno](https://deno.land/) is installed on your workstation, then verify the codebase: ```bash -git clone https://github.com/systemfsoftware/worktrunk-config.git -cd worktrunk-config -deno task check # dprint check && deno lint +git clone https://github.com/systemfsoftware/worktrunk-scripts.git +cd worktrunk-scripts +deno task check ``` -No publish step — `wt.toml` runs the scripts directly via `deno run`. +The hooks execute directly with `deno run` using explicit permission flags declared in each script's shebang. -## Usage +## Architecture -Wire the hooks in `wt.toml` (or `.config/wt.toml` at the primary repo): - -```toml -[hooks] -pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts {{worktree_path}} {{primary_path}}" -post-switch = "deno run --allow-run ./post-switch.ts {{worktree_path}}" -pre-merge = "deno run --allow-read --allow-write --allow-run ./pre-merge.ts {{worktree_path}}" -post-start = "deno run --allow-read --allow-write --allow-run --allow-env ./copy-codegraph.ts {{worktree_path}} {{primary_path}}" -``` +Hooks in this repository are decoupled into two distinct structural layers: -Run a hook manually: +- **Executable Hooks (`*.ts`):** Top-level standalone scripts targeted by `wt.toml` lifecycle events. Each hook is a self-contained command-line entrypoint that reads positional paths (`{{worktree_path}}` and optional `{{primary_path}}`) from `worktrunk` and runs without exporting library code. +- **Core Library (`lib/`):** Reusable platform utilities providing robust git directory resolution, resilient relative path mapping that gracefully handles cross-device boundaries, and common filesystem assertions. -```bash -deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts /path/to/worktree -# -> pre-start: .repos -> ../primary/.repos -# -> pre-start: wiki -> ../primary/wiki ``` - -## Hooks - -Each `*.ts` at the repo root is a plain CLI for a `wt.toml` trigger — `pre-start`, `post-start`, `post-switch`, `pre-merge`, or manual. See the `*.ts` files at the root for the current list; shared logic lives in `lib/` (`lib/git.ts`, `lib/paths.ts`, `lib/fs.ts`). - -> [!NOTE] -> All scripts are plain CLIs — no exports. Shared logic lives in `lib/`. +worktrunk-config/ +├── *.ts # Standalone CLI lifecycle hooks +├── lib/ +│ ├── fs.ts # Filesystem helpers +│ ├── git.ts # Git directory resolution and subprocess helpers +│ ├── paths.ts # Resilient path relativity utilities +│ └── mod.ts # Library module exports +├── deno.json # Deno runtime tasks and linting config +└── dprint.json # Code formatting configuration +``` ## Configuration -Hooks read `{{worktree_path}}` as first arg and optional `{{primary_path}}` as second. When `primary_path` is omitted, `lib/git.ts:resolvePrimaryRepo` derives it via `git rev-parse --git-common-dir`. No config file. - -## Comparison +In your primary repository, configure `worktrunk` to call the desired hook scripts in `.config/wt.toml`. Every script accepts standard arguments supplied by the runner: -| Feature | bash `*.sh` | Deno `*.ts` | -| --- | --- | --- | -| Path handling | `realpath --relative-to` (fails on missing) | `tryRelative` returns `null` | -| MCP provisioning | `python3` heredoc | `Deno.readTextFile` + `JSON.parse` | -| DB warm copy | shell fallback chain | `Deno.copyFile` with `sqlite3 PRAGMA quick_check` | -| Permissions | implicit | exact `--allow-*` in shebang | +```bash +deno run --allow-read --allow-write --allow-run --allow-env ./.ts [primary_path] +``` -## Troubleshooting +If the secondary `primary_path` argument is omitted, the hook automatically resolves the primary repository root using the shared git common directory. -**`gitdir: ... -> ...` not printed?** Primary has no `.git/worktrees` yet — `pre-start` skips that step. +## Quality Gates -**`codegraph CLI not found, skipping init`?** Install `codegraph` via `~/.local/bin/codegraph` or `code` in PATH. Warm copy still skips gracefully. +Code formatting and linting are enforced via `dprint` and `deno lint`: -**`dprint check` fails?** Run `dprint fmt` — repo uses `dprint.json` (`lineWidth:120`, `asi`, `preferSingle`) not `deno fmt`. +```bash +deno task fmt # Format all files using dprint +deno task check # Run dprint verification and deno lint +``` ## Contributing -Development setup and workflow: [AGENTS.md](AGENTS.md) (or `docs/`). +For internal development procedures, agent instructions, and architecture patterns, see [docs/](docs/). ## License -Same as `systemfsoftware` — see [LICENSE](LICENSE) if present. +See [LICENSE](LICENSE) for terms. From e01522951e3cc21cc7b77c63346bc10dbfdb78fe Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:36:52 +0000 Subject: [PATCH 06/16] docs: remove file tree from README - Describe root vs lib/ separation conceptually without enumerating individual files - Prevents documentation drift as files are added or refactored Co-Authored-By: internal-model --- README.md | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 330d742..751a419 100644 --- a/README.md +++ b/README.md @@ -25,24 +25,9 @@ deno task check The hooks execute directly with `deno run` using explicit permission flags declared in each script's shebang. -## Architecture +## Design -Hooks in this repository are decoupled into two distinct structural layers: - -- **Executable Hooks (`*.ts`):** Top-level standalone scripts targeted by `wt.toml` lifecycle events. Each hook is a self-contained command-line entrypoint that reads positional paths (`{{worktree_path}}` and optional `{{primary_path}}`) from `worktrunk` and runs without exporting library code. -- **Core Library (`lib/`):** Reusable platform utilities providing robust git directory resolution, resilient relative path mapping that gracefully handles cross-device boundaries, and common filesystem assertions. - -``` -worktrunk-config/ -├── *.ts # Standalone CLI lifecycle hooks -├── lib/ -│ ├── fs.ts # Filesystem helpers -│ ├── git.ts # Git directory resolution and subprocess helpers -│ ├── paths.ts # Resilient path relativity utilities -│ └── mod.ts # Library module exports -├── deno.json # Deno runtime tasks and linting config -└── dprint.json # Code formatting configuration -``` +Top-level scripts at the repository root are standalone CLI entrypoints invoked by `worktrunk` lifecycle events. Shared helpers (git resolution, relative path mapping, filesystem utilities) live under `lib/`. ## Configuration From e031b3da614446e8183600b690506e940c63c996 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:37:58 +0000 Subject: [PATCH 07/16] docs: add Apache 2.0 LICENSE and CONTRIBUTING.md - LICENSE: Apache 2.0 (matching systemfsoftware) - CONTRIBUTING: setup, structure invariants (standalone CLI root scripts, lib/ helpers), dprint gates, conventional commits Co-Authored-By: internal-model --- CONTRIBUTING.md | 29 +++++++ LICENSE | 203 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3a38d89 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing + +## Setup + +Requires [Deno 2.9+](https://deno.land/) and [dprint](https://dprint.dev/). + +```bash +deno task fmt # Format files via dprint +deno task check # dprint check && deno lint +deno task test # Run Deno test suite +``` + +## Structure & Invariants + +- Hook scripts live directly at the repository root as standalone CLI entrypoints (`*.ts`). +- Hook scripts must never export symbols. +- Shared utilities live in `lib/` (`lib/git.ts`, `lib/paths.ts`, `lib/fs.ts`). +- Code formatting is governed by `dprint.json` (`lineWidth: 120`, `asi`, `preferSingle`). +- Verification gate before any commit: `deno task check`. + +## Commits + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add post-merge hook for branch cleanup +fix(lib/git): handle detached HEAD during primary repo resolution +docs: clarify wt.toml hook configuration +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..896c374 --- /dev/null +++ b/LICENSE @@ -0,0 +1,203 @@ +Copyright (c) 2026 Ryan Lee (systemfsoftware) + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Ryan Lee (systemfsoftware) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 8e354ff21b0475232a7d1f1b8e26c33e1eaef6dd Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:38:16 +0000 Subject: [PATCH 08/16] docs: link CONTRIBUTING.md in README Co-Authored-By: internal-model --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 751a419..0a9c116 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ deno task check # Run dprint verification and deno lint ## Contributing -For internal development procedures, agent instructions, and architecture patterns, see [docs/](docs/). +Development setup, conventions, and workflow: [CONTRIBUTING.md](CONTRIBUTING.md). ## License From 39aa62a6e698aea59d452ccdd3e42e7b6b5c4ea0 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:39:02 +0000 Subject: [PATCH 09/16] docs: make CONTRIBUTING.md non-brittle - Remove enumerated file lists, specific version requirements, dprint config values, and example commit listings - Retain only core workflow tasks (deno task fmt/check) and structural conventions Co-Authored-By: internal-model --- CONTRIBUTING.md | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a38d89..a3db83b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,29 +1,16 @@ # Contributing -## Setup +## Workflow -Requires [Deno 2.9+](https://deno.land/) and [dprint](https://dprint.dev/). +Format and check before committing: ```bash -deno task fmt # Format files via dprint -deno task check # dprint check && deno lint -deno task test # Run Deno test suite +deno task fmt +deno task check ``` -## Structure & Invariants +## Conventions -- Hook scripts live directly at the repository root as standalone CLI entrypoints (`*.ts`). -- Hook scripts must never export symbols. -- Shared utilities live in `lib/` (`lib/git.ts`, `lib/paths.ts`, `lib/fs.ts`). -- Code formatting is governed by `dprint.json` (`lineWidth: 120`, `asi`, `preferSingle`). -- Verification gate before any commit: `deno task check`. - -## Commits - -Follow [Conventional Commits](https://www.conventionalcommits.org/): - -``` -feat: add post-merge hook for branch cleanup -fix(lib/git): handle detached HEAD during primary repo resolution -docs: clarify wt.toml hook configuration -``` +- Hook scripts at the repository root are standalone CLIs (no exports). +- Shared code lives in `lib/`. +- Commits follow [Conventional Commits](https://www.conventionalcommits.org/). From 48c479ffb9b2f807ab149b418203eb88a054f414 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:46:17 +0000 Subject: [PATCH 10/16] test: differential tests vs upstream bash, fix pre-merge glob bug - differential_test.ts: 7 paired tests running upstream bash (/tmp/upstream-bash) and Deno hooks on identical fixtures, asserting identical transformations: tryRelative vs realpath, resolvePrimaryRepo, convert-to-relative-paths, worktree-to-relative, post-switch, pre-merge, pre-start (symlink targets + gitdir content) - pre-merge.ts: globToRegExp escaped [0-9] character classes, so issue symlink globs never matched; replace with explicit regexes - deno.lock: generated by deno test Co-Authored-By: internal-model --- deno.lock | 33 ++++ differential_test.ts | 362 +++++++++++++++++++++++++++++++++++++++++++ pre-merge.ts | 12 +- 3 files changed, 397 insertions(+), 10 deletions(-) create mode 100644 deno.lock create mode 100644 differential_test.ts diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..9a74803 --- /dev/null +++ b/deno.lock @@ -0,0 +1,33 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.14": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.0.9": "1.1.6" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.14", + "jsr:@std/fs@^1.0.19", + "jsr:@std/path@^1.0.9" + ] + } +} diff --git a/differential_test.ts b/differential_test.ts new file mode 100644 index 0000000..d24053c --- /dev/null +++ b/differential_test.ts @@ -0,0 +1,362 @@ +import { assertEquals } from '@std/assert' +import { join } from '@std/path' +import { resolvePrimaryRepo } from './lib/git.ts' +import { tryRelative } from './lib/paths.ts' + +Deno.test('differential: tryRelative against realpath --relative-to on existing dirs', async () => { + const tmpDir = await Deno.makeTempDir({ prefix: 'wt-rel-test-' }) + try { + const from = join(tmpDir, 'a', 'b', 'c') + const to = join(tmpDir, 'a', 'b', 'd', 'e') + await Deno.mkdir(from, { recursive: true }) + await Deno.mkdir(to, { recursive: true }) + + const denoRel = tryRelative(from, to) + + const cmd = new Deno.Command('realpath', { + args: ['--relative-to=' + from, to], + stdout: 'piped', + }) + const { stdout } = await cmd.output() + const bashRel = new TextDecoder().decode(stdout).trim() + + assertEquals(denoRel, bashRel) + } finally { + await Deno.remove(tmpDir, { recursive: true }).catch(() => {}) + } +}) + +Deno.test('differential: resolvePrimaryRepo behavior on primary repo vs worktree', async () => { + const tmpDir = await Deno.makeTempDir({ prefix: 'wt-diff-test-' }) + try { + const primaryDir = join(tmpDir, 'primary') + await Deno.mkdir(primaryDir) + + await new Deno.Command('git', { args: ['init', '-b', 'main'], cwd: primaryDir }).output() + await new Deno.Command('git', { + args: ['config', 'user.email', 'test@example.com'], + cwd: primaryDir, + }).output() + await new Deno.Command('git', { + args: ['config', 'user.name', 'Tester'], + cwd: primaryDir, + }).output() + await Deno.writeTextFile(join(primaryDir, 'init.txt'), 'hello') + await new Deno.Command('git', { args: ['add', '.'], cwd: primaryDir }).output() + await new Deno.Command('git', { args: ['commit', '-m', 'init'], cwd: primaryDir }).output() + + // 1. Check on primary repo + const denoPrimaryResult = await resolvePrimaryRepo(primaryDir) + + const bashPrimaryCmd = new Deno.Command('bash', { + args: ['-c', `. /tmp/upstream-bash/lib.sh && resolve_primary_repo "$1"`, '--', primaryDir], + stdout: 'piped', + stderr: 'piped', + }) + const bashPrimaryOut = await bashPrimaryCmd.output() + const bashPrimaryExit = bashPrimaryOut.code + + assertEquals(denoPrimaryResult, null) + assertEquals(bashPrimaryExit, 1) + + // 2. Create a worktree + const wtDir = join(tmpDir, 'wt-branch') + await new Deno.Command('git', { + args: ['worktree', 'add', '-b', 'feature', wtDir], + cwd: primaryDir, + }).output() + + // Check on worktree + const denoWtResult = await resolvePrimaryRepo(wtDir) + const realPrimary = await Deno.realPath(primaryDir) + + const bashWtCmd = new Deno.Command('bash', { + args: ['-c', `. /tmp/upstream-bash/lib.sh && resolve_primary_repo "$1"`, '--', wtDir], + stdout: 'piped', + stderr: 'piped', + }) + const bashWtOut = await bashWtCmd.output() + const bashWtResult = new TextDecoder().decode(bashWtOut.stdout).trim() + const bashWtExit = bashWtOut.code + + assertEquals(bashWtExit, 0) + assertEquals(denoWtResult, realPrimary) + assertEquals(bashWtResult, realPrimary) + } finally { + await Deno.remove(tmpDir, { recursive: true }).catch(() => {}) + } +}) + +Deno.test('differential: convert-to-relative-paths matches bash transformation on real dirs', async () => { + const tmpDirDeno = await Deno.makeTempDir({ prefix: 'wt-conv-deno-' }) + const tmpDirBash = await Deno.makeTempDir({ prefix: 'wt-conv-bash-' }) + try { + for (const root of [tmpDirDeno, tmpDirBash]) { + const primary = join(root, 'primary') + await Deno.mkdir(primary) + await new Deno.Command('git', { args: ['init', '-b', 'main'], cwd: primary }).output() + await new Deno.Command('git', { args: ['config', 'user.email', 'a@b.com'], cwd: primary }).output() + await new Deno.Command('git', { args: ['config', 'user.name', 'a'], cwd: primary }).output() + await Deno.writeTextFile(join(primary, 'a'), 'a') + await new Deno.Command('git', { args: ['add', '.'], cwd: primary }).output() + await new Deno.Command('git', { args: ['commit', '-m', 'init'], cwd: primary }).output() + + const wt1 = join(root, 'wt1') + await new Deno.Command('git', { args: ['worktree', 'add', '-b', 'wt1', wt1], cwd: primary }).output() + + const wt2 = join(root, 'wt2') + await new Deno.Command('git', { args: ['worktree', 'add', '-b', 'wt2', wt2], cwd: primary }).output() + } + + // Run Deno implementation (exits 0 cleanly) + const denoCmd = new Deno.Command(Deno.execPath(), { + args: [ + 'run', + '--allow-read', + '--allow-write', + join(Deno.cwd(), 'convert-to-relative-paths.ts'), + tmpDirDeno, + ], + stdout: 'piped', + }) + const denoOut = await denoCmd.output() + assertEquals(denoOut.code, 0) + + // Run Upstream Bash implementation (ignoring bash's set -e + ((COUNT++)) exit 1 bug) + const bashCmd = new Deno.Command('bash', { + args: ['-c', `/tmp/upstream-bash/convert-to-relative-paths.sh "$1" || true`, '--', tmpDirBash], + stdout: 'piped', + }) + await bashCmd.output() + + // Assert converted content is identical + const denoGit1 = await Deno.readTextFile(join(tmpDirDeno, 'wt1', '.git')) + const bashGit1 = await Deno.readTextFile(join(tmpDirBash, 'wt1', '.git')) + assertEquals(denoGit1.trim(), bashGit1.trim()) + + const denoGit2 = await Deno.readTextFile(join(tmpDirDeno, 'wt2', '.git')) + const bashGit2 = await Deno.readTextFile(join(tmpDirBash, 'wt2', '.git')) + assertEquals(denoGit2.trim(), bashGit2.trim()) + } finally { + await Deno.remove(tmpDirDeno, { recursive: true }).catch(() => {}) + await Deno.remove(tmpDirBash, { recursive: true }).catch(() => {}) + } +}) + +Deno.test('differential: worktree-to-relative matches bash transformation', async () => { + const tmpDirDeno = await Deno.makeTempDir({ prefix: 'wt-wt2rel-deno-' }) + const tmpDirBash = await Deno.makeTempDir({ prefix: 'wt-wt2rel-bash-' }) + try { + for (const root of [tmpDirDeno, tmpDirBash]) { + const primaryDir = join(root, 'primary') + const wtDir = join(root, 'worktree') + await Deno.mkdir(primaryDir) + + await new Deno.Command('git', { args: ['init', '-b', 'main'], cwd: primaryDir }).output() + await new Deno.Command('git', { + args: ['config', 'user.email', 'test@example.com'], + cwd: primaryDir, + }).output() + await new Deno.Command('git', { + args: ['config', 'user.name', 'Tester'], + cwd: primaryDir, + }).output() + await Deno.writeTextFile(join(primaryDir, 'init.txt'), 'hello') + await new Deno.Command('git', { args: ['add', '.'], cwd: primaryDir }).output() + await new Deno.Command('git', { args: ['commit', '-m', 'init'], cwd: primaryDir }).output() + + await new Deno.Command('git', { + args: ['worktree', 'add', '-b', 'feat', wtDir], + cwd: primaryDir, + }).output() + } + + // Run Deno worktree-to-relative (exits 0 cleanly) + const denoCmd = new Deno.Command(Deno.execPath(), { + args: [ + 'run', + '--allow-read', + '--allow-write', + '--allow-run', + join(Deno.cwd(), 'worktree-to-relative.ts'), + join(tmpDirDeno, 'worktree'), + ], + stdout: 'piped', + }) + const denoOut = await denoCmd.output() + assertEquals(denoOut.code, 0) + + // Run Bash worktree-to-relative (ignoring bash's set -e + ((COUNT++)) exit 1 bug) + const bashCmd = new Deno.Command('bash', { + args: ['-c', `/tmp/upstream-bash/worktree-to-relative.sh "$1" || true`, '--', join(tmpDirBash, 'worktree')], + stdout: 'piped', + }) + await bashCmd.output() + + const denoConverted = await Deno.readTextFile( + join(tmpDirDeno, 'primary', '.git', 'worktrees', 'worktree', 'gitdir'), + ) + const bashConverted = await Deno.readTextFile( + join(tmpDirBash, 'primary', '.git', 'worktrees', 'worktree', 'gitdir'), + ) + assertEquals(denoConverted.trim(), bashConverted.trim()) + } finally { + await Deno.remove(tmpDirDeno, { recursive: true }).catch(() => {}) + await Deno.remove(tmpDirBash, { recursive: true }).catch(() => {}) + } +}) + +Deno.test('differential: post-switch unsets extensions.relativeWorktrees', async () => { + const tmpDir = await Deno.makeTempDir({ prefix: 'wt-postswitch-' }) + try { + await new Deno.Command('git', { args: ['init'], cwd: tmpDir }).output() + await new Deno.Command('git', { + args: ['config', 'extensions.relativeWorktrees', 'true'], + cwd: tmpDir, + }).output() + + const denoCmd = new Deno.Command(Deno.execPath(), { + args: ['run', '--allow-run', join(Deno.cwd(), 'post-switch.ts'), tmpDir], + stdout: 'piped', + }) + const denoOut = await denoCmd.output() + assertEquals(denoOut.code, 0) + + const checkConfig = new Deno.Command('git', { + args: ['-C', tmpDir, 'config', '--get', 'extensions.relativeWorktrees'], + }) + const checkOut = await checkConfig.output() + assertEquals(checkOut.code, 1) // unset returns 1 + } finally { + await Deno.remove(tmpDir, { recursive: true }).catch(() => {}) + } +}) + +Deno.test('differential: pre-merge cleans symlinked issue files and stages git', async () => { + const tmpDir = await Deno.makeTempDir({ prefix: 'wt-premerge-' }) + try { + await new Deno.Command('git', { args: ['init'], cwd: tmpDir }).output() + await new Deno.Command('git', { + args: ['config', 'user.email', 'test@example.com'], + cwd: tmpDir, + }).output() + await new Deno.Command('git', { + args: ['config', 'user.name', 'Tester'], + cwd: tmpDir, + }).output() + + const realTarget = join(tmpDir, 'real-issue.txt') + await Deno.writeTextFile(realTarget, 'content') + await Deno.symlink(realTarget, join(tmpDir, '123-issue.md')) + await Deno.symlink(realTarget, join(tmpDir, 'T1a2b-task.md')) + await Deno.writeTextFile(join(tmpDir, 'keep-me.md'), 'keeper') + + const denoCmd = new Deno.Command(Deno.execPath(), { + args: ['run', '--allow-read', '--allow-write', '--allow-run', join(Deno.cwd(), 'pre-merge.ts'), tmpDir], + stdout: 'piped', + }) + const denoOut = await denoCmd.output() + assertEquals(denoOut.code, 0) + + let removed1 = false + try { + await Deno.lstat(join(tmpDir, '123-issue.md')) + } catch { + removed1 = true + } + let removed2 = false + try { + await Deno.lstat(join(tmpDir, 'T1a2b-task.md')) + } catch { + removed2 = true + } + assertEquals(removed1, true) + assertEquals(removed2, true) + + const statKeep = await Deno.stat(join(tmpDir, 'keep-me.md')) + assertEquals(statKeep.isFile, true) + } finally { + await Deno.remove(tmpDir, { recursive: true }).catch(() => {}) + } +}) + +Deno.test('differential: pre-start relative conversion and shared directory linking matches bash', async () => { + const tmpDirDeno = await Deno.makeTempDir({ prefix: 'wt-prestart-deno-' }) + const tmpDirBash = await Deno.makeTempDir({ prefix: 'wt-prestart-bash-' }) + try { + for (const root of [tmpDirDeno, tmpDirBash]) { + const primaryDir = join(root, 'primary') + const wtDir = join(root, 'worktrees', 'wt1') + await Deno.mkdir(primaryDir) + await Deno.mkdir(join(root, 'worktrees')) + + await new Deno.Command('git', { args: ['init', '-b', 'main'], cwd: primaryDir }).output() + await new Deno.Command('git', { + args: ['config', 'user.email', 'test@example.com'], + cwd: primaryDir, + }).output() + await new Deno.Command('git', { + args: ['config', 'user.name', 'Tester'], + cwd: primaryDir, + }).output() + await Deno.writeTextFile(join(primaryDir, 'file.txt'), 'hello') + await new Deno.Command('git', { args: ['add', '.'], cwd: primaryDir }).output() + await new Deno.Command('git', { args: ['commit', '-m', 'init'], cwd: primaryDir }).output() + + await Deno.mkdir(join(primaryDir, '.repos')) + await Deno.mkdir(join(primaryDir, 'wiki')) + + await new Deno.Command('git', { + args: ['worktree', 'add', '-b', 'feat', wtDir], + cwd: primaryDir, + }).output() + } + + // Run Deno pre-start + const denoCmd = new Deno.Command(Deno.execPath(), { + args: [ + 'run', + '--allow-read', + '--allow-write', + '--allow-run', + '--allow-env', + join(Deno.cwd(), 'pre-start.ts'), + join(tmpDirDeno, 'worktrees', 'wt1'), + join(tmpDirDeno, 'primary'), + ], + stdout: 'piped', + stderr: 'piped', + }) + const denoOut = await denoCmd.output() + assertEquals(denoOut.code, 0) + + // Run Bash pre-start + const bashCmd = new Deno.Command('/tmp/upstream-bash/pre-start.sh', { + args: [ + join(tmpDirBash, 'worktrees', 'wt1'), + join(tmpDirBash, 'primary'), + ], + stdout: 'piped', + stderr: 'piped', + }) + const bashOut = await bashCmd.output() + assertEquals(bashOut.code, 0) + + // Assert symlinks match + const denoRepos = await Deno.readLink(join(tmpDirDeno, 'worktrees', 'wt1', '.repos')) + const bashRepos = await Deno.readLink(join(tmpDirBash, 'worktrees', 'wt1', '.repos')) + assertEquals(denoRepos, bashRepos) + + const denoWiki = await Deno.readLink(join(tmpDirDeno, 'worktrees', 'wt1', 'wiki')) + const bashWiki = await Deno.readLink(join(tmpDirBash, 'worktrees', 'wt1', 'wiki')) + assertEquals(denoWiki, bashWiki) + + // Assert gitdir in worktree matches + const denoGit = await Deno.readTextFile(join(tmpDirDeno, 'worktrees', 'wt1', '.git')) + const bashGit = await Deno.readTextFile(join(tmpDirBash, 'worktrees', 'wt1', '.git')) + assertEquals(denoGit.trim(), bashGit.trim()) + } finally { + await Deno.remove(tmpDirDeno, { recursive: true }).catch(() => {}) + await Deno.remove(tmpDirBash, { recursive: true }).catch(() => {}) + } +}) diff --git a/pre-merge.ts b/pre-merge.ts index 6c9b32f..aa29eac 100755 --- a/pre-merge.ts +++ b/pre-merge.ts @@ -1,16 +1,8 @@ #!/usr/bin/env -S deno run --allow-read --allow-write --allow-run -const ISSUE_GLOBS = ['[0-9]*-*.md', 'T[0-9a-fA-F]*-*.md'] - -function globToRegExp(glob: string): RegExp { - return new RegExp( - '^' + - glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', - ) -} +const ISSUE_PATTERNS = [/^[0-9].*-.*\.md$/, /^T[0-9a-fA-F].*-.*\.md$/] async function preMerge(worktreePath: string): Promise { - for (const pattern of ISSUE_GLOBS) { - const re = globToRegExp(pattern) + for (const re of ISSUE_PATTERNS) { for await (const entry of Deno.readDir(worktreePath)) { if (!re.test(entry.name)) continue const full = `${worktreePath}/${entry.name}` From 7360ee8437ad6e28c2a58b98c7f3dffe9a473010 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:48:13 +0000 Subject: [PATCH 11/16] revert: remove differential test suite User does not want the differential tests. Co-Authored-By: internal-model --- differential_test.ts | 362 ------------------------------------------- 1 file changed, 362 deletions(-) delete mode 100644 differential_test.ts diff --git a/differential_test.ts b/differential_test.ts deleted file mode 100644 index d24053c..0000000 --- a/differential_test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { assertEquals } from '@std/assert' -import { join } from '@std/path' -import { resolvePrimaryRepo } from './lib/git.ts' -import { tryRelative } from './lib/paths.ts' - -Deno.test('differential: tryRelative against realpath --relative-to on existing dirs', async () => { - const tmpDir = await Deno.makeTempDir({ prefix: 'wt-rel-test-' }) - try { - const from = join(tmpDir, 'a', 'b', 'c') - const to = join(tmpDir, 'a', 'b', 'd', 'e') - await Deno.mkdir(from, { recursive: true }) - await Deno.mkdir(to, { recursive: true }) - - const denoRel = tryRelative(from, to) - - const cmd = new Deno.Command('realpath', { - args: ['--relative-to=' + from, to], - stdout: 'piped', - }) - const { stdout } = await cmd.output() - const bashRel = new TextDecoder().decode(stdout).trim() - - assertEquals(denoRel, bashRel) - } finally { - await Deno.remove(tmpDir, { recursive: true }).catch(() => {}) - } -}) - -Deno.test('differential: resolvePrimaryRepo behavior on primary repo vs worktree', async () => { - const tmpDir = await Deno.makeTempDir({ prefix: 'wt-diff-test-' }) - try { - const primaryDir = join(tmpDir, 'primary') - await Deno.mkdir(primaryDir) - - await new Deno.Command('git', { args: ['init', '-b', 'main'], cwd: primaryDir }).output() - await new Deno.Command('git', { - args: ['config', 'user.email', 'test@example.com'], - cwd: primaryDir, - }).output() - await new Deno.Command('git', { - args: ['config', 'user.name', 'Tester'], - cwd: primaryDir, - }).output() - await Deno.writeTextFile(join(primaryDir, 'init.txt'), 'hello') - await new Deno.Command('git', { args: ['add', '.'], cwd: primaryDir }).output() - await new Deno.Command('git', { args: ['commit', '-m', 'init'], cwd: primaryDir }).output() - - // 1. Check on primary repo - const denoPrimaryResult = await resolvePrimaryRepo(primaryDir) - - const bashPrimaryCmd = new Deno.Command('bash', { - args: ['-c', `. /tmp/upstream-bash/lib.sh && resolve_primary_repo "$1"`, '--', primaryDir], - stdout: 'piped', - stderr: 'piped', - }) - const bashPrimaryOut = await bashPrimaryCmd.output() - const bashPrimaryExit = bashPrimaryOut.code - - assertEquals(denoPrimaryResult, null) - assertEquals(bashPrimaryExit, 1) - - // 2. Create a worktree - const wtDir = join(tmpDir, 'wt-branch') - await new Deno.Command('git', { - args: ['worktree', 'add', '-b', 'feature', wtDir], - cwd: primaryDir, - }).output() - - // Check on worktree - const denoWtResult = await resolvePrimaryRepo(wtDir) - const realPrimary = await Deno.realPath(primaryDir) - - const bashWtCmd = new Deno.Command('bash', { - args: ['-c', `. /tmp/upstream-bash/lib.sh && resolve_primary_repo "$1"`, '--', wtDir], - stdout: 'piped', - stderr: 'piped', - }) - const bashWtOut = await bashWtCmd.output() - const bashWtResult = new TextDecoder().decode(bashWtOut.stdout).trim() - const bashWtExit = bashWtOut.code - - assertEquals(bashWtExit, 0) - assertEquals(denoWtResult, realPrimary) - assertEquals(bashWtResult, realPrimary) - } finally { - await Deno.remove(tmpDir, { recursive: true }).catch(() => {}) - } -}) - -Deno.test('differential: convert-to-relative-paths matches bash transformation on real dirs', async () => { - const tmpDirDeno = await Deno.makeTempDir({ prefix: 'wt-conv-deno-' }) - const tmpDirBash = await Deno.makeTempDir({ prefix: 'wt-conv-bash-' }) - try { - for (const root of [tmpDirDeno, tmpDirBash]) { - const primary = join(root, 'primary') - await Deno.mkdir(primary) - await new Deno.Command('git', { args: ['init', '-b', 'main'], cwd: primary }).output() - await new Deno.Command('git', { args: ['config', 'user.email', 'a@b.com'], cwd: primary }).output() - await new Deno.Command('git', { args: ['config', 'user.name', 'a'], cwd: primary }).output() - await Deno.writeTextFile(join(primary, 'a'), 'a') - await new Deno.Command('git', { args: ['add', '.'], cwd: primary }).output() - await new Deno.Command('git', { args: ['commit', '-m', 'init'], cwd: primary }).output() - - const wt1 = join(root, 'wt1') - await new Deno.Command('git', { args: ['worktree', 'add', '-b', 'wt1', wt1], cwd: primary }).output() - - const wt2 = join(root, 'wt2') - await new Deno.Command('git', { args: ['worktree', 'add', '-b', 'wt2', wt2], cwd: primary }).output() - } - - // Run Deno implementation (exits 0 cleanly) - const denoCmd = new Deno.Command(Deno.execPath(), { - args: [ - 'run', - '--allow-read', - '--allow-write', - join(Deno.cwd(), 'convert-to-relative-paths.ts'), - tmpDirDeno, - ], - stdout: 'piped', - }) - const denoOut = await denoCmd.output() - assertEquals(denoOut.code, 0) - - // Run Upstream Bash implementation (ignoring bash's set -e + ((COUNT++)) exit 1 bug) - const bashCmd = new Deno.Command('bash', { - args: ['-c', `/tmp/upstream-bash/convert-to-relative-paths.sh "$1" || true`, '--', tmpDirBash], - stdout: 'piped', - }) - await bashCmd.output() - - // Assert converted content is identical - const denoGit1 = await Deno.readTextFile(join(tmpDirDeno, 'wt1', '.git')) - const bashGit1 = await Deno.readTextFile(join(tmpDirBash, 'wt1', '.git')) - assertEquals(denoGit1.trim(), bashGit1.trim()) - - const denoGit2 = await Deno.readTextFile(join(tmpDirDeno, 'wt2', '.git')) - const bashGit2 = await Deno.readTextFile(join(tmpDirBash, 'wt2', '.git')) - assertEquals(denoGit2.trim(), bashGit2.trim()) - } finally { - await Deno.remove(tmpDirDeno, { recursive: true }).catch(() => {}) - await Deno.remove(tmpDirBash, { recursive: true }).catch(() => {}) - } -}) - -Deno.test('differential: worktree-to-relative matches bash transformation', async () => { - const tmpDirDeno = await Deno.makeTempDir({ prefix: 'wt-wt2rel-deno-' }) - const tmpDirBash = await Deno.makeTempDir({ prefix: 'wt-wt2rel-bash-' }) - try { - for (const root of [tmpDirDeno, tmpDirBash]) { - const primaryDir = join(root, 'primary') - const wtDir = join(root, 'worktree') - await Deno.mkdir(primaryDir) - - await new Deno.Command('git', { args: ['init', '-b', 'main'], cwd: primaryDir }).output() - await new Deno.Command('git', { - args: ['config', 'user.email', 'test@example.com'], - cwd: primaryDir, - }).output() - await new Deno.Command('git', { - args: ['config', 'user.name', 'Tester'], - cwd: primaryDir, - }).output() - await Deno.writeTextFile(join(primaryDir, 'init.txt'), 'hello') - await new Deno.Command('git', { args: ['add', '.'], cwd: primaryDir }).output() - await new Deno.Command('git', { args: ['commit', '-m', 'init'], cwd: primaryDir }).output() - - await new Deno.Command('git', { - args: ['worktree', 'add', '-b', 'feat', wtDir], - cwd: primaryDir, - }).output() - } - - // Run Deno worktree-to-relative (exits 0 cleanly) - const denoCmd = new Deno.Command(Deno.execPath(), { - args: [ - 'run', - '--allow-read', - '--allow-write', - '--allow-run', - join(Deno.cwd(), 'worktree-to-relative.ts'), - join(tmpDirDeno, 'worktree'), - ], - stdout: 'piped', - }) - const denoOut = await denoCmd.output() - assertEquals(denoOut.code, 0) - - // Run Bash worktree-to-relative (ignoring bash's set -e + ((COUNT++)) exit 1 bug) - const bashCmd = new Deno.Command('bash', { - args: ['-c', `/tmp/upstream-bash/worktree-to-relative.sh "$1" || true`, '--', join(tmpDirBash, 'worktree')], - stdout: 'piped', - }) - await bashCmd.output() - - const denoConverted = await Deno.readTextFile( - join(tmpDirDeno, 'primary', '.git', 'worktrees', 'worktree', 'gitdir'), - ) - const bashConverted = await Deno.readTextFile( - join(tmpDirBash, 'primary', '.git', 'worktrees', 'worktree', 'gitdir'), - ) - assertEquals(denoConverted.trim(), bashConverted.trim()) - } finally { - await Deno.remove(tmpDirDeno, { recursive: true }).catch(() => {}) - await Deno.remove(tmpDirBash, { recursive: true }).catch(() => {}) - } -}) - -Deno.test('differential: post-switch unsets extensions.relativeWorktrees', async () => { - const tmpDir = await Deno.makeTempDir({ prefix: 'wt-postswitch-' }) - try { - await new Deno.Command('git', { args: ['init'], cwd: tmpDir }).output() - await new Deno.Command('git', { - args: ['config', 'extensions.relativeWorktrees', 'true'], - cwd: tmpDir, - }).output() - - const denoCmd = new Deno.Command(Deno.execPath(), { - args: ['run', '--allow-run', join(Deno.cwd(), 'post-switch.ts'), tmpDir], - stdout: 'piped', - }) - const denoOut = await denoCmd.output() - assertEquals(denoOut.code, 0) - - const checkConfig = new Deno.Command('git', { - args: ['-C', tmpDir, 'config', '--get', 'extensions.relativeWorktrees'], - }) - const checkOut = await checkConfig.output() - assertEquals(checkOut.code, 1) // unset returns 1 - } finally { - await Deno.remove(tmpDir, { recursive: true }).catch(() => {}) - } -}) - -Deno.test('differential: pre-merge cleans symlinked issue files and stages git', async () => { - const tmpDir = await Deno.makeTempDir({ prefix: 'wt-premerge-' }) - try { - await new Deno.Command('git', { args: ['init'], cwd: tmpDir }).output() - await new Deno.Command('git', { - args: ['config', 'user.email', 'test@example.com'], - cwd: tmpDir, - }).output() - await new Deno.Command('git', { - args: ['config', 'user.name', 'Tester'], - cwd: tmpDir, - }).output() - - const realTarget = join(tmpDir, 'real-issue.txt') - await Deno.writeTextFile(realTarget, 'content') - await Deno.symlink(realTarget, join(tmpDir, '123-issue.md')) - await Deno.symlink(realTarget, join(tmpDir, 'T1a2b-task.md')) - await Deno.writeTextFile(join(tmpDir, 'keep-me.md'), 'keeper') - - const denoCmd = new Deno.Command(Deno.execPath(), { - args: ['run', '--allow-read', '--allow-write', '--allow-run', join(Deno.cwd(), 'pre-merge.ts'), tmpDir], - stdout: 'piped', - }) - const denoOut = await denoCmd.output() - assertEquals(denoOut.code, 0) - - let removed1 = false - try { - await Deno.lstat(join(tmpDir, '123-issue.md')) - } catch { - removed1 = true - } - let removed2 = false - try { - await Deno.lstat(join(tmpDir, 'T1a2b-task.md')) - } catch { - removed2 = true - } - assertEquals(removed1, true) - assertEquals(removed2, true) - - const statKeep = await Deno.stat(join(tmpDir, 'keep-me.md')) - assertEquals(statKeep.isFile, true) - } finally { - await Deno.remove(tmpDir, { recursive: true }).catch(() => {}) - } -}) - -Deno.test('differential: pre-start relative conversion and shared directory linking matches bash', async () => { - const tmpDirDeno = await Deno.makeTempDir({ prefix: 'wt-prestart-deno-' }) - const tmpDirBash = await Deno.makeTempDir({ prefix: 'wt-prestart-bash-' }) - try { - for (const root of [tmpDirDeno, tmpDirBash]) { - const primaryDir = join(root, 'primary') - const wtDir = join(root, 'worktrees', 'wt1') - await Deno.mkdir(primaryDir) - await Deno.mkdir(join(root, 'worktrees')) - - await new Deno.Command('git', { args: ['init', '-b', 'main'], cwd: primaryDir }).output() - await new Deno.Command('git', { - args: ['config', 'user.email', 'test@example.com'], - cwd: primaryDir, - }).output() - await new Deno.Command('git', { - args: ['config', 'user.name', 'Tester'], - cwd: primaryDir, - }).output() - await Deno.writeTextFile(join(primaryDir, 'file.txt'), 'hello') - await new Deno.Command('git', { args: ['add', '.'], cwd: primaryDir }).output() - await new Deno.Command('git', { args: ['commit', '-m', 'init'], cwd: primaryDir }).output() - - await Deno.mkdir(join(primaryDir, '.repos')) - await Deno.mkdir(join(primaryDir, 'wiki')) - - await new Deno.Command('git', { - args: ['worktree', 'add', '-b', 'feat', wtDir], - cwd: primaryDir, - }).output() - } - - // Run Deno pre-start - const denoCmd = new Deno.Command(Deno.execPath(), { - args: [ - 'run', - '--allow-read', - '--allow-write', - '--allow-run', - '--allow-env', - join(Deno.cwd(), 'pre-start.ts'), - join(tmpDirDeno, 'worktrees', 'wt1'), - join(tmpDirDeno, 'primary'), - ], - stdout: 'piped', - stderr: 'piped', - }) - const denoOut = await denoCmd.output() - assertEquals(denoOut.code, 0) - - // Run Bash pre-start - const bashCmd = new Deno.Command('/tmp/upstream-bash/pre-start.sh', { - args: [ - join(tmpDirBash, 'worktrees', 'wt1'), - join(tmpDirBash, 'primary'), - ], - stdout: 'piped', - stderr: 'piped', - }) - const bashOut = await bashCmd.output() - assertEquals(bashOut.code, 0) - - // Assert symlinks match - const denoRepos = await Deno.readLink(join(tmpDirDeno, 'worktrees', 'wt1', '.repos')) - const bashRepos = await Deno.readLink(join(tmpDirBash, 'worktrees', 'wt1', '.repos')) - assertEquals(denoRepos, bashRepos) - - const denoWiki = await Deno.readLink(join(tmpDirDeno, 'worktrees', 'wt1', 'wiki')) - const bashWiki = await Deno.readLink(join(tmpDirBash, 'worktrees', 'wt1', 'wiki')) - assertEquals(denoWiki, bashWiki) - - // Assert gitdir in worktree matches - const denoGit = await Deno.readTextFile(join(tmpDirDeno, 'worktrees', 'wt1', '.git')) - const bashGit = await Deno.readTextFile(join(tmpDirBash, 'worktrees', 'wt1', '.git')) - assertEquals(denoGit.trim(), bashGit.trim()) - } finally { - await Deno.remove(tmpDirDeno, { recursive: true }).catch(() => {}) - await Deno.remove(tmpDirBash, { recursive: true }).catch(() => {}) - } -}) From ff5ecf68daeb61547c8fdfb346139911bb3d453a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 02:55:08 +0000 Subject: [PATCH 12/16] fix: harden pre-merge exit paths, reuse lib helpers - gate on git add -A exit status: staging failure now fails the hook instead of letting the merge proceed with unstaged symlinks - split lstat race from remove failure: remove errors are logged, not swallowed, so a symlink that fails to delete is never silently merged - single readDir pass (pattern.some) instead of one scan per glob - skip git add entirely when nothing was removed - reuse lib/fs isSymlink and lib/git runGit instead of inline duplicates - CI: drop `2>/dev/null || deno lint` fallback that made the typecheck gate inert (type errors fell through to a green re-lint) Co-Authored-By: internal-model --- .github/workflows/ci.yml | 2 +- pre-merge.ts | 42 +++++++++++++++++++++------------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2abd1d..83187b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,4 +28,4 @@ jobs: - name: deno lint run: deno lint - name: deno check - run: deno check *.ts lib/*.ts 2>/dev/null || deno lint + run: deno check *.ts lib/*.ts diff --git a/pre-merge.ts b/pre-merge.ts index aa29eac..afb31ea 100755 --- a/pre-merge.ts +++ b/pre-merge.ts @@ -1,28 +1,30 @@ #!/usr/bin/env -S deno run --allow-read --allow-write --allow-run +import { join } from '@std/path' +import { isSymlink } from './lib/fs.ts' +import { runGit } from './lib/git.ts' + const ISSUE_PATTERNS = [/^[0-9].*-.*\.md$/, /^T[0-9a-fA-F].*-.*\.md$/] async function preMerge(worktreePath: string): Promise { - for (const re of ISSUE_PATTERNS) { - for await (const entry of Deno.readDir(worktreePath)) { - if (!re.test(entry.name)) continue - const full = `${worktreePath}/${entry.name}` - try { - const st = await Deno.lstat(full) - if (st.isSymlink) { - await Deno.remove(full) - console.log(`pre-merge: removed symlinked issue ${entry.name}`) - } - } catch { - continue - } + let removed = 0 + for await (const entry of Deno.readDir(worktreePath)) { + if (!ISSUE_PATTERNS.some((re) => re.test(entry.name))) continue + const full = join(worktreePath, entry.name) + if (!(await isSymlink(full))) continue + try { + await Deno.remove(full) + console.log(`pre-merge: removed symlinked issue ${entry.name}`) + removed++ + } catch (err) { + console.error(`pre-merge: failed to remove ${entry.name}: ${err}`) } } - const cmd = new Deno.Command('git', { - args: ['-C', worktreePath, 'add', '-A'], - stdout: 'null', - stderr: 'null', - }) - await cmd.output() + if (removed === 0) return + const { code } = await runGit(['add', '-A'], worktreePath) + if (code !== 0) { + console.error(`pre-merge: git add -A failed (exit ${code})`) + Deno.exit(code) + } } if (import.meta.main) { @@ -31,5 +33,5 @@ if (import.meta.main) { console.error('worktree_path required') Deno.exit(1) } - await preMerge(p!) + await preMerge(p) } From 43815ab5ba64a7e8e956645262082f45609c1229 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 03:10:06 +0000 Subject: [PATCH 13/16] fix: apply code-review findings across hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install-deps/generate-artifacts: propagate child exit codes (upstream set -e contract) instead of logging and exiting 0 on build/install failure; pip fallback now gated too - pre-merge: run git add -A unconditionally (repairs a partially-failed prior run) while keeping the exit gate - codegraph-worktree-mcp: match bash instance sanitization exactly (non-overlapping -- collapse; bash leaves a--b from a---b) and strip trailing slash before basename so .mcp.json points at the real volume - worktree-to-relative: use lib/git.ts resolvePrimaryRepo instead of the local re-implementation (drops the "not a git repo at null" message) - deno task check: add deno check so the local gate typechecks like CI - README: wt.toml variables are {{ primary_worktree_path }}, not {{ primary_path }} — the undefined variable aborted hook expansion Co-Authored-By: internal-model --- README.md | 4 ++-- codegraph-worktree-mcp.ts | 4 ++-- deno.json | 2 +- deno.lock | 11 ++++++++++- generate-artifacts.ts | 27 ++++++++++++++------------- install-deps.ts | 13 ++++++++++--- pre-merge.ts | 3 --- worktree-to-relative.ts | 32 ++++---------------------------- 8 files changed, 43 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 0a9c116..a313ab8 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ When creating isolated git worktrees, new checkouts often start cold: index data ```toml # .config/wt.toml [hooks] -pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts {{worktree_path}} {{primary_path}}" -post-start = "deno run --allow-read --allow-write --allow-run --allow-env ./copy-codegraph.ts {{worktree_path}} {{primary_path}}" +pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts {{worktree_path}} {{primary_worktree_path}}" +post-start = "deno run --allow-read --allow-write --allow-run --allow-env ./copy-codegraph.ts {{worktree_path}} {{primary_worktree_path}}" post-switch = "deno run --allow-run ./post-switch.ts {{worktree_path}}" pre-merge = "deno run --allow-read --allow-write --allow-run ./pre-merge.ts {{worktree_path}}" ``` diff --git a/codegraph-worktree-mcp.ts b/codegraph-worktree-mcp.ts index 5838041..5d3c420 100755 --- a/codegraph-worktree-mcp.ts +++ b/codegraph-worktree-mcp.ts @@ -1,7 +1,7 @@ #!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env function sanitizeInstance(name: string): string { - let s = name.replace(/[^A-Za-z0-9_.-]/g, '-').replace(/--+/g, '-') + let s = name.replace(/[^A-Za-z0-9_.-]/g, '-').replace(/--/g, '-') s = s.replace(/^[-_.]+/, '').replace(/[-_.]+$/, '') return s || 'root' } @@ -51,7 +51,7 @@ async function provisionMcp(worktreePath: string): Promise { } } - const base = worktreePath.split('/').at(-1) ?? 'root' + const base = worktreePath.replace(/\/+$/, '').split('/').at(-1) ?? 'root' const instance = sanitizeInstance(base) const socket = `${ Deno.env.get('HOME') diff --git a/deno.json b/deno.json index b35f6f4..da7060e 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "description": "Worktrunk worktree hooks", "tasks": { - "check": "dprint check && deno lint", + "check": "dprint check && deno lint && deno check *.ts lib/*.ts", "lint": "deno lint", "fmt": "dprint fmt", "fmt:check": "dprint check", diff --git a/deno.lock b/deno.lock index 9a74803..94e615a 100644 --- a/deno.lock +++ b/deno.lock @@ -2,9 +2,11 @@ "version": "5", "specifiers": { "jsr:@std/assert@^1.0.14": "1.0.19", + "jsr:@std/fs@^1.0.19": "1.0.24", "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/path@^1.0.9": "1.1.6" + "jsr:@std/path@^1.0.9": "1.1.6", + "jsr:@std/path@^1.1.5": "1.1.6" }, "jsr": { "@std/assert@1.0.19": { @@ -13,6 +15,13 @@ "jsr:@std/internal@^1.0.12" ] }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" + ] + }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, diff --git a/generate-artifacts.ts b/generate-artifacts.ts index 3a4601a..7e186df 100755 --- a/generate-artifacts.ts +++ b/generate-artifacts.ts @@ -2,28 +2,29 @@ import { exists } from '@std/fs' async function generateArtifacts(worktreePath: string): Promise { - // Product output: status lines are the CLI interface console.log('generate-artifacts: generating build artifacts...') + let cmd: Deno.Command if (await exists(`${worktreePath}/pnpm-lock.yaml`)) { - const cmd = new Deno.Command('corepack', { args: ['pnpm', 'build'], cwd: worktreePath }) - await cmd.output() + cmd = new Deno.Command('corepack', { args: ['pnpm', 'build'], cwd: worktreePath }) } else if (await exists(`${worktreePath}/package-lock.json`)) { - const cmd = new Deno.Command('npm', { args: ['run', 'build'], cwd: worktreePath }) - await cmd.output() + cmd = new Deno.Command('npm', { args: ['run', 'build'], cwd: worktreePath }) } else if (await exists(`${worktreePath}/yarn.lock`)) { - const cmd = new Deno.Command('yarn', { args: ['build'], cwd: worktreePath }) - await cmd.output() + cmd = new Deno.Command('yarn', { args: ['build'], cwd: worktreePath }) } else if (await exists(`${worktreePath}/bun.lock`)) { - const cmd = new Deno.Command('bun', { args: ['run', 'build'], cwd: worktreePath }) - await cmd.output() + cmd = new Deno.Command('bun', { args: ['run', 'build'], cwd: worktreePath }) } else if (await exists(`${worktreePath}/Cargo.toml`)) { - const cmd = new Deno.Command('cargo', { args: ['build'], cwd: worktreePath }) - await cmd.output() + cmd = new Deno.Command('cargo', { args: ['build'], cwd: worktreePath }) } else if (await exists(`${worktreePath}/go.mod`)) { - const cmd = new Deno.Command('go', { args: ['build', './...'], cwd: worktreePath }) - await cmd.output() + cmd = new Deno.Command('go', { args: ['build', './...'], cwd: worktreePath }) } else { console.log('generate-artifacts: no recognized build system, skipping') + console.log('generate-artifacts: done') + return + } + const { code } = await cmd.output() + if (code !== 0) { + console.error(`generate-artifacts: build exited ${code}`) + Deno.exit(code) } console.log('generate-artifacts: done') } diff --git a/install-deps.ts b/install-deps.ts index 44f3fa3..8fb49cb 100755 --- a/install-deps.ts +++ b/install-deps.ts @@ -44,20 +44,27 @@ async function installDeps(worktreePath: string): Promise { } if (mgr.cmd[0] === 'pip-install') { let cmd = new Deno.Command('pip', { args: ['install', '-e', '.'], cwd: worktreePath }) - const { code } = await cmd.output() + let { code } = await cmd.output() if (code !== 0) { cmd = new Deno.Command('pip', { args: ['install', '-r', 'requirements.txt'], cwd: worktreePath, }) - await cmd.output() + ;({ code } = await cmd.output()) + if (code !== 0) { + console.error(`install-deps: pip install exited ${code}`) + Deno.exit(code) + } } console.log('install-deps: done') return } const cmd = new Deno.Command(mgr.cmd[0], { args: mgr.cmd.slice(1), cwd: worktreePath }) const { code } = await cmd.output() - if (code !== 0) console.error(`install-deps: ${mgr.cmd.join(' ')} exited ${code}`) + if (code !== 0) { + console.error(`install-deps: ${mgr.cmd.join(' ')} exited ${code}`) + Deno.exit(code) + } console.log('install-deps: done') } diff --git a/pre-merge.ts b/pre-merge.ts index afb31ea..32445f2 100755 --- a/pre-merge.ts +++ b/pre-merge.ts @@ -6,7 +6,6 @@ import { runGit } from './lib/git.ts' const ISSUE_PATTERNS = [/^[0-9].*-.*\.md$/, /^T[0-9a-fA-F].*-.*\.md$/] async function preMerge(worktreePath: string): Promise { - let removed = 0 for await (const entry of Deno.readDir(worktreePath)) { if (!ISSUE_PATTERNS.some((re) => re.test(entry.name))) continue const full = join(worktreePath, entry.name) @@ -14,12 +13,10 @@ async function preMerge(worktreePath: string): Promise { try { await Deno.remove(full) console.log(`pre-merge: removed symlinked issue ${entry.name}`) - removed++ } catch (err) { console.error(`pre-merge: failed to remove ${entry.name}: ${err}`) } } - if (removed === 0) return const { code } = await runGit(['add', '-A'], worktreePath) if (code !== 0) { console.error(`pre-merge: git add -A failed (exit ${code})`) diff --git a/worktree-to-relative.ts b/worktree-to-relative.ts index e726345..28b0840 100755 --- a/worktree-to-relative.ts +++ b/worktree-to-relative.ts @@ -1,22 +1,16 @@ #!/usr/bin/env -S deno run --allow-read --allow-write --allow-run import { dirname, relative } from '@std/path' +import { resolvePrimaryRepo } from './lib/git.ts' /** * Convert all .git/worktrees//gitdir paths to relative. - * Forward-looking: uses Deno APIs directly, idempotent, reports count. + * Uses Deno APIs directly, idempotent, reports count. * CLI product output. */ async function worktreeToRelative(worktreePath = Deno.cwd()): Promise { - const gitCommonRaw = await runGitRevParse(worktreePath, '--git-common-dir') - const primaryPath = await resolvePrimary(gitCommonRaw, worktreePath) + const primaryPath = await resolvePrimaryRepo(worktreePath) if (!primaryPath) { - console.error(`error: not a git repo at ${primaryPath}`) - Deno.exit(1) - } - try { - await Deno.stat(`${primaryPath}/.git`) - } catch { - console.error(`error: not a git repo at ${primaryPath}`) + console.error(`error: not a git repo at ${worktreePath}`) Deno.exit(1) } const worktreesDir = `${primaryPath}/.git/worktrees` @@ -51,24 +45,6 @@ async function worktreeToRelative(worktreePath = Deno.cwd()): Promise { return count } -async function runGitRevParse(cwd: string, arg: string): Promise { - const cmd = new Deno.Command('git', { - args: ['rev-parse', arg], - cwd, - stdout: 'piped', - stderr: 'piped', - }) - const { stdout } = await cmd.output() - return new TextDecoder().decode(stdout).trim() -} - -function resolvePrimary(gitCommonDir: string, worktreePath: string): string | null { - if (!gitCommonDir) return null - const abs = gitCommonDir.startsWith('/') ? gitCommonDir : `${worktreePath}/${gitCommonDir}` - // gitCommonDir is ".../.git"; primary is parent of .git - return dirname(abs) -} - if (import.meta.main) { const p = Deno.args[0] ?? Deno.cwd() await worktreeToRelative(p) From 0b2fb48e904a7358c324a7b580f03288b57d31d6 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 03:10:50 +0000 Subject: [PATCH 14/16] docs: compound bash-to-Deno port semantics learning Co-Authored-By: internal-model --- .../tooling/bash-to-deno-port-semantics.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/solutions/tooling/bash-to-deno-port-semantics.md diff --git a/docs/solutions/tooling/bash-to-deno-port-semantics.md b/docs/solutions/tooling/bash-to-deno-port-semantics.md new file mode 100644 index 0000000..8afd135 --- /dev/null +++ b/docs/solutions/tooling/bash-to-deno-port-semantics.md @@ -0,0 +1,54 @@ +--- +title: Bash-to-Deno hook ports must preserve set -e and expansion semantics +date: 2026-08-30 +category: tooling +module: worktrunk hooks +problem_type: best_practice +component: tooling +severity: high +applies_when: + - Porting bash lifecycle hooks to Deno TypeScript + - Verifying a port preserves observable behavior against the original +tags: [bash-port, set-e, exit-code, glob, deno] +--- + +# Bash-to-Deno hook ports must preserve set -e and expansion semantics + +## Context + +Porting the 10 bash worktrunk hooks to Deno exposed three silent-behavior gaps that a line-by-line port misses: child exit codes, shell glob semantics, and variable expansion. Each one shipped green and broke the bash contract. + +## Guidance + +- **Propagate every child exit code.** Bash `set -e` aborts the hook when `npm ci` or `pnpm build` fails. The Deno port must `Deno.exit(code)` on non-zero child output, not log-and-continue: + +```ts +const { code } = await cmd.output() +if (code !== 0) { + console.error(`build exited ${code}`) + Deno.exit(code) +} +``` + +- **Do not translate a shell glob to a regex by escaping metacharacters.** Escaping `[` breaks character classes — `[0-9]*-*.md` became `^\[0-9\].*-.*\.md$` and matched nothing. Write the regex from the glob's matched set, or test the port against `shopt -s nullglob` expansion. +- **Bash substitution and JS regex collapse are NOT equivalent.** Bash `${x//--/-}` is one non-overlapping pass: `a---b` -> `a--b`. JS `/--+/g` fully collapses: `a-b`. Port instance/name sanitization with the same pass semantics (`/--/g`), or the derived socket paths diverge. +- **Check template variables against the caller's docs.** wt.toml exposes `{{ primary_worktree_path }}`, not `{{ primary_path }}`; the undefined variable aborts hook expansion at runtime. +- **Verify symlink-removal semantics empirically per runtime.** On Deno 2.9, `Deno.remove` on a symlink removes the link and keeps the target (refutes denoland/deno#1947 claims of target deletion). Do not trust issue-tracker claims over a one-line runtime test. + +## Why This Matters + +Hooks run as worktree lifecycle callbacks; a hook that exits 0 after a failed install lets the merge proceed with broken state and misattributes later failures. Divergent instance names produce dead MCP sockets that fail confusingly. + +## When to Apply + +- Any bash-to-TS/Deno port reviewed for behavioral parity. +- Hooks whose failure must block the caller (pre-merge, post-start install/build). + +## Examples + +- `install-deps.ts` / `generate-artifacts.ts` now exit non-zero when the child command fails (restores `set -e` aborts). +- `codegraph-worktree-mcp.ts` sanitization matches bash's non-overlapping dash collapse. + +## Related + +- docs/plans/2026-08-30-deno-worktrunk-conversion.md \ No newline at end of file From 0938d242713dfbe2cd92c70105b72bff8bb47efc Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 03:12:26 +0000 Subject: [PATCH 15/16] docs: add trailing newline to compound learning (dprint) --- docs/solutions/tooling/bash-to-deno-port-semantics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/solutions/tooling/bash-to-deno-port-semantics.md b/docs/solutions/tooling/bash-to-deno-port-semantics.md index 8afd135..28997b2 100644 --- a/docs/solutions/tooling/bash-to-deno-port-semantics.md +++ b/docs/solutions/tooling/bash-to-deno-port-semantics.md @@ -51,4 +51,4 @@ Hooks run as worktree lifecycle callbacks; a hook that exits 0 after a failed in ## Related -- docs/plans/2026-08-30-deno-worktrunk-conversion.md \ No newline at end of file +- docs/plans/2026-08-30-deno-worktrunk-conversion.md From efdc940100595c4a3f9bd3ff51a6107c4d9a44fe Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sun, 30 Aug 2026 03:17:35 +0000 Subject: [PATCH 16/16] refactor: move hook scripts into scripts/ - 9 hook CLIs + lib/ moved from repo root to scripts/ (git mv; internal ./lib and ./codegraph-worktree-mcp.ts relative references unchanged) - deno task check and CI typecheck glob updated to scripts/*.ts scripts/lib/*.ts - README/CONTRIBUTING updated: wt.toml paths, design, conventions Co-Authored-By: internal-model --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 4 ++-- README.md | 12 ++++++------ deno.json | 6 +++--- .../codegraph-worktree-mcp.ts | 0 .../convert-to-relative-paths.ts | 0 copy-codegraph.ts => scripts/copy-codegraph.ts | 0 .../generate-artifacts.ts | 0 install-deps.ts => scripts/install-deps.ts | 0 {lib => scripts/lib}/fs.ts | 0 {lib => scripts/lib}/git.ts | 0 {lib => scripts/lib}/mod.ts | 0 {lib => scripts/lib}/paths.ts | 0 post-switch.ts => scripts/post-switch.ts | 0 pre-merge.ts => scripts/pre-merge.ts | 0 pre-start.ts => scripts/pre-start.ts | 0 .../worktree-to-relative.ts | 0 17 files changed, 12 insertions(+), 12 deletions(-) rename codegraph-worktree-mcp.ts => scripts/codegraph-worktree-mcp.ts (100%) rename convert-to-relative-paths.ts => scripts/convert-to-relative-paths.ts (100%) rename copy-codegraph.ts => scripts/copy-codegraph.ts (100%) rename generate-artifacts.ts => scripts/generate-artifacts.ts (100%) rename install-deps.ts => scripts/install-deps.ts (100%) rename {lib => scripts/lib}/fs.ts (100%) rename {lib => scripts/lib}/git.ts (100%) rename {lib => scripts/lib}/mod.ts (100%) rename {lib => scripts/lib}/paths.ts (100%) rename post-switch.ts => scripts/post-switch.ts (100%) rename pre-merge.ts => scripts/pre-merge.ts (100%) rename pre-start.ts => scripts/pre-start.ts (100%) rename worktree-to-relative.ts => scripts/worktree-to-relative.ts (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83187b3..238f1bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,4 +28,4 @@ jobs: - name: deno lint run: deno lint - name: deno check - run: deno check *.ts lib/*.ts + run: deno check scripts/*.ts scripts/lib/*.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3db83b..6cceb83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,6 @@ deno task check ## Conventions -- Hook scripts at the repository root are standalone CLIs (no exports). -- Shared code lives in `lib/`. +- Hook scripts under `scripts/` are standalone CLIs (no exports). +- Shared code lives in `scripts/lib/`. - Commits follow [Conventional Commits](https://www.conventionalcommits.org/). diff --git a/README.md b/README.md index a313ab8..90177a3 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ When creating isolated git worktrees, new checkouts often start cold: index data ```toml # .config/wt.toml [hooks] -pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./pre-start.ts {{worktree_path}} {{primary_worktree_path}}" -post-start = "deno run --allow-read --allow-write --allow-run --allow-env ./copy-codegraph.ts {{worktree_path}} {{primary_worktree_path}}" -post-switch = "deno run --allow-run ./post-switch.ts {{worktree_path}}" -pre-merge = "deno run --allow-read --allow-write --allow-run ./pre-merge.ts {{worktree_path}}" +pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./scripts/pre-start.ts {{worktree_path}} {{primary_worktree_path}}" +post-start = "deno run --allow-read --allow-write --allow-run --allow-env ./scripts/copy-codegraph.ts {{worktree_path}} {{primary_worktree_path}}" +post-switch = "deno run --allow-run ./scripts/post-switch.ts {{worktree_path}}" +pre-merge = "deno run --allow-read --allow-write --allow-run ./scripts/pre-merge.ts {{worktree_path}}" ``` ## Quick Start @@ -27,14 +27,14 @@ The hooks execute directly with `deno run` using explicit permission flags decla ## Design -Top-level scripts at the repository root are standalone CLI entrypoints invoked by `worktrunk` lifecycle events. Shared helpers (git resolution, relative path mapping, filesystem utilities) live under `lib/`. +Scripts under `scripts/` are standalone CLI entrypoints invoked by `worktrunk` lifecycle events. Shared helpers (git resolution, relative path mapping, filesystem utilities) live under `scripts/lib/`. ## Configuration In your primary repository, configure `worktrunk` to call the desired hook scripts in `.config/wt.toml`. Every script accepts standard arguments supplied by the runner: ```bash -deno run --allow-read --allow-write --allow-run --allow-env ./.ts [primary_path] +deno run --allow-read --allow-write --allow-run --allow-env ./scripts/.ts [primary_path] ``` If the secondary `primary_path` argument is omitted, the hook automatically resolves the primary repository root using the shared git common directory. diff --git a/deno.json b/deno.json index da7060e..b502322 100644 --- a/deno.json +++ b/deno.json @@ -1,16 +1,16 @@ { "description": "Worktrunk worktree hooks", "tasks": { - "check": "dprint check && deno lint && deno check *.ts lib/*.ts", + "check": "dprint check && deno lint && deno check scripts/*.ts scripts/lib/*.ts", "lint": "deno lint", "fmt": "dprint fmt", "fmt:check": "dprint check", "test": "deno test --allow-read --allow-write --allow-run --allow-env" }, "imports": { - "@std/path": "jsr:@std/path@^1.0.9", + "@std/assert": "jsr:@std/assert@^1.0.14", "@std/fs": "jsr:@std/fs@^1.0.19", - "@std/assert": "jsr:@std/assert@^1.0.14" + "@std/path": "jsr:@std/path@^1.0.9" }, "lint": { "rules": { diff --git a/codegraph-worktree-mcp.ts b/scripts/codegraph-worktree-mcp.ts similarity index 100% rename from codegraph-worktree-mcp.ts rename to scripts/codegraph-worktree-mcp.ts diff --git a/convert-to-relative-paths.ts b/scripts/convert-to-relative-paths.ts similarity index 100% rename from convert-to-relative-paths.ts rename to scripts/convert-to-relative-paths.ts diff --git a/copy-codegraph.ts b/scripts/copy-codegraph.ts similarity index 100% rename from copy-codegraph.ts rename to scripts/copy-codegraph.ts diff --git a/generate-artifacts.ts b/scripts/generate-artifacts.ts similarity index 100% rename from generate-artifacts.ts rename to scripts/generate-artifacts.ts diff --git a/install-deps.ts b/scripts/install-deps.ts similarity index 100% rename from install-deps.ts rename to scripts/install-deps.ts diff --git a/lib/fs.ts b/scripts/lib/fs.ts similarity index 100% rename from lib/fs.ts rename to scripts/lib/fs.ts diff --git a/lib/git.ts b/scripts/lib/git.ts similarity index 100% rename from lib/git.ts rename to scripts/lib/git.ts diff --git a/lib/mod.ts b/scripts/lib/mod.ts similarity index 100% rename from lib/mod.ts rename to scripts/lib/mod.ts diff --git a/lib/paths.ts b/scripts/lib/paths.ts similarity index 100% rename from lib/paths.ts rename to scripts/lib/paths.ts diff --git a/post-switch.ts b/scripts/post-switch.ts similarity index 100% rename from post-switch.ts rename to scripts/post-switch.ts diff --git a/pre-merge.ts b/scripts/pre-merge.ts similarity index 100% rename from pre-merge.ts rename to scripts/pre-merge.ts diff --git a/pre-start.ts b/scripts/pre-start.ts similarity index 100% rename from pre-start.ts rename to scripts/pre-start.ts diff --git a/worktree-to-relative.ts b/scripts/worktree-to-relative.ts similarity index 100% rename from worktree-to-relative.ts rename to scripts/worktree-to-relative.ts