From 0935796a3df4391846f0ca89e6994599d93cbf12 Mon Sep 17 00:00:00 2001 From: nemanull Date: Mon, 27 Jul 2026 16:45:51 -0700 Subject: [PATCH] fix: repoint absolute env paths into the worktree --- README.md | 33 +++- docs/env-path-isolation.md | 100 ++++++++++++ skills/wt/SKILL.md | 3 + src/commands/new.spec.ts | 2 + src/commands/new.ts | 18 ++- src/commands/setup.spec.ts | 2 +- src/commands/setup.ts | 8 +- src/core/env-patcher.ts | 23 ++- src/core/env-paths.spec.ts | 308 +++++++++++++++++++++++++++++++++++++ src/core/env-paths.ts | 158 +++++++++++++++++++ src/output.ts | 32 ++++ 11 files changed, 674 insertions(+), 13 deletions(-) create mode 100644 docs/env-path-isolation.md create mode 100644 src/core/env-paths.spec.ts create mode 100644 src/core/env-paths.ts diff --git a/README.md b/README.md index 6e34b22..20d0cd1 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Each worktree gets a numbered slot. The slot determines everything: - **Database**: Created via `CREATE DATABASE ... TEMPLATE` (fast filesystem copy, not dump/restore) - **Docker services**: Run in a dedicated Docker Compose project per worktree, grouped in Docker Desktop - **Ports**: Offset by `portStride` (default 100) per slot -- **Env files**: Copied from main worktree, filled with safe defaults from configured `.env.example` files, and patched with the slot's values +- **Env files**: Copied from main worktree, filled with safe defaults from configured `.env.example` files, patched with the slot's values, and swept for absolute paths that would reach back into the main checkout ## Quick Start @@ -174,7 +174,7 @@ Creates a new git worktree and sets up its isolated environment: - If `[branch]` is **omitted**, a throwaway branch is auto-named from the base and today's date — like `main-20260723-nemanull` — and forked from `--base` (defaulting to `origin/main`, then `main`). Use this for a clean scratch environment without inventing a name: `wt new --base main`. Because wt invented the name, it is always a fresh branch: no `origin` lookup, and a same-named remote branch is never adopted. 2. Allocates the next available slot (or uses `--slot N`) 3. Creates a new Postgres database from the main DB as template -4. Copies configured `.env` files, fills missing safe defaults from examples, and patches each with slot-specific values +4. Copies configured `.env` files, fills missing safe defaults from examples, patches each with slot-specific values, and repoints absolute paths that still address the main checkout 5. Starts configured Docker services after the slot database exists 6. Runs `postSetup` commands (unless `--no-install`) @@ -428,6 +428,35 @@ The `port` and `url` types require a `service` field that matches a name in `ser Legacy `type: "redis"` patches are no longer supported. Declare Redis in `dockerServices` and patch `REDIS_URL` with `type: "url"` instead. +### Absolute Paths + +Patches only touch the keys you list, so anything else in a developer's env file +is copied into the worktree verbatim — including absolute paths that still point +at the main checkout. A worktree that runs another checkout's build artifact +looks completely healthy: the path resolves, the process starts, health checks +pass, and it listens on the worktree's own isolated port. Only the code behind it +is wrong. + +`wt new` and `wt setup` therefore sweep every env file they seed for absolute +path values, with no configuration and no key list to keep up to date: + +- A path inside the main worktree, or inside one of its other worktrees, is + repointed at the same relative location in this worktree, and reported. +- A path inside an unrelated checkout is reported and left alone. +- Everything else is left alone. `/usr/bin/node` and `/tmp/cache` are shared on + purpose. + +``` +wt: rewrote 1 env value that pointed outside this worktree + server/.env RUNNER_BIN + was /home/dev/proj/apps/runner/target/release/runner + now /home/dev/proj/.worktrees/my-branch/apps/runner/target/release/runner +``` + +Worktrees created before this existed still hold the escaped values. Re-run plain +`wt setup` inside one to re-seed and repair it. See +[docs/env-path-isolation.md](docs/env-path-isolation.md). + ### Env Seeding `envFiles[].seedFrom` is for committed safe defaults. It maps a checked-in example file to the local env file in the same `envFiles` entry: diff --git a/docs/env-path-isolation.md b/docs/env-path-isolation.md new file mode 100644 index 0000000..94b493f --- /dev/null +++ b/docs/env-path-isolation.md @@ -0,0 +1,100 @@ +# Env path isolation + +How `wt` keeps filesystem paths inside seeded env files pointing at the worktree +that owns them, and why an unpatched absolute path is dangerous. + +## The failure mode + +`wt new` and `wt setup` build a worktree's env files in three passes: + +1. Copy each configured env file verbatim from the main worktree. +2. Fill in vars missing from the file using the committed `.env.example`. +3. Patch the vars enumerated in `wt.config.json` (database, ports, URLs, branch). + +Pass 3 only touches keys the config names. Every other line survives the copy +untouched, including any value that is an absolute filesystem path. + +A developer's env file often holds such a path. The committed example may use the +correct relative form, but a local file that was hand-edited once keeps whatever +was typed. Copied into a worktree, that value still addresses the main checkout. + +This is worse than a stale setting because it is silent: + +- The path resolves, so nothing errors. +- The artifact behind it is real and executable, so process spawns succeed and + health checks pass. +- Port isolation still works. A service started from the wrong binary listens on + exactly the port the worktree allocated, so every observable signal looks right. + +The result inverts the point of the worktree: code changed in the worktree is not +the code that runs, and local verification of that component proves nothing. + +The concrete report that motivated this +([wt#18](https://github.com/Tokenbooks/wt/issues/18)) was a Rust engine fix +verified in a worktree whose server had been spawning the main checkout's release +binary the whole time, because `server/.env` carried +`ACCOUNTING_RUST_RUNNER_BIN=/abs/path/to/main-checkout/...`. + +## The rule + +For every value in a seeded env file that is an absolute path (leading `/`, or +`~/` which is expanded first), `wt` locates the git working tree that encloses it +by walking up the directory chain for a `.git` entry. A primary checkout has a +`.git` directory, a linked worktree has a `.git` file, so both are found. The path +itself does not need to exist. + +| Enclosing checkout | Action | +| --- | --- | +| None | Leave alone. `/usr/bin/node` and `/tmp/cache` are deliberately shared. | +| This worktree | Leave alone. Already correct. | +| The main worktree, or another worktree of it | Rewrite to the same relative location inside this worktree, and report it. | +| Any other checkout | Leave alone and warn, naming the file and key. | + +Rewriting means replacing the enclosing checkout's root with this worktree's root +and keeping the rest of the path. It is an absolute path in, absolute path out; +nothing is made relative, so nothing depends on the working directory a consumer +happens to run from. + +Rewriting is not conditional on the target existing. A worktree that has not built +an artifact yet gets a path to where that artifact belongs, and the consumer fails +loudly instead of silently reaching into another checkout. + +Only the path token is touched. Quoting, an inline comment, trailing whitespace, +and a CRLF line ending are all written back byte-for-byte. Checkout roots are +compared by their physical location, so a symlinked spelling of the main worktree +is still recognised as the main worktree. + +## What you see + +`wt new` and `wt setup` print each rewrite and each warning to stderr. Both also +appear in `--json` output under a single `envPathEscapes` array, where an entry +that could be repointed carries a `rewritten` field and a warning does not. + +``` +wt: rewrote 1 env value that pointed outside this worktree + server/.env ACCOUNTING_RUST_RUNNER_BIN + was /home/dev/proj/apps/runner/target/release/runner + now /home/dev/proj/.worktrees/my-branch/apps/runner/target/release/runner +``` + +## Repairing an existing worktree + +Worktrees created before this behaviour existed still hold the escaped values. +Re-running `wt setup` in the worktree re-copies and re-patches every configured +env file, which applies the rule and prints what it changed. Note that +`wt setup --repair` short-circuits when there is nothing to repair, so use plain +`wt setup`. + +## Limits + +- Only keys inside files listed in `wt.config.json` under `envFiles` are examined. + A path in a file `wt` does not manage is invisible to it. +- A value that packs several paths into one string, shell-`PATH` style, is not + parsed and is left alone. Any value containing a colon is skipped for this + reason, as is a value whose opening quote is never closed. +- A path into an unrelated checkout is reported, never rewritten. There is no way + to know what the equivalent location in this worktree would be. +- `wt env seed` only fills in missing defaults. It does not sweep paths, because + it has no worktree to repoint them at. Use `wt setup` for that. +- The nearest enclosing `.git` wins, so a path inside a submodule or a nested + repository is judged against that inner checkout, not the main worktree. diff --git a/skills/wt/SKILL.md b/skills/wt/SKILL.md index 72d9794..c3fe54c 100644 --- a/skills/wt/SKILL.md +++ b/skills/wt/SKILL.md @@ -38,6 +38,7 @@ For each `.env` file, examine every variable and classify: | Redis connection URL (`redis://...`) | `url` | Yes (`redis`) | | Just a port number | `port` | Yes | | A URL containing a service port (`http://localhost:3000/...`) | `url` | Yes | +| A filesystem path | Skip — wt repoints these automatically | — | | Anything else (API keys, secrets, flags) | Skip — do not patch | — | **Step 3: Generate `wt.config.json`** @@ -84,6 +85,8 @@ Validation rules: - `baseDatabaseName` must match the actual DB name in `DATABASE_URL` - If using `dockerServices`, Docker must be available locally - Do not use legacy `type: "redis"` patches; use `type: "url"` for `REDIS_URL` +- Do not invent a patch entry for a filesystem path. `wt` repoints absolute paths that + address the main checkout at every seed, so no key list can fall behind. **Step 4: Install wt** diff --git a/src/commands/new.spec.ts b/src/commands/new.spec.ts index 22f2660..7494c0f 100644 --- a/src/commands/new.spec.ts +++ b/src/commands/new.spec.ts @@ -157,6 +157,7 @@ describe('new command branch selection', () => { mockFindAvailableSlot.mockReturnValue(2); mockAllocateServicePorts.mockResolvedValue({ ports: { web: 3200 }, drifts: [] }); mockCalculateDbName.mockReturnValue('myapp_wt2'); + mockCopyAndPatchAllEnvFiles.mockReturnValue({ escapes: [] }); mockDatabaseExists.mockResolvedValue(false); mockCreateDatabase.mockResolvedValue(); mockCreateWorktree.mockReturnValue(allocation.worktreePath); @@ -485,6 +486,7 @@ describe('new command rollback on failure', () => { mockFindAvailableSlot.mockReturnValue(2); mockAllocateServicePorts.mockResolvedValue({ ports: { web: 3200, redis: 6579 }, drifts: [] }); mockCalculateDbName.mockReturnValue('myapp_wt2'); + mockCopyAndPatchAllEnvFiles.mockReturnValue({ escapes: [] }); mockEnsureDockerServices.mockReturnValue({ projectName: 'wt-2-myapp-deadbeef', services: ['redis'], diff --git a/src/commands/new.ts b/src/commands/new.ts index 2fc655f..ce3ac70 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -23,8 +23,9 @@ import { type WorktreeBranchSelection, } from '../core/git'; import { resolveBaseRef } from '../core/audit'; -import { extractErrorMessage, formatJson, formatSetupSummary, success, error } from '../output'; +import { extractErrorMessage, formatEnvPathEscapes, formatJson, formatSetupSummary, success, error } from '../output'; import { loadConfig } from './setup'; +import type { EnvPathEscape } from '../core/env-paths'; import type { Allocation, PortDrift } from '../types'; import { execSync } from 'node:child_process'; import * as fs from 'node:fs'; @@ -42,6 +43,7 @@ export interface CreateWorktreeResult { readonly branchSelection: WorktreeBranchSelection; readonly portDrifts: readonly PortDrift[]; readonly autoNamed: boolean; + readonly envPathEscapes: readonly EnvPathEscape[]; } /** Read DATABASE_URL from the main worktree's .env file */ @@ -176,6 +178,7 @@ export async function createNewWorktree( let worktreePath: string; let actualBranch: string; let allocation: Allocation; + let envPathEscapes: readonly EnvPathEscape[] = []; try { worktreePath = createWorktree( @@ -213,11 +216,11 @@ export async function createNewWorktree( }); log(`Patching ${config.envFiles.length} env file(s)...`); - copyAndPatchAllEnvFiles(config, mainRoot, worktreePath, { + envPathEscapes = copyAndPatchAllEnvFiles(config, mainRoot, worktreePath, { dbName, ports, branchName: actualBranch, - }); + }).escapes; allocation = { worktreePath, @@ -289,7 +292,7 @@ export async function createNewWorktree( } log(`Ready — slot ${slot}, branch '${actualBranch}'.`); - return { slot, allocation, branchSelection, portDrifts, autoNamed }; + return { slot, allocation, branchSelection, portDrifts, autoNamed, envPathEscapes }; } /** Create a new worktree with full environment isolation */ @@ -298,11 +301,15 @@ export async function newCommand( options: NewOptions, ): Promise { try { - const { slot, allocation, branchSelection, portDrifts, autoNamed } = await createNewWorktree(branchName, { + const { slot, allocation, branchSelection, portDrifts, autoNamed, envPathEscapes } = await createNewWorktree(branchName, { ...options, quiet: options.json, }); + if (!options.json) { + process.stderr.write(formatEnvPathEscapes(envPathEscapes)); + } + if (options.json) { console.log( formatJson( @@ -314,6 +321,7 @@ export async function newCommand( startPoint: branchSelection.startPoint ?? null, autoNamed, portDrifts, + envPathEscapes, }), ), ); diff --git a/src/commands/setup.spec.ts b/src/commands/setup.spec.ts index f3731a0..4fcf4c2 100644 --- a/src/commands/setup.spec.ts +++ b/src/commands/setup.spec.ts @@ -141,7 +141,7 @@ describe('setup command', () => { allocations: { ...registry.allocations, [String(slot)]: allocation }, })); mockWriteRegistry.mockImplementation(() => {}); - mockCopyAndPatchAllEnvFiles.mockImplementation(() => {}); + mockCopyAndPatchAllEnvFiles.mockReturnValue({ escapes: [] }); mockAllocateServicePorts.mockResolvedValue({ ports: { web: 3200 }, drifts: [] }); process.exitCode = 0; }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 59c9f8d..e694da8 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -17,7 +17,7 @@ import { computeServiceHashes, } from '../core/docker-services'; import { getMainWorktreePath, isMainWorktree, getBranchName } from '../core/git'; -import { extractErrorMessage, formatJson, formatRepairPreview, formatSetupSummary, success, error } from '../output'; +import { extractErrorMessage, formatEnvPathEscapes, formatJson, formatRepairPreview, formatSetupSummary, success, error } from '../output'; import type { Allocation, PortChange, PortDrift, WtConfig } from '../types'; interface SetupOptions { @@ -295,11 +295,14 @@ export async function setupCommand( }); // Copy and patch env files - copyAndPatchAllEnvFiles(config, mainRoot, worktreePath, { + const envFiles = copyAndPatchAllEnvFiles(config, mainRoot, worktreePath, { dbName, ports, branchName, }); + if (!options.json) { + process.stderr.write(formatEnvPathEscapes(envFiles.escapes)); + } // Update registry const allocation: Allocation = { @@ -330,6 +333,7 @@ export async function setupCommand( portDrifts, portChanges, recreatedDockerServices: recreateServices, + envPathEscapes: envFiles.escapes, repaired: !!options.repair, dryRun: !!options.dryRun, }), diff --git a/src/core/env-patcher.ts b/src/core/env-patcher.ts index 179ff59..a4c3bc6 100644 --- a/src/core/env-patcher.ts +++ b/src/core/env-patcher.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { rewriteEnvPaths, type EnvPathEscape, type EnvPathRoots } from './env-paths'; import type { EnvFileConfig, PatchConfig, PatchContext, WtConfig } from '../types'; type PortPatch = Extract; @@ -19,6 +20,10 @@ export interface SeedEnvFilesResult { readonly files: SeedEnvFileResult[]; } +export interface CopyAndPatchEnvFilesResult { + readonly escapes: readonly EnvPathEscape[]; +} + interface EnvAssignment { readonly varName: string; readonly line: string; @@ -245,14 +250,15 @@ export function seedEnvFileDefaults( /** * Copy and patch all env files from the main worktree to the target worktree. - * Reads each source from mainRoot, patches it, writes to worktreeRoot. + * Absolute paths pointing back at the source checkout are repointed and reported. + * See docs/env-path-isolation.md. */ export function copyAndPatchAllEnvFiles( config: WtConfig, mainRoot: string, worktreeRoot: string, context: PatchContext, -): void { +): CopyAndPatchEnvFilesResult { for (const envFile of config.envFiles) { const sourcePath = path.join(mainRoot, envFile.source); if (!fs.existsSync(sourcePath)) continue; @@ -265,12 +271,23 @@ export function copyAndPatchAllEnvFiles( seedEnvFileDefaults(config.envFiles, worktreeRoot, { dryRun: false }); + const roots: EnvPathRoots = { + worktreeRoot, + mainRoot, + worktreesDir: path.resolve(mainRoot, config.baseWorktreePath), + }; + const escapes: EnvPathEscape[] = []; + for (const envFile of config.envFiles) { const targetPath = path.join(worktreeRoot, envFile.source); if (!fs.existsSync(targetPath)) continue; const content = fs.readFileSync(targetPath, 'utf-8'); const patched = patchEnvContent(content, envFile.patches ?? [], context); - fs.writeFileSync(targetPath, patched, 'utf-8'); + const repointed = rewriteEnvPaths(patched, envFile.source, roots); + fs.writeFileSync(targetPath, repointed.content, 'utf-8'); + escapes.push(...repointed.escapes); } + + return { escapes }; } diff --git a/src/core/env-paths.spec.ts b/src/core/env-paths.spec.ts new file mode 100644 index 0000000..1ba1c40 --- /dev/null +++ b/src/core/env-paths.spec.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { findEnclosingCheckout, rewriteEnvPaths } from './env-paths'; +import type { EnvPathRoots } from './env-paths'; + +/** + * Build a main checkout with a sibling worktree, both marked as git working + * trees the way git itself marks them: a `.git` directory in the primary + * checkout and a `.git` file in the linked worktree. + */ +function createCheckoutFixture(tmpDir: string): EnvPathRoots { + const mainRoot = path.join(tmpDir, 'proj'); + const worktreesDir = path.join(mainRoot, '.worktrees'); + const worktreeRoot = path.join(worktreesDir, 'my-branch'); + + fs.mkdirSync(path.join(mainRoot, '.git'), { recursive: true }); + fs.mkdirSync(worktreeRoot, { recursive: true }); + fs.writeFileSync(path.join(worktreeRoot, '.git'), 'gitdir: /elsewhere\n', 'utf-8'); + + return { mainRoot, worktreeRoot, worktreesDir }; +} + +describe('findEnclosingCheckout', () => { + let tmpDir: string; + let roots: EnvPathRoots; + + beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-env-paths-find-'))); + roots = createCheckoutFixture(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('finds the checkout root for a path that does not exist yet', () => { + // Arrange + const unbuilt = path.join(roots.mainRoot, 'apps/runner/target/release/runner'); + + // Act + const checkout = findEnclosingCheckout(unbuilt); + + // Assert + expect(checkout).toBe(roots.mainRoot); + }); + + it('finds the nearest checkout, so a worktree wins over the main root above it', () => { + // Arrange + const inWorktree = path.join(roots.worktreeRoot, 'apps/runner/target/release/runner'); + + // Act + const checkout = findEnclosingCheckout(inWorktree); + + // Assert + expect(checkout).toBe(roots.worktreeRoot); + }); + + it('never attributes a path outside the checkouts to one of them', () => { + // Arrange: the ancestors of a temp dir are shared with the machine, so this + // asserts the attribution rather than the absence of any checkout at all. + const outside = path.join(tmpDir, 'not-a-repo/bin/tool'); + + // Act + const checkout = findEnclosingCheckout(outside); + + // Assert + expect(checkout).not.toBe(roots.mainRoot); + expect(checkout).not.toBe(roots.worktreeRoot); + }); + + it('returns undefined when no ancestor is a checkout', () => { + // Act + const checkout = findEnclosingCheckout(path.parse(tmpDir).root); + + // Assert + expect(checkout).toBeUndefined(); + }); +}); + +describe('rewriteEnvPaths', () => { + let tmpDir: string; + let roots: EnvPathRoots; + + beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-env-paths-'))); + roots = createCheckoutFixture(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('repoints a path into the main checkout at the same place in the worktree', () => { + // Arrange + const mainBinary = path.join(roots.mainRoot, 'apps/runner/target/release/runner'); + const content = `RUNNER_BIN=${mainBinary}\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + const worktreeBinary = path.join(roots.worktreeRoot, 'apps/runner/target/release/runner'); + expect(result.content).toBe(`RUNNER_BIN=${worktreeBinary}\n`); + expect(result.escapes).toEqual([ + { + file: 'server/.env', + varName: 'RUNNER_BIN', + value: mainBinary, + rewritten: worktreeBinary, + }, + ]); + }); + + it('repoints a path into a sibling worktree rather than nesting it', () => { + // Arrange + const sibling = path.join(roots.worktreesDir, 'other-branch'); + fs.mkdirSync(sibling, { recursive: true }); + fs.writeFileSync(path.join(sibling, '.git'), 'gitdir: /elsewhere\n', 'utf-8'); + const content = `RUNNER_BIN=${path.join(sibling, 'apps/runner/bin')}\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe( + `RUNNER_BIN=${path.join(roots.worktreeRoot, 'apps/runner/bin')}\n`, + ); + }); + + it('reports a path into an unrelated checkout without rewriting it', () => { + // Arrange + const foreignRoot = path.join(tmpDir, 'other-project'); + fs.mkdirSync(path.join(foreignRoot, '.git'), { recursive: true }); + const foreignBinary = path.join(foreignRoot, 'bin/tool'); + const content = `TOOL_BIN=${foreignBinary}\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe(content); + expect(result.escapes).toEqual([ + { file: 'server/.env', varName: 'TOOL_BIN', value: foreignBinary, rewritten: undefined }, + ]); + }); + + it('leaves a path that already points inside this worktree alone', () => { + // Arrange + const inside = path.join(roots.worktreeRoot, 'apps/runner/bin'); + const content = `RUNNER_BIN=${inside}\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe(content); + expect(result.escapes).toEqual([]); + }); + + it.each([ + ['a shared system path', 'NODE_BIN=/usr/bin/node'], + ['a relative path', 'RUNNER_BIN=../apps/runner/target/release/runner'], + ['a database url', 'DATABASE_URL=postgresql://user:pw@localhost:5432/myapp'], + ['a comment', '# RUNNER_BIN=/usr/bin/node'], + ['a blank line', ''], + ])('leaves %s untouched and unreported', (_label, line) => { + // Arrange + const content = `${line}\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe(content); + expect(result.escapes).toEqual([]); + }); + + it('preserves an export prefix and surrounding quotes', () => { + // Arrange + const mainBinary = path.join(roots.mainRoot, 'apps/runner/bin'); + const content = `export RUNNER_BIN="${mainBinary}"\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe( + `export RUNNER_BIN="${path.join(roots.worktreeRoot, 'apps/runner/bin')}"\n`, + ); + }); + + it('expands a leading tilde before deciding, and writes back an absolute path', () => { + // Arrange: spell the same main-checkout path relative to the home directory. + const homeRelative = path.relative(os.homedir(), roots.mainRoot); + const content = `RUNNER_BIN=~/${path.join(homeRelative, 'apps/runner/bin')}\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe( + `RUNNER_BIN=${path.join(roots.worktreeRoot, 'apps/runner/bin')}\n`, + ); + }); + + it('rewrites the path but leaves an inline comment byte-for-byte', () => { + // Arrange + const mainBinary = path.join(roots.mainRoot, 'apps/runner/bin'); + const content = `RUNNER_BIN=${mainBinary} # see https://example.com/build\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe( + `RUNNER_BIN=${path.join(roots.worktreeRoot, 'apps/runner/bin')} # see https://example.com/build\n`, + ); + expect(result.escapes[0]?.value).toBe(mainBinary); + }); + + it('keeps quotes balanced when text follows the closing quote', () => { + // Arrange + const content = `RUNNER_BIN="${path.join(roots.mainRoot, 'apps/runner/bin')}" \n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe( + `RUNNER_BIN="${path.join(roots.worktreeRoot, 'apps/runner/bin')}" \n`, + ); + expect(result.content.match(/"/g)).toHaveLength(2); + }); + + it('rewrites a value on a CRLF line and keeps the carriage return', () => { + // Arrange + const content = `RUNNER_BIN=${path.join(roots.mainRoot, 'apps/runner/bin')}\r\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe( + `RUNNER_BIN=${path.join(roots.worktreeRoot, 'apps/runner/bin')}\r\n`, + ); + }); + + it.each([ + ['an unterminated quote', (root: string) => `RUNNER_BIN="${path.join(root, 'apps/runner/bin')}\n`], + ['a colon-joined path list', (root: string) => `PATHS=${path.join(root, 'bin')}:${path.join(root, 'lib')}\n`], + ])('leaves %s entirely alone rather than repairing it partly', (_label, build) => { + // Arrange + const content = build(roots.mainRoot); + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe(content); + expect(result.escapes).toEqual([]); + }); + + it('recognises the main checkout through a symlinked spelling', () => { + // Arrange + const linkedRoot = path.join(tmpDir, 'link-to-proj'); + fs.symlinkSync(roots.mainRoot, linkedRoot); + const content = `RUNNER_BIN=${path.join(linkedRoot, 'apps/runner/bin')}\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + expect(result.content).toBe( + `RUNNER_BIN=${path.join(roots.worktreeRoot, 'apps/runner/bin')}\n`, + ); + }); + + it('produces the same result when run a second time', () => { + // Arrange + const content = `RUNNER_BIN=${path.join(roots.mainRoot, 'apps/runner/bin')}\n`; + + // Act + const once = rewriteEnvPaths(content, 'server/.env', roots); + const twice = rewriteEnvPaths(once.content, 'server/.env', roots); + + // Assert + expect(twice.content).toBe(once.content); + expect(twice.escapes).toEqual([]); + }); + + it('rewrites a path to an artifact the worktree has not built yet', () => { + // Arrange + const builtInMain = path.join(roots.mainRoot, 'apps/runner/target/release/runner'); + fs.mkdirSync(path.dirname(builtInMain), { recursive: true }); + fs.writeFileSync(builtInMain, 'binary', 'utf-8'); + const content = `RUNNER_BIN=${builtInMain}\n`; + + // Act + const result = rewriteEnvPaths(content, 'server/.env', roots); + + // Assert + const worktreeBinary = path.join(roots.worktreeRoot, 'apps/runner/target/release/runner'); + expect(fs.existsSync(worktreeBinary)).toBe(false); + expect(result.content).toBe(`RUNNER_BIN=${worktreeBinary}\n`); + }); +}); diff --git a/src/core/env-paths.ts b/src/core/env-paths.ts new file mode 100644 index 0000000..71b72e8 --- /dev/null +++ b/src/core/env-paths.ts @@ -0,0 +1,158 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +/** An absolute env path that addressed something outside its own worktree. */ +export interface EnvPathEscape { + readonly file: string; + readonly varName: string; + readonly value: string; + readonly rewritten?: string; +} + +export interface EnvPathRoots { + readonly worktreeRoot: string; + readonly mainRoot: string; + /** Where sibling worktrees live, from `baseWorktreePath`. */ + readonly worktreesDir: string; +} + +export interface RewriteEnvPathsResult { + readonly content: string; + readonly escapes: readonly EnvPathEscape[]; +} + +// `[\s\S]` not `.` so a CRLF line's carriage return still matches. +const ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/; + +/** + * Find the git working tree enclosing a path, which need not exist. + * A primary checkout has a `.git` directory, a linked worktree a `.git` file. + * + * @example + * findEnclosingCheckout('/home/dev/proj/apps/runner/bin'); // returns '/home/dev/proj' + */ +export function findEnclosingCheckout(absolutePath: string): string | undefined { + let dir = path.resolve(absolutePath); + for (;;) { + if (fs.existsSync(path.join(dir, '.git'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** Expand a leading `~/` against the current user's home directory. */ +function expandHome(value: string): string { + return value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value; +} + +/** Whether the value is one absolute path. A colon means a `PATH` list, so skip it. */ +function isAbsolutePathValue(value: string): boolean { + if (value.includes(':')) return false; + return value.startsWith('/') || value.startsWith('~/'); +} + +/** Physical location, so two spellings of one checkout compare equal. */ +function canonical(target: string): string { + const resolved = path.resolve(target); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +function isSamePath(left: string, right: string): boolean { + return canonical(left) === canonical(right); +} + +/** Whether a checkout is the main worktree or one of its siblings. */ +function isRelatedCheckout(checkout: string, roots: EnvPathRoots): boolean { + return ( + isSamePath(checkout, roots.mainRoot) || + isSamePath(path.dirname(checkout), roots.worktreesDir) + ); +} + +/** + * Undefined to leave the value alone, `{}` to report it without rewriting. + * See docs/env-path-isolation.md. + */ +function resolveWorktreePath( + value: string, + roots: EnvPathRoots, +): { readonly rewritten?: string } | undefined { + const absolute = expandHome(value); + const checkout = findEnclosingCheckout(absolute); + if (!checkout) return undefined; + if (isSamePath(checkout, roots.worktreeRoot)) return undefined; + if (!isRelatedCheckout(checkout, roots)) return {}; + return { + rewritten: path.join(roots.worktreeRoot, path.relative(checkout, absolute)), + }; +} + +interface ParsedValue { + readonly value: string; + readonly quote: string; + /** Trailing text such as an inline comment, restored verbatim. */ + readonly suffix: string; +} + +/** + * Split a value from the text trailing it, so only the path is rewritten. + * Undefined when a quote is never closed. + * + * @example + * parseValue('"/opt/tool" # notes'); // returns value '/opt/tool', quote '"', suffix ' # notes' + */ +function parseValue(rawValue: string): ParsedValue | undefined { + const quote = rawValue.startsWith('"') ? '"' : rawValue.startsWith("'") ? "'" : ''; + if (quote) { + const closing = rawValue.indexOf(quote, 1); + if (closing === -1) return undefined; + return { + value: rawValue.slice(1, closing), + quote, + suffix: rawValue.slice(closing + 1), + }; + } + + const match = rawValue.match(/^(\S*)([\s\S]*)$/); + return { value: match![1]!, quote: '', suffix: match![2]! }; +} + +/** + * Repoint absolute paths at this worktree and report every escape. + * Paths in no checkout, such as `/usr/bin/node`, are left alone. + * + * @example + * rewriteEnvPaths('BIN=/proj/bin/tool', 'server/.env', roots); + * // returns 'BIN=/proj/.worktrees/x/bin/tool' plus one escape + */ +export function rewriteEnvPaths( + content: string, + file: string, + roots: EnvPathRoots, +): RewriteEnvPathsResult { + const escapes: EnvPathEscape[] = []; + + const lines = content.split('\n').map((line) => { + const match = line.match(ASSIGNMENT_PATTERN); + if (!match) return line; + + const [, prefix, varName, rawValue] = match; + const parsed = parseValue(rawValue!); + if (!parsed || !isAbsolutePathValue(parsed.value)) return line; + + const resolved = resolveWorktreePath(parsed.value, roots); + if (!resolved) return line; + + escapes.push({ file, varName: varName!, value: parsed.value, rewritten: resolved.rewritten }); + if (!resolved.rewritten) return line; + return `${prefix}${varName}=${parsed.quote}${resolved.rewritten}${parsed.quote}${parsed.suffix}`; + }); + + return { content: lines.join('\n'), escapes }; +} diff --git a/src/output.ts b/src/output.ts index b0696aa..3e38fe8 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs'; +import type { EnvPathEscape } from './core/env-paths'; import type { Allocation, CliResult } from './types'; /** Extract a meaningful message from any error, including AggregateError and child-process errors. */ @@ -107,6 +108,37 @@ export function formatSetupSummary( ].join('\n'); } +/** Format env paths that escaped the worktree. Empty string when none did. */ +export function formatEnvPathEscapes(escapes: readonly EnvPathEscape[]): string { + if (escapes.length === 0) return ''; + + const rewritten = escapes.filter((escape) => escape.rewritten !== undefined); + const foreign = escapes.filter((escape) => escape.rewritten === undefined); + const lines: string[] = []; + + if (rewritten.length > 0) { + lines.push(`wt: rewrote ${plural(rewritten.length, 'env value')} that pointed outside this worktree`); + for (const escape of rewritten) { + lines.push(` ${escape.file} ${escape.varName}`); + lines.push(` was ${escape.value}`); + lines.push(` now ${escape.rewritten}`); + } + } + + if (foreign.length > 0) { + lines.push(`wt: left ${plural(foreign.length, 'env value')} unchanged, pointing into another checkout`); + for (const escape of foreign) { + lines.push(` ${escape.file} ${escape.varName}=${escape.value}`); + } + } + + return lines.join('\n') + '\n'; +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}`; +} + export interface RepairPreviewInput { readonly slot: number; readonly dbName: string;