diff --git a/packages/client/src/node.test.ts b/packages/client/src/node.test.ts index fc407f30a..a6a652e1f 100644 --- a/packages/client/src/node.test.ts +++ b/packages/client/src/node.test.ts @@ -43,6 +43,21 @@ test("discovers the running daemon TCP address from its PID record", () => { ]); }); +test("normalizes the daemon's bare IPv6 loopback PID address", () => { + const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-ipv6-")); + cleanup.push(paseoHome); + writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "::1:7789" })); + + expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([ + "[::1]:7789", + "localhost:6767", + ]); + expect(resolveDaemonTarget("::1:7789")).toEqual({ + type: "tcp", + url: "ws://[::1]:7789/ws", + }); +}); + test("normalizes TCP, Unix, pipe, and Windows path-shaped targets", () => { expect(normalizeDaemonHost("tcp://Example.com:6767?ssl=true&password=secret")).toBe( "tcp://Example.com:6767?ssl=true&password=secret", diff --git a/packages/client/src/node.ts b/packages/client/src/node.ts index f977726e2..d27b8ca11 100644 --- a/packages/client/src/node.ts +++ b/packages/client/src/node.ts @@ -89,6 +89,9 @@ export function normalizeDaemonHost(raw: string): string | null { if (value.startsWith("/") || value.startsWith("~")) return `unix://${value}`; if (/^[A-Za-z]:[/\\]/.test(value)) return null; if (/^\d+$/.test(value)) return `127.0.0.1:${value}`; + const ipv6Loopback = normalizeBareIpv6Loopback(value); + if (ipv6Loopback) return ipv6Loopback; + if (value.startsWith("::1:")) return null; return value.includes(":") ? value : null; } @@ -166,7 +169,18 @@ export function resolveDaemonTarget(host: string): DaemonTarget { ); return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }) }; } - return { type: "tcp", url: `ws://${value}/ws` }; + const endpoint = normalizeBareIpv6Loopback(value) ?? normalizeHostPort(value); + return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: false }) }; +} + +function normalizeBareIpv6Loopback(value: string): string | null { + const match = value.match(/^::1:(\d{1,5})$/); + if (!match) return null; + try { + return normalizeHostPort(`[::1]:${match[1]}`); + } catch { + return null; + } } export function resolveDaemonPassword( diff --git a/packages/migrate/src/sources/conductor/inspect.test.ts b/packages/migrate/src/sources/conductor/inspect.test.ts index 28f2e979e..2d3e7fc85 100644 --- a/packages/migrate/src/sources/conductor/inspect.test.ts +++ b/packages/migrate/src/sources/conductor/inspect.test.ts @@ -83,6 +83,41 @@ test("reports an unknown workspace state by its exact value", () => { }); }); +test("does not adopt an existing worktree on a different branch", () => { + const repo = createRepository("wrong-worktree-branch", null); + execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { + cwd: repo, + env: { + ...process.env, + GIT_AUTHOR_NAME: "Paseo Test", + GIT_AUTHOR_EMAIL: "test@paseo.local", + GIT_COMMITTER_NAME: "Paseo Test", + GIT_COMMITTER_EMAIL: "test@paseo.local", + }, + }); + execFileSync("git", ["branch", "recorded-branch"], { cwd: repo }); + + const inspected = inspectCatalog({ + repos: [repoRecord("repo", repo)], + workspaces: [ + { + id: "workspace-wrong-branch", + repoId: "repo", + branch: "recorded-branch", + state: "ready", + path: repo, + archiveCommit: null, + }, + ], + }); + + expect(inspected.projects[0]?.workspaces[0]).toMatchObject({ + sourceId: "workspace-wrong-branch", + disposition: "invalid", + notices: [{ code: "invalid-worktree" }], + }); +}); + function createRepository(name: string, settings: string | null): string { const repo = mkdtempSync(path.join(os.tmpdir(), `paseo-inspect-${name}-`)); cleanup.push(repo); diff --git a/packages/migrate/src/sources/conductor/inspect.ts b/packages/migrate/src/sources/conductor/inspect.ts index 191d56667..94a95dda4 100644 --- a/packages/migrate/src/sources/conductor/inspect.ts +++ b/packages/migrate/src/sources/conductor/inspect.ts @@ -112,7 +112,7 @@ function inspectWorkspace( } if (workspace.path && isDirectory(workspace.path)) { - const valid = isLinkedWorktree(repo.rootPath, workspace.path); + const valid = isLinkedWorktree(repo.rootPath, workspace.path, workspace.branch); return { sourceId: workspace.id, state: "ready", @@ -126,7 +126,9 @@ function inspectWorkspace( : [ notice( "invalid-worktree", - `Skipped ${workspace.path}: not linked to ${repo.rootPath}.`, + workspace.branch + ? `Skipped ${workspace.path}: not linked to ${repo.rootPath} on recorded branch ${workspace.branch}.` + : `Skipped ${workspace.path}: not linked to ${repo.rootPath}.`, ), ], }; @@ -174,14 +176,21 @@ function isGitRepository(rootPath: string): boolean { } } -function isLinkedWorktree(rootPath: string, workspacePath: string): boolean { +function isLinkedWorktree( + rootPath: string, + workspacePath: string, + expectedBranch: string | null, +): boolean { try { const rootCommon = resolveGitPath(rootPath, git(rootPath, ["rev-parse", "--git-common-dir"])); const workspaceCommon = resolveGitPath( workspacePath, git(workspacePath, ["rev-parse", "--git-common-dir"]), ); - return realpathSync(rootCommon) === realpathSync(workspaceCommon); + if (realpathSync(rootCommon) !== realpathSync(workspaceCommon)) return false; + if (!expectedBranch) return true; + const actualBranch = git(workspacePath, ["symbolic-ref", "--short", "HEAD"]); + return actualBranch === expectedBranch.replace(/^refs\/heads\//, ""); } catch { return false; }