Treat every added folder as an independent project (#2098)

* fix(projects): detect when projects become Git repositories

Project identity was coupled to Git placement, while non-Git roots were dropped by session-scoped observation. Keep identity tied to the selected root and observe Git transitions daemon-wide so empty projects update without rehoming workspaces.

* fix(projects): preserve metadata across Git read failures

Refresh archived workspace facts before persistence and treat only confirmed non-repositories as non-Git so transient failures cannot rewrite stored project metadata.

* fix(projects): close project update races

* fix(projects): refresh checkout metadata when reopening folders

Persist worktree ownership separately from workspace kind and gate exact-root project creation on stable host identity. Refresh active and archived records so missed Git transitions cannot return stale checkout descriptors.

* test(projects): accept Windows aliases for repaired roots

* test(server): retry transient Windows cleanup locks

* fix(projects): propagate project metadata refreshes

Project-only Git transitions now fan out workspace updates for legacy clients. Explicit project creation refreshes stored kind, and equivalent cwd spellings reuse existing workspace records.

* fix(projects): refresh worktree source project kind

* fix(projects): preserve exact-folder runtime isolation

* fix(projects): preserve exact cwd across worktree lifecycle

* fix(projects): harden exact-folder worktree flows

* fix(projects): derive exact cwd from matched path identity

* fix(projects): preserve nested worktree lifecycle

* refactor(projects): centralize workspace placement

* test(e2e): verify isolated server ports

* fix(projects): preserve placement reshape compatibility

* fix(projects): validate created worktree placement

* fix(projects): preserve placement through workspace lifecycle

Archive and recovery were rediscovering or guessing checkout roots instead of consuming the persisted workspace placement. Make the record authoritative for exact cwd, backing worktree, and source repository, and classify stale paths before Git reconciliation.

* fix(worktrees): preserve source checkout root

Convert Git's common administrative directory back to the source checkout root when legacy archive placement is reconstructed.

* fix(worktrees): compare filesystem identities

Resolve discovered worktrees and teardown locations through the shared realpath-aware matcher so Windows short and long path spellings cannot split placement identity.

* fix(worktrees): centralize path containment

Route worktree ownership, listing, resolution, and deletion through the realpath-aware containment primitive so Windows path aliases cannot be filtered by an earlier raw prefix check.

* fix(worktrees): remove duplicate ownership discovery

* test(e2e): isolate daemon restart ownership

* fix(worktrees): make lifecycle operations transactional

* fix(workspaces): share git watches by cwd

* test(worktrees): make teardown proof portable

* fix(sync): integrate project updates with directory owner

* fix(workspaces): close reconciliation edge cases

* refactor(sync): remove duplicate project reconciliation

Keep workspace and project deltas behind the host directory transaction, with owner-boundary coverage for snapshot replay and queued full reconciliation.

* fix(sync): buffer project updates before hydration

Start the workspace transaction with the online connection epoch so project broadcasts cannot publish a partial directory before the authoritative snapshot commits.
This commit is contained in:
Mohamed Boudra
2026-07-17 19:30:17 +02:00
committed by GitHub
parent 1977d330ed
commit 39cb3dbb9c
93 changed files with 7426 additions and 2762 deletions
@@ -307,6 +307,13 @@ describe("checkout git utilities", () => {
});
});
it("reports a real non-git directory as non-git", async () => {
const nonGitDir = join(tempDir, "not-git-status");
mkdirSync(nonGitDir, { recursive: true });
await expect(getCheckoutStatus(nonGitDir)).resolves.toEqual({ isGit: false });
});
it("returns null for getCurrentBranch in a repo with no commits", async () => {
const emptyRepo = join(tempDir, "empty-repo");
mkdirSync(emptyRepo, { recursive: true });
+30 -32
View File
@@ -22,6 +22,7 @@ import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktr
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
LC_ALL: "C",
} as const;
/**
@@ -804,11 +805,11 @@ export type CheckoutSnapshotFacts =
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
};
function isGitError(error: unknown): boolean {
function isNotGitRepositoryError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return /not a git repository/i.test(error.message) || /git repository/i.test(error.message);
return /not a git repository \(or any of the parent directories\): \.git/i.test(error.message);
}
async function requireGitRepo(cwd: string): Promise<void> {
@@ -865,8 +866,11 @@ async function getWorktreeRoot(cwd: string, context?: CheckoutContext): Promise<
logger: context?.logger,
});
return parseGitRevParsePath(stdout);
} catch {
return null;
} catch (error) {
if (isNotGitRepositoryError(error)) {
return null;
}
throw error;
}
}
@@ -1533,35 +1537,29 @@ async function inspectCheckoutContext(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutInspectionContext | null> {
try {
const root = await getWorktreeRoot(cwd, context);
if (!root) {
return null;
}
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] =
await Promise.all([
getCurrentBranch(cwd),
getOriginRemoteUrl(cwd),
resolveAbsoluteGitDir(cwd),
resolveGitCommonDir(cwd),
getPaseoWorktreeForCwd(cwd, context, root),
]);
return {
worktreeRoot: root,
currentBranch,
remoteUrl,
absoluteGitDir,
gitCommonDir,
paseoWorktree,
};
} catch (error) {
if (isGitError(error)) {
return null;
}
throw error;
const root = await getWorktreeRoot(cwd, context);
if (!root) {
return null;
}
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] = await Promise.all(
[
getCurrentBranch(cwd),
getOriginRemoteUrl(cwd),
resolveAbsoluteGitDir(cwd),
resolveGitCommonDir(cwd),
getPaseoWorktreeForCwd(cwd, context, root),
],
);
return {
worktreeRoot: root,
currentBranch,
remoteUrl,
absoluteGitDir,
gitCommonDir,
paseoWorktree,
};
}
function buildPullRequestLookupTargetFromBranchConfig(
+28 -1
View File
@@ -1,6 +1,14 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js";
import {
areEquivalentPaths,
createPathEquivalenceMatcher,
getRealpathAwareRelativePath,
isPathInsideRoot,
} from "./path.js";
describe("path equivalence", () => {
test.each([
@@ -33,4 +41,23 @@ describe("path equivalence", () => {
false,
);
});
test.skipIf(process.platform === "win32")(
"derives the contained suffix from a realpath-equivalent root",
() => {
const tempDir = mkdtempSync(join(tmpdir(), "paseo-path-"));
try {
const realRoot = join(tempDir, "real-root");
const nestedPath = join(realRoot, "packages", "app");
const aliasRoot = join(tempDir, "root-alias");
mkdirSync(nestedPath, { recursive: true });
symlinkSync(realRoot, aliasRoot, "dir");
expect(getRealpathAwareRelativePath(aliasRoot, nestedPath)).toBe(join("packages", "app"));
expect(getRealpathAwareRelativePath(aliasRoot, tempDir)).toBeNull();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
});
+32 -10
View File
@@ -59,22 +59,44 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
}
export function isPathInsideRoot(root: string, candidate: string): boolean {
return getRelativePathInsideRoot(root, candidate) !== null;
}
/**
* Returns the candidate's relative suffix when it is inside root.
*
* The suffix is derived from the same lexical path pair used to prove
* containment. Callers that map an existing filesystem path into a new root
* must keep those two operations coupled.
*/
export function getRealpathAwareRelativePath(root: string, candidate: string): string | null {
const rootVariants = collectPathVariants(root);
const candidateVariants = collectPathVariants(candidate);
for (const rootVariant of rootVariants) {
for (const candidateVariant of candidateVariants) {
const relativePath = getRelativePathInsideRoot(rootVariant, candidateVariant);
if (relativePath !== null) return relativePath;
}
}
return null;
}
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
return getRealpathAwareRelativePath(root, candidate) !== null;
}
function getRelativePathInsideRoot(root: string, candidate: string): string | null {
const compareAsWindows = shouldCompareAsWindows(root, candidate);
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
const relative = platformPath.relative(normalizedRoot, normalizedCandidate);
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative));
}
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
const rootVariants = collectPathVariants(root);
const candidateVariants = collectPathVariants(candidate);
return rootVariants.some((rootVariant) =>
candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)),
);
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative))
? relative
: null;
}
function collectPathVariants(value: string): string[] {
+83 -1
View File
@@ -4,14 +4,24 @@ import {
deriveWorktreeProjectHash,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
slugify,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "fs";
import {
mkdtempSync,
mkdirSync,
rmSync,
existsSync,
realpathSync,
symlinkSync,
writeFileSync,
} from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { createRealpathAwarePathMatcher } from "./path";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
@@ -86,6 +96,12 @@ describe("paseo worktree manager", () => {
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
await expect(
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
).resolves.toMatchObject({
allowed: true,
worktreePath: created.worktreePath,
});
});
it("rejects paths that are not under the paseo worktrees root", async () => {
@@ -97,6 +113,72 @@ describe("paseo worktree manager", () => {
expect(ownership.allowed).toBe(false);
});
it("reports the source checkout root separately from Git's common directory", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "placement-root-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "placement-root",
paseoHome,
});
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
expect(createRealpathAwarePathMatcher(repoDir)(ownership.repoRoot ?? "")).toBe(true);
expect(createRealpathAwarePathMatcher(created.worktreePath)(ownership.worktreePath ?? "")).toBe(
true,
);
});
it("maps only root-contained workspace paths into a replacement worktree", () => {
const sourceWorktreePath = join(tempDir, "source-worktree");
const targetWorktreePath = join(tempDir, "target-worktree");
const nestedWorkspaceCwd = join(sourceWorktreePath, "packages", "app");
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: sourceWorktreePath,
targetWorktreePath,
}),
).toBe(targetWorktreePath);
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: nestedWorkspaceCwd,
targetWorktreePath,
}),
).toBe(join(targetWorktreePath, "packages", "app"));
expect(() =>
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: join(tempDir, "outside-worktree"),
targetWorktreePath,
}),
).toThrow("outside its source worktree");
});
it.skipIf(process.platform === "win32")(
"maps a realpath-equivalent source workspace into the matching target subdirectory",
() => {
const sourceWorktreePath = join(tempDir, "source-worktree");
const workspaceCwd = join(sourceWorktreePath, "packages", "app");
const sourceAlias = join(tempDir, "source-alias");
const targetWorktreePath = join(tempDir, "target-worktree");
mkdirSync(workspaceCwd, { recursive: true });
symlinkSync(sourceWorktreePath, sourceAlias, "dir");
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath: sourceAlias,
workspaceCwd,
targetWorktreePath,
}),
).toBe(join(targetWorktreePath, "packages", "app"));
},
);
it("rejects the worktrees root itself and the per-repo hash dir", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const worktreesRoot = join(paseoHome, "worktrees");
+108 -95
View File
@@ -35,7 +35,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { expandTilde } from "./path.js";
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
@@ -724,11 +724,15 @@ export async function resolveWorktreeRuntimeEnv(options: {
export async function runWorktreeTeardownCommands(options: {
worktreePath: string;
teardownCwd?: string;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeTeardownCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
const teardownCwd = options.teardownCwd ?? options.worktreePath;
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
}
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
if (teardownCommands.length === 0) {
return [];
}
@@ -756,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: {
const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of teardownCommands) {
const result = await execSetupCommand(cmd, {
cwd: options.worktreePath,
cwd: teardownCwd,
env: teardownEnv,
});
results.push(result);
@@ -772,6 +776,23 @@ export async function runWorktreeTeardownCommands(options: {
return results;
}
export async function seedPaseoConfigFile(options: {
sourceCwd: string;
targetCwd: string;
}): Promise<void> {
const sourceConfigPath = join(options.sourceCwd, "paseo.json");
const targetConfigPath = join(options.targetCwd, "paseo.json");
try {
await stat(targetConfigPath);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await copyFile(sourceConfigPath, targetConfigPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
});
}
/**
* Get the git common directory (shared across worktrees) for a given cwd.
* This is where refs, objects, etc. are stored.
@@ -845,6 +866,36 @@ export async function computeWorktreePath(
return join(projectWorktreesRoot, slug);
}
export function mapWorkspaceCwdToWorktree(input: {
sourceWorktreePath: string;
workspaceCwd: string;
targetWorktreePath: string;
}): string {
const relativeWorkspaceCwd = getRealpathAwareRelativePath(
input.sourceWorktreePath,
input.workspaceCwd,
);
if (relativeWorkspaceCwd === null) {
throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`);
}
return mapWorkspaceRelativeCwdToWorktree({
relativeWorkspaceCwd,
targetWorktreePath: input.targetWorktreePath,
});
}
export function mapWorkspaceRelativeCwdToWorktree(input: {
relativeWorkspaceCwd: string;
targetWorktreePath: string;
}): string {
const mappedCwd = resolve(input.targetWorktreePath, input.relativeWorkspaceCwd);
if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) {
throw new Error(`Workspace cwd escapes its target worktree: ${input.relativeWorkspaceCwd}`);
}
return mappedCwd;
}
function normalizePathForOwnership(input: string): string {
try {
return realpathSync(input);
@@ -878,13 +929,13 @@ export async function isPaseoOwnedWorktreeCwd(
}
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
const paseoWorktreesPrefix = normalizePathForOwnership(worktreesBaseRoot) + sep;
const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd);
// Ownership is defined by the path living under <worktrees-root>/<hash>/<slug>[/...].
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
// path shape alone is sufficient proof of ownership, even when git has already
// forgotten about the worktree.
if (!resolvedCwd.startsWith(paseoWorktreesPrefix)) {
if (relativePath === null) {
return {
allowed: false,
...(repoRoot !== undefined ? { repoRoot } : {}),
@@ -892,8 +943,7 @@ export async function isPaseoOwnedWorktreeCwd(
};
}
const relative = resolvedCwd.slice(paseoWorktreesPrefix.length);
const parts = relative.split(sep).filter((part) => part.length > 0);
const parts = relativePath.split(sep).filter((part) => part.length > 0);
if (parts.length < 2) {
return {
allowed: false,
@@ -907,7 +957,7 @@ export async function isPaseoOwnedWorktreeCwd(
allowed: true,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
worktreePath: join(worktreesRoot, parts[1]),
};
}
@@ -977,10 +1027,9 @@ export async function listPaseoWorktrees({
envOverlay: READ_ONLY_GIT_ENV,
});
const rootPrefix = normalizePathForOwnership(projectWorktreesRoot) + sep;
return parseWorktreeList(stdout)
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
.filter((entry) => entry.path.startsWith(rootPrefix))
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
.map((entry) =>
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
);
@@ -1018,76 +1067,25 @@ export async function resolveExistingWorktreeForSlug({
};
}
export async function resolvePaseoWorktreeRootForCwd(
cwd: string,
options?: WorktreeRootOptions,
): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> {
let gitCommonDir: string;
try {
gitCommonDir = await getGitCommonDir(cwd);
} catch {
return null;
}
const worktreesRoot = await getPaseoWorktreesRoot(
cwd,
options?.paseoHome,
options?.worktreesRoot,
);
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
let worktreeRoot: string | null = null;
try {
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
worktreeRoot = parseGitRevParsePath(stdout);
} catch {
worktreeRoot = null;
}
if (!worktreeRoot) {
return null;
}
const resolvedWorktreeRoot = normalizePathForOwnership(worktreeRoot);
if (!resolvedWorktreeRoot.startsWith(resolvedRoot)) {
return null;
}
const knownWorktrees = await listPaseoWorktrees({
cwd,
paseoHome: options?.paseoHome,
worktreesRoot: options?.worktreesRoot,
});
const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot);
if (!match) {
return null;
}
return {
repoRoot: gitCommonDir,
worktreeRoot: worktreesRoot,
worktreePath: match.path,
};
export interface DeletePaseoWorktreeOptions {
cwd: string | null;
worktreePath?: string;
teardownCwds?: string[];
worktreeSlug?: string;
worktreesRoot?: string;
paseoHome?: string;
worktreesBaseRoot?: string;
}
export async function deletePaseoWorktree({
cwd,
worktreePath,
teardownCwds,
worktreeSlug,
worktreesRoot,
paseoHome,
worktreesBaseRoot,
}: {
cwd: string | null;
worktreePath?: string;
worktreeSlug?: string;
worktreesRoot?: string;
paseoHome?: string;
worktreesBaseRoot?: string;
}): Promise<void> {
}: DeletePaseoWorktreeOptions): Promise<void> {
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
}
@@ -1104,25 +1102,30 @@ export async function deletePaseoWorktree({
throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree");
}
const resolvedRoot = normalizePathForOwnership(resolvedWorktreesRoot) + sep;
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
const resolvedRequested = normalizePathForOwnership(requestedPath);
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
paseoHome,
worktreesRoot: worktreesBaseRoot,
});
const resolvedWorktree =
(
await resolvePaseoWorktreeRootForCwd(requestedPath, {
paseoHome,
worktreesRoot: worktreesBaseRoot,
})
)?.worktreePath ?? resolvedRequested;
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
if (!resolvedWorktree.startsWith(resolvedRoot)) {
const relativeWorktreePath = getRealpathAwareRelativePath(
resolvedWorktreesRoot,
resolvedWorktree,
);
if (relativeWorktreePath === null || relativeWorktreePath === "") {
throw new Error("Refusing to delete non-Paseo worktree");
}
if (await pathExists(resolvedWorktree)) {
await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree,
});
for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) {
await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree,
teardownCwd,
});
}
}
if (cwd) {
@@ -1150,6 +1153,27 @@ export async function deletePaseoWorktree({
}
}
export async function rollbackCreatedPaseoWorktree(
options: DeletePaseoWorktreeOptions,
cause: unknown,
): Promise<never> {
let cleanupError: unknown;
try {
await deletePaseoWorktree(options);
} catch (error) {
cleanupError = error;
}
if (cleanupError) {
const failure = new Error(
`${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
{ cause },
);
Object.assign(failure, { cleanupError });
throw failure;
}
throw cause;
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
@@ -1243,18 +1267,7 @@ export const createWorktree = async ({
: {}),
});
// If paseo.json exists in the main repo but wasn't checked into the worktree
// (e.g. uncommitted on first-time setup), seed the worktree with it so setup
// commands and scripts pick up the user's intended config.
const mainConfigPath = join(cwd, "paseo.json");
const worktreeConfigPath = join(worktreePath, "paseo.json");
try {
await stat(worktreeConfigPath);
} catch {
await copyFile(mainConfigPath, worktreeConfigPath).catch((err) => {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
});
}
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
if (runSetup) {
await runWorktreeSetupCommands({