From fc0a47f02d799a587e64d1d9c38aee96c5fbb967 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Wed, 5 Aug 2026 14:28:41 -0300 Subject: [PATCH] fix: report broken worktree source symlinks (#3396) --- scripts/setup-worktree.test.ts | 66 ++++++++++++++++++++++++++++++++++ scripts/setup-worktree.ts | 35 +++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/scripts/setup-worktree.test.ts b/scripts/setup-worktree.test.ts index c6921581..931c8757 100644 --- a/scripts/setup-worktree.test.ts +++ b/scripts/setup-worktree.test.ts @@ -6,6 +6,7 @@ import { mkdirSync, realpathSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -344,6 +345,71 @@ describe("setup-worktree", () => { } }); + it("reports broken local-state symlinks in rejected worktree sources", () => { + withSourceAndCurrent((source, current) => { + symlinkSync("missing.env.local", join(source, ".env.local")); + symlinkSync("missing.convex", join(source, ".convex")); + + expect(() => + findSource( + { + force: false, + from: source, + quiet: true, + }, + current, + ), + ).toThrow( + `Rejected sources:\n- ${source}: .env.local is a broken symlink to missing.env.local; .convex is a broken symlink to missing.convex`, + ); + }); + }); + + it("reports a broken Convex symlink when the source env remains readable", () => { + withSourceAndCurrent((source, current) => { + writeWorktree(source, "local-clawhub"); + rmSync(join(source, ".convex"), { force: true, recursive: true }); + symlinkSync("missing.convex", join(source, ".convex")); + + expect(() => + findSource( + { + force: false, + from: source, + quiet: true, + }, + current, + ), + ).toThrow(`${source}: .convex is a broken symlink to missing.convex`); + }); + }); + + it("accepts a remote source when only its unused Convex symlink is broken", () => { + withSourceAndCurrent((source, current) => { + writeWorktree(source, "remote-clawhub", { + env: { + CONVEX_DEPLOYMENT: "dev:remote-clawhub", + CONVEX_SITE_URL: null, + VITE_CONVEX_SITE_URL: null, + VITE_CONVEX_URL: "https://remote-clawhub.convex.cloud", + }, + }); + rmSync(join(source, ".convex"), { force: true, recursive: true }); + symlinkSync("missing.convex", join(source, ".convex")); + + expect( + findSource( + { + force: false, + from: source, + quiet: true, + }, + current, + ).path, + ).toBe(source); + }); + }); + it("rejects local sources that point browser HTTP routes at the Convex function port", () => { withSourceAndCurrent((source, current) => { writeWorktree(source, "local-clawhub", { diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index 4d49f9a2..43f63934 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun import { spawnSync } from "node:child_process"; -import { existsSync, lstatSync, readFileSync, rmSync, symlinkSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, readlinkSync, rmSync, symlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; type Options = { @@ -104,6 +104,25 @@ function readSource(path: string): Source | null { }; } +function describeUnavailableSource(path: string, names: readonly (".env.local" | ".convex")[]) { + const problems: string[] = []; + + for (const name of names) { + const localPath = resolve(path, name); + if (existsSync(localPath)) continue; + + try { + if (lstatSync(localPath).isSymbolicLink()) { + problems.push(`${name} is a broken symlink to ${readlinkSync(localPath)}`); + } + } catch { + // Missing local state is normal; dangling links require operator repair. + } + } + + return problems.join("; "); +} + function readConvexConfig(convexPath: string) { const configPath = resolve(convexPath, "local/default/config.json"); return existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : null; @@ -200,8 +219,22 @@ export function findSource(options: Options, cwd = process.cwd()) { const rejected: string[] = []; for (const candidate of candidates) { + const unavailableEnv = describeUnavailableSource(candidate, [".env.local"]); + if (unavailableEnv) { + const unavailableConvex = describeUnavailableSource(candidate, [".convex"]); + const unavailable = [unavailableEnv, unavailableConvex].filter(Boolean).join("; "); + rejected.push(`${candidate}: ${unavailable}`); + continue; + } const source = readSource(candidate); if (!source) continue; + const unavailableConvex = source.env.CONVEX_DEPLOYMENT?.startsWith("local:") + ? describeUnavailableSource(candidate, [".convex"]) + : ""; + if (unavailableConvex) { + rejected.push(`${candidate}: ${unavailableConvex}`); + continue; + } const invalid = validateSource(source); if (!invalid) return source; rejected.push(`${candidate}: ${invalid}`);