fix(migrate): preserve checkout and shell semantics

This commit is contained in:
Mohamed Boudra
2026-07-19 00:48:41 +02:00
parent ddaaaa27be
commit c54ddfd07f
7 changed files with 167 additions and 22 deletions

View File

@@ -33,7 +33,7 @@ export function ConductorMigration() {
return (
<>
<View style={conductorRowStyle} testID="conductor-migration-row">
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="conductor-migration-row">
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<Image source={source.icon} style={styles.icon} />
@@ -60,5 +60,3 @@ const styles = StyleSheet.create((theme) => ({
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
icon: { width: theme.iconSize.md, height: theme.iconSize.md },
}));
const conductorRowStyle = [settingsStyles.row, settingsStyles.rowBorder];

View File

@@ -1,7 +1,10 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { slugify } from "@getpaseo/protocol/branch-slug";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
@@ -19,6 +22,7 @@ import { DaemonClient, type WebSocketLike } from "./daemon-client.js";
const DEFAULT_HOST = "localhost:6767";
const DEFAULT_TIMEOUT_MS = 15_000;
const execFileAsync = promisify(execFile);
export interface NodeHostConnectionOptions {
appVersion: string;
@@ -325,15 +329,21 @@ class DaemonHostAutomation implements HostAutomation {
refName: string;
directoryName: string;
}): Promise<{ path: string; created: boolean }> {
const directorySlug = slugify(input.directoryName);
if (!directorySlug)
throw new Error(`Unable to derive a checkout name from ${input.directoryName}`);
const listed = await this.client.getPaseoWorktreeList({ repoRoot: input.rootPath });
if (listed.error)
throw new Error(`Unable to inspect existing checkouts: ${listed.error.message}`);
const existing = listed.worktrees.find(
(worktree) =>
worktree.branchName === input.refName &&
path.basename(worktree.worktreePath) === input.directoryName,
(worktree) => path.basename(worktree.worktreePath) === directorySlug,
);
if (existing) {
if (existing && existing.branchName !== input.refName) {
throw new Error(
`Checkout name ${directorySlug} is already used by branch ${existing.branchName ?? "unknown"}.`,
);
}
if (existing && existsSync(existing.worktreePath)) {
const opened = await this.client.openProject(existing.worktreePath);
if (!opened.workspace) {
throw new Error(opened.error ?? `Unable to open ${existing.worktreePath}`);
@@ -341,9 +351,11 @@ class DaemonHostAutomation implements HostAutomation {
return { path: existing.worktreePath, created: false };
}
await removeMissingCheckoutRegistration(input.rootPath, input.refName);
const result = await this.client.createPaseoWorktree({
cwd: input.rootPath,
worktreeSlug: input.directoryName,
worktreeSlug: directorySlug,
action: "checkout",
refName: input.refName,
});
@@ -357,6 +369,35 @@ class DaemonHostAutomation implements HostAutomation {
}
}
async function removeMissingCheckoutRegistration(rootPath: string, refName: string): Promise<void> {
const { stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain"], {
cwd: rootPath,
encoding: "utf8",
});
const expectedBranch = `refs/heads/${refName.replace(/^refs\/heads\//, "")}`;
const stale = parseGitWorktreeList(stdout).find(
(worktree) => worktree.branch === expectedBranch && !existsSync(worktree.path),
);
if (!stale) return;
// ensureCheckout is the explicit repair boundary: remove only the missing
// registration that blocks the requested branch, never unrelated worktrees.
await execFileAsync("git", ["worktree", "remove", "--force", stale.path], {
cwd: rootPath,
});
}
function parseGitWorktreeList(stdout: string): Array<{ path: string; branch: string | null }> {
return stdout
.trim()
.split(/\n\s*\n/)
.flatMap((entry) => {
const worktreePath = entry.match(/^worktree (.+)$/m)?.[1];
if (!worktreePath) return [];
return [{ path: worktreePath, branch: entry.match(/^branch (.+)$/m)?.[1] ?? null }];
});
}
export async function connectHostAutomation(
options: NodeHostConnectionOptions,
): Promise<HostAutomation> {

View File

@@ -118,6 +118,28 @@ run = 42
});
});
test("skips cwd scripts on Windows instead of emitting POSIX shell syntax", () => {
const repo = emptyRepo("windows-cwd");
writeSettings(
repo,
`
[scripts.run.dev]
command = "npm run dev"
[scripts.run.dev.options]
cwd = "apps/web"
`,
);
const inspected = inspectConductorProjectConfig(repo, {}, "win32");
expect(inspected.config).toBeNull();
expect(inspected.notices).toContainEqual({
code: "conductor-setting-unsupported",
level: "warning",
message: "scripts.dev.cwd: Working-directory scripts are not imported on Windows.",
});
});
function fixtureRepo(name: "current" | "legacy"): string {
const directory = mkdtempSync(path.join(os.tmpdir(), `paseo-migrate-${name}-`));
cleanup.push(directory);

View File

@@ -51,6 +51,7 @@ type RewriteContext = "lifecycle" | "run";
export function inspectConductorProjectConfig(
repoRoot: string,
databaseSettings: ConductorSettings = {},
platform: NodeJS.Platform = process.platform,
): { config: PaseoConfigRaw | null; notices: MigrationNotice[] } {
const settings = mergeSettings(
databaseSettings,
@@ -61,7 +62,7 @@ export function inspectConductorProjectConfig(
mapLifecycle(settings.scripts?.setup, "worktree.setup", "setup", config, notices);
mapLifecycle(settings.scripts?.archive, "worktree.teardown", "teardown", config, notices);
mapRunScripts(settings.scripts?.run, config, notices);
mapRunScripts(settings.scripts?.run, config, notices, platform);
mapMetadataPrompts(settings.prompts, config, notices);
reportUnsupported(repoRoot, settings, notices);
@@ -168,11 +169,12 @@ function mapRunScripts(
runConfig: unknown,
config: PaseoConfigRaw,
notices: MigrationNotice[],
platform: NodeJS.Platform,
): void {
if (runConfig === undefined) return;
const services = new Map<string, string>();
if (typeof runConfig === "string") {
mapRunScript("run", { command: runConfig }, config, notices, services);
mapRunScript("run", { command: runConfig }, config, notices, services, platform);
return;
}
if (!isRecord(runConfig)) {
@@ -186,7 +188,7 @@ function mapRunScripts(
continue;
}
const script = normalizeRunScript(scriptId, value, notices);
if (script) mapRunScript(scriptId, script, config, notices, services);
if (script) mapRunScript(scriptId, script, config, notices, services, platform);
}
}
@@ -268,6 +270,7 @@ function mapRunScript(
config: PaseoConfigRaw,
notices: MigrationNotice[],
serviceNames: Map<string, string>,
platform: NodeJS.Platform,
): void {
const key = `scripts.${scriptId}`;
if (script.hide) {
@@ -287,6 +290,12 @@ function mapRunScript(
let command = appendArgs(script.command, script.args ?? []);
if (script.cwd) {
if (platform === "win32") {
notices.push(
unsupportedSetting(`${key}.cwd`, "Working-directory scripts are not imported on Windows."),
);
return;
}
const cwdPrefix = safeCwdPrefix(script.cwd);
if (!cwdPrefix) {
notices.push(

View File

@@ -94,6 +94,75 @@ test("a different live checkout continues to protect its branch", async () => {
expect(realpathSync(existingPath)).toBe(existingPath);
});
test("ensureCheckout repairs only the requested missing registration", async () => {
const repoRoot = createRepository();
execFileSync("git", ["branch", "unrelated"], { cwd: repoRoot });
const staleFeature = path.join(path.dirname(repoRoot), "stale-feature");
const staleUnrelated = path.join(path.dirname(repoRoot), "stale-unrelated");
execFileSync("git", ["worktree", "add", staleFeature, "feature"], { cwd: repoRoot });
execFileSync("git", ["worktree", "add", staleUnrelated, "unrelated"], { cwd: repoRoot });
rmSync(staleFeature, { recursive: true, force: true });
rmSync(staleUnrelated, { recursive: true, force: true });
const daemon = await createTestPaseoDaemon();
cleanupDaemons.add(daemon);
const paseo = await connectHostAutomation({
appVersion: "0.1.110",
clientId: "migration-stale-registration-e2e",
env: {},
host: `127.0.0.1:${daemon.port}`,
});
cleanupConnections.add(paseo);
const ensured = await paseo.ensureCheckout({
rootPath: repoRoot,
refName: "feature",
directoryName: "imported-feature",
});
expect(path.basename(ensured.path)).toBe("imported-feature");
const registrations = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repoRoot,
encoding: "utf8",
});
expect(registrations).not.toContain(staleFeature);
expect(registrations).toContain(staleUnrelated);
});
test("normalized checkout-name collisions cannot reuse another branch", async () => {
const repoRoot = createRepository();
execFileSync("git", ["branch", "other-feature"], { cwd: repoRoot });
const daemon = await createTestPaseoDaemon();
cleanupDaemons.add(daemon);
const paseo = await connectHostAutomation({
appVersion: "0.1.110",
clientId: "migration-slug-collision-e2e",
env: {},
host: `127.0.0.1:${daemon.port}`,
});
cleanupConnections.add(paseo);
const first = await paseo.ensureCheckout({
rootPath: repoRoot,
refName: "feature",
directoryName: "Foo.Bar",
});
await expect(
paseo.ensureCheckout({
rootPath: repoRoot,
refName: "other-feature",
directoryName: "foo-bar",
}),
).rejects.toThrow(/already used by branch feature/);
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: first.path,
encoding: "utf8",
}).trim(),
).toBe("feature");
});
test("the public connector authenticates to a real password-protected daemon and closes cleanly", async () => {
const daemon = await createTestPaseoDaemon({
auth: { password: hashDaemonPassword("connector-secret") },

View File

@@ -117,6 +117,7 @@ export async function createWorktreeCore(
worktreesRoot: input.worktreesRoot,
});
if (existingWorktree) {
assertExistingWorktreeMatchesIntent(existingWorktree, intent, normalizedSlug);
return { worktree: existingWorktree, intent, repoRoot, created: false };
}
@@ -135,6 +136,22 @@ export async function createWorktreeCore(
};
}
function assertExistingWorktreeMatchesIntent(
existingWorktree: WorktreeConfig,
intent: WorktreeCreationIntent,
slug: string,
): void {
const intendedBranchName =
intent.kind === "checkout-change-request" || intent.kind === "checkout-github-pr"
? (intent.localBranchName ?? intent.headRef)
: intent.branchName;
if (existingWorktree.branchName !== intendedBranchName) {
throw new Error(
`Worktree name ${slug} is already used by branch ${existingWorktree.branchName}.`,
);
}
}
async function resolveForge(
repoRoot: string,
deps: CreateWorktreeCoreDeps,

View File

@@ -1226,7 +1226,6 @@ export const createWorktree = async ({
paseoHome,
worktreesRoot,
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
await pruneVerifiedStaleWorktrees(cwd);
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
mkdirSync(dirname(worktreePath), { recursive: true });
@@ -1284,16 +1283,6 @@ export const createWorktree = async ({
};
};
async function pruneVerifiedStaleWorktrees(cwd: string): Promise<void> {
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
const hasStaleRegistration = parseWorktreeList(stdout).some((entry) => !existsSync(entry.path));
if (!hasStaleRegistration) return;
await runGitCommand(["worktree", "prune"], { cwd, timeout: 30_000 });
}
interface ResolveWorktreeSourcePlanOptions {
cwd: string;
source: WorktreeSource;