feat: improve agent workspace flows and directory suggestions

This commit is contained in:
Mohamed Boudra
2026-02-16 23:19:01 +07:00
parent 22da0951d5
commit 170ee27813
49 changed files with 3053 additions and 293 deletions
@@ -0,0 +1,154 @@
import {
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { searchHomeDirectories } from "./directory-suggestions.js";
describe("searchHomeDirectories", () => {
let tempRoot: string;
let homeDir: string;
let outsideDir: string;
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
homeDir = path.join(tempRoot, "home");
outsideDir = path.join(tempRoot, "outside");
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
it("returns an empty list for blank queries", async () => {
await expect(
searchHomeDirectories({
homeDir,
query: " ",
limit: 10,
})
).resolves.toEqual([]);
});
it("returns only existing directories", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "proj",
limit: 10,
});
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
});
it("supports home-relative path query syntax", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~/projects/pa",
limit: 10,
});
expect(results).toEqual([
path.join(homeDir, "projects", "paseo"),
]);
});
it("prioritizes exact segment matches before segment-prefix matches", async () => {
const exactSegmentPath = path.join(
homeDir,
"something",
"faro",
"something-else"
);
const prefixSegmentPath = path.join(
homeDir,
"something",
"somethingelse",
"faro-bla"
);
mkdirSync(exactSegmentPath, { recursive: true });
mkdirSync(prefixSegmentPath, { recursive: true });
const results = await searchHomeDirectories({
homeDir,
query: "faro",
limit: 30,
});
const exactIndex = results.indexOf(exactSegmentPath);
const prefixIndex = results.indexOf(prefixSegmentPath);
expect(exactIndex).toBeGreaterThanOrEqual(0);
expect(prefixIndex).toBeGreaterThanOrEqual(0);
expect(exactIndex).toBeLessThan(prefixIndex);
});
it("prioritizes partial matches that appear earlier in the path", async () => {
const earlierPath = path.join(homeDir, "farofoo");
const laterPath = path.join(homeDir, "x", "y", "farofoo");
mkdirSync(earlierPath, { recursive: true });
mkdirSync(laterPath, { recursive: true });
const results = await searchHomeDirectories({
homeDir,
query: "arofo",
limit: 30,
});
const earlierIndex = results.indexOf(earlierPath);
const laterIndex = results.indexOf(laterPath);
expect(earlierIndex).toBeGreaterThanOrEqual(0);
expect(laterIndex).toBeGreaterThanOrEqual(0);
expect(earlierIndex).toBeLessThan(laterIndex);
});
it("returns home-root suggestions when query is '~'", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~",
limit: 20,
});
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "documents"));
});
it("does not return paths that escape home through symlinks", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "outside",
limit: 20,
});
expect(results).not.toContain(path.join(homeDir, "outside-link"));
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
});
it("respects the result limit", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "p",
limit: 1,
});
expect(results).toHaveLength(1);
});
});
@@ -0,0 +1,456 @@
import type { Dirent } from "node:fs";
import { readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
export interface SearchHomeDirectoriesOptions {
homeDir: string;
query: string;
limit?: number;
maxDepth?: number;
maxDirectoriesScanned?: number;
}
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000;
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
type QueryParts = {
isPathQuery: boolean;
parentPart: string;
searchTerm: string;
};
type RankedDirectory = {
absolutePath: string;
matchTier: number;
segmentIndex: number;
matchOffset: number;
depth: number;
};
type ChildDirectoryEntry = {
name: string;
absolutePath: string;
};
type DirectoryListCacheEntry = {
expiresAt: number;
entries: ChildDirectoryEntry[];
};
const directoryListCache = new Map<string, DirectoryListCacheEntry>();
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
export async function searchHomeDirectories(
options: SearchHomeDirectoriesOptions
): Promise<string[]> {
const query = options.query.trim();
if (!query) {
return [];
}
const limit = normalizeLimit(options.limit);
const homeRoot = await resolveDirectory(options.homeDir);
if (!homeRoot) {
return [];
}
const queryParts = normalizeQueryParts(query, homeRoot);
if (!queryParts) {
return [];
}
if (queryParts.isPathQuery) {
return searchWithinParentDirectory({
homeRoot,
parentPart: queryParts.parentPart,
searchTerm: queryParts.searchTerm,
limit,
});
}
return searchAcrossHomeTree({
homeRoot,
searchTerm: queryParts.searchTerm,
limit,
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxDirectoriesScanned:
options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
});
}
function normalizeLimit(limit: number | undefined): number {
const candidate = limit ?? DEFAULT_LIMIT;
if (!Number.isFinite(candidate)) {
return DEFAULT_LIMIT;
}
const bounded = Math.trunc(candidate);
return Math.max(1, Math.min(MAX_LIMIT, bounded));
}
async function searchWithinParentDirectory(input: {
homeRoot: string;
parentPart: string;
searchTerm: string;
limit: number;
}): Promise<string[]> {
const parentPath = path.resolve(input.homeRoot, input.parentPart || ".");
const parentRoot = await resolveDirectory(parentPath);
if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) {
return [];
}
const searchLower = input.searchTerm.toLowerCase();
const ranked: RankedDirectory[] = [];
const entries = await listChildDirectories({
directory: parentRoot,
homeRoot: input.homeRoot,
});
for (const entry of entries) {
if (searchLower && !entry.name.toLowerCase().includes(searchLower)) {
continue;
}
ranked.push(
rankDirectory({
absolutePath: entry.absolutePath,
homeRoot: input.homeRoot,
searchLower,
})
);
}
return dedupeAndSort(ranked).slice(0, input.limit);
}
async function searchAcrossHomeTree(input: {
homeRoot: string;
searchTerm: string;
limit: number;
maxDepth: number;
maxDirectoriesScanned: number;
}): Promise<string[]> {
const queue: Array<{ directory: string; depth: number }> = [
{ directory: input.homeRoot, depth: 0 },
];
const visited = new Set<string>([input.homeRoot]);
const ranked: RankedDirectory[] = [];
let scanned = 0;
const searchLower = input.searchTerm.toLowerCase();
for (
let queueIndex = 0;
queueIndex < queue.length && scanned < input.maxDirectoriesScanned;
queueIndex += 1
) {
const current = queue[queueIndex];
if (!current) continue;
const entries = await listChildDirectories({
directory: current.directory,
homeRoot: input.homeRoot,
});
for (const entry of entries) {
const resolvedCandidate = entry.absolutePath;
if (visited.has(resolvedCandidate)) {
continue;
}
visited.add(resolvedCandidate);
scanned += 1;
const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate);
if (
relativePath.toLowerCase().includes(searchLower) ||
entry.name.toLowerCase().includes(searchLower)
) {
ranked.push(
rankDirectory({
absolutePath: resolvedCandidate,
homeRoot: input.homeRoot,
searchLower,
})
);
}
if (
current.depth < input.maxDepth &&
scanned < input.maxDirectoriesScanned
) {
queue.push({ directory: resolvedCandidate, depth: current.depth + 1 });
}
}
}
return dedupeAndSort(ranked).slice(0, input.limit);
}
function dedupeAndSort(ranked: RankedDirectory[]): string[] {
const byPath = new Map<string, RankedDirectory>();
for (const entry of ranked) {
const existing = byPath.get(entry.absolutePath);
if (!existing || compareRankedDirectories(entry, existing) < 0) {
byPath.set(entry.absolutePath, entry);
}
}
return Array.from(byPath.values())
.sort(compareRankedDirectories)
.map((entry) => entry.absolutePath);
}
function compareRankedDirectories(
left: RankedDirectory,
right: RankedDirectory
): number {
if (left.matchTier !== right.matchTier) {
return left.matchTier - right.matchTier;
}
if (left.segmentIndex !== right.segmentIndex) {
return left.segmentIndex - right.segmentIndex;
}
if (left.matchOffset !== right.matchOffset) {
return left.matchOffset - right.matchOffset;
}
if (left.depth !== right.depth) {
return left.depth - right.depth;
}
return left.absolutePath.localeCompare(right.absolutePath);
}
function rankDirectory(input: {
absolutePath: string;
homeRoot: string;
searchLower: string;
}): RankedDirectory {
const relative = normalizeRelativePath(input.homeRoot, input.absolutePath);
const relativeLower = relative.toLowerCase();
const depth = relative === "." ? 0 : relative.split("/").length;
const searchLower = input.searchLower;
if (!searchLower) {
return {
absolutePath: input.absolutePath,
matchTier: 3,
segmentIndex: NO_SEGMENT_INDEX,
matchOffset: 0,
depth,
};
}
const segments = relativeLower === "." ? [] : relativeLower.split("/");
const exactSegmentIndex = findSegmentMatchIndex(
segments,
(segment) => segment === searchLower
);
const prefixSegmentIndex = findSegmentMatchIndex(
segments,
(segment) => segment.startsWith(searchLower)
);
const partialSegmentIndex = findSegmentMatchIndex(
segments,
(segment) => segment.includes(searchLower)
);
const matchOffset = relativeLower.indexOf(searchLower);
let matchTier = 4;
let segmentIndex = NO_SEGMENT_INDEX;
if (exactSegmentIndex >= 0) {
matchTier = 0;
segmentIndex = exactSegmentIndex;
} else if (prefixSegmentIndex >= 0) {
matchTier = 1;
segmentIndex = prefixSegmentIndex;
} else if (partialSegmentIndex >= 0) {
matchTier = 2;
segmentIndex = partialSegmentIndex;
} else if (relativeLower.startsWith(searchLower)) {
matchTier = 3;
}
return {
absolutePath: input.absolutePath,
matchTier,
segmentIndex,
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
depth,
};
}
function findSegmentMatchIndex(
segments: string[],
predicate: (segment: string) => boolean
): number {
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
if (!segment) {
continue;
}
if (predicate(segment)) {
return index;
}
}
return -1;
}
function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
const relative = path.relative(homeRoot, absolutePath);
if (!relative) {
return ".";
}
return relative.split(path.sep).join("/");
}
function isPathInsideRoot(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
const typedQuery = query.trim().replace(/\\/g, "/");
let normalized = typedQuery;
if (!normalized) {
return null;
}
if (normalized.startsWith("~")) {
normalized = normalized.slice(1);
if (normalized.startsWith("/")) {
normalized = normalized.slice(1);
}
}
if (path.isAbsolute(normalized)) {
const absolute = path.resolve(normalized);
if (!isPathInsideRoot(homeRoot, absolute)) {
return null;
}
normalized = normalizeRelativePath(homeRoot, absolute);
}
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
if (!normalized) {
// Treat "~" and "~/" as a request to browse the home root.
if (typedQuery === "~" || typedQuery === "~/") {
return {
isPathQuery: true,
parentPart: "",
searchTerm: "",
};
}
return null;
}
const isPathQuery = normalized.includes("/");
const slashIndex = normalized.lastIndexOf("/");
const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : "";
const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
return {
isPathQuery,
parentPart,
searchTerm,
};
}
async function resolveDirectory(inputPath: string): Promise<string | null> {
try {
const resolved = await realpath(path.resolve(inputPath));
const stats = await stat(resolved);
if (!stats.isDirectory()) {
return null;
}
return resolved;
} catch {
return null;
}
}
async function listChildDirectories(input: {
directory: string;
homeRoot: string;
}): Promise<ChildDirectoryEntry[]> {
const now = Date.now();
const cached = directoryListCache.get(input.directory);
if (cached && cached.expiresAt > now) {
return cached.entries;
}
const dirents = await readdir(input.directory, { withFileTypes: true }).catch(
() => [] as Dirent[]
);
const entries: ChildDirectoryEntry[] = [];
for (const dirent of dirents) {
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) {
continue;
}
const candidatePath = path.join(input.directory, dirent.name);
const absolutePath = await resolveDirectoryCandidate({
candidatePath,
dirent,
homeRoot: input.homeRoot,
});
if (!absolutePath) {
continue;
}
entries.push({
name: dirent.name,
absolutePath,
});
}
setDirectoryListCache(input.directory, {
expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS,
entries,
});
return entries;
}
async function resolveDirectoryCandidate(input: {
candidatePath: string;
dirent: Dirent;
homeRoot: string;
}): Promise<string | null> {
if (input.dirent.isDirectory()) {
const resolved = path.resolve(input.candidatePath);
return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null;
}
const resolved = await resolveDirectory(input.candidatePath);
if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) {
return null;
}
return resolved;
}
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
directoryListCache.set(cacheKey, entry);
pruneDirectoryListCache();
}
function pruneDirectoryListCache(): void {
if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
return;
}
const now = Date.now();
for (const [cacheKey, entry] of directoryListCache) {
if (entry.expiresAt <= now) {
directoryListCache.delete(cacheKey);
}
}
while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
const oldestKey = directoryListCache.keys().next().value as string | undefined;
if (!oldestKey) {
return;
}
directoryListCache.delete(oldestKey);
}
}
+52 -1
View File
@@ -2,11 +2,26 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { isAbsolute, join, resolve } from "path";
import { z } from "zod";
const PaseoWorktreeMetadataSchema = z.object({
const PaseoWorktreeMetadataV1Schema = z.object({
version: z.literal(1),
baseRefName: z.string().min(1),
});
const PaseoWorktreeMetadataV2Schema = z.object({
version: z.literal(2),
baseRefName: z.string().min(1),
runtime: z
.object({
worktreePort: z.number().int().positive(),
})
.optional(),
});
const PaseoWorktreeMetadataSchema = z.union([
PaseoWorktreeMetadataV1Schema,
PaseoWorktreeMetadataV2Schema,
]);
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
@@ -68,6 +83,31 @@ export function writePaseoWorktreeMetadata(
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
}
export function writePaseoWorktreeRuntimeMetadata(
worktreeRoot: string,
options: { worktreePort: number }
): void {
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
}
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current) {
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
}
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
const next: PaseoWorktreeMetadata = {
version: 2,
baseRefName: current.baseRefName,
runtime: {
worktreePort: options.worktreePort,
},
};
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
if (!existsSync(metadataPath)) {
@@ -85,3 +125,14 @@ export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
}
return metadata.baseRefName;
}
export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null {
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
if (!metadata) {
return null;
}
if (metadata.version === 2 && metadata.runtime?.worktreePort) {
return metadata.runtime.worktreePort;
}
return null;
}
@@ -5,6 +5,7 @@ import {
getWorktreeTerminalSpecs,
isPaseoOwnedWorktreeCwd,
listPaseoWorktrees,
resolveWorktreeRuntimeEnv,
type WorktreeSetupCommandProgressEvent,
runWorktreeSetupCommands,
slugify,
@@ -14,6 +15,7 @@ import { execSync } from "child_process";
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import net from "node:net";
describe("createWorktree", () => {
let tempDir: string;
@@ -265,6 +267,68 @@ describe("createWorktree", () => {
expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true);
});
it("reuses persisted worktree runtime port across resolutions", async () => {
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "runtime-env-port-reuse",
runSetup: false,
paseoHome,
});
const first = await resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
});
const second = await resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
});
expect(second.PASEO_WORKTREE_PORT).toBe(first.PASEO_WORKTREE_PORT);
});
it("fails runtime env resolution when persisted port is in use", async () => {
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "runtime-env-port-conflict",
runSetup: false,
paseoHome,
});
const env = await resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
});
const port = Number(env.PASEO_WORKTREE_PORT);
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, () => resolve());
});
await expect(
resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
})
).rejects.toThrow(`Persisted worktree port ${port} is already in use`);
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
it("cleans up worktree if setup command fails", async () => {
// Create paseo.json with failing setup command
const paseoConfig = {
+81 -14
View File
@@ -4,7 +4,13 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createNameId } from "mnemonic-id";
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
import {
normalizeBaseRefName,
readPaseoWorktreeMetadata,
readPaseoWorktreeRuntimePort,
writePaseoWorktreeMetadata,
writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
interface PaseoConfig {
@@ -26,6 +32,14 @@ export interface WorktreeConfig {
worktreePath: string;
}
export type WorktreeRuntimeEnv = {
PASEO_SOURCE_CHECKOUT_PATH: string;
PASEO_ROOT_PATH: string;
PASEO_WORKTREE_PATH: string;
PASEO_BRANCH_NAME: string;
PASEO_WORKTREE_PORT: string;
};
export type WorktreeSetupCommandResult = {
command: string;
cwd: string;
@@ -327,6 +341,29 @@ async function getAvailablePort(): Promise<number> {
});
}
async function assertPortAvailable(port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const server = net.createServer();
server.once("error", (error: NodeJS.ErrnoException) => {
const message = error?.code === "EADDRINUSE"
? `Persisted worktree port ${port} is already in use`
: error instanceof Error
? error.message
: String(error);
reject(new Error(message));
});
server.listen(port, () => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
}
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
try {
const commonDir = await getGitCommonDir(worktreePath);
@@ -360,6 +397,7 @@ export async function runWorktreeSetupCommands(options: {
branchName: string;
cleanupOnFailure: boolean;
repoRootPath?: string;
runtimeEnv?: WorktreeRuntimeEnv;
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
}): Promise<WorktreeSetupCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
@@ -368,21 +406,16 @@ export async function runWorktreeSetupCommands(options: {
return [];
}
const repoRootPath =
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const worktreePort = await getAvailablePort();
const runtimeEnv =
options.runtimeEnv ??
(await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
branchName: options.branchName,
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
}));
const setupEnv = {
...process.env,
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
// source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: options.branchName,
PASEO_WORKTREE_PORT: String(worktreePort),
...runtimeEnv,
};
const results: WorktreeSetupCommandResult[] = [];
@@ -439,6 +472,40 @@ async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<s
return basename(worktreePath);
}
export async function resolveWorktreeRuntimeEnv(options: {
worktreePath: string;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeRuntimeEnv> {
const repoRootPath =
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
if (worktreePort === null) {
worktreePort = await getAvailablePort();
const metadata = readPaseoWorktreeMetadata(options.worktreePath);
if (metadata) {
writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
}
} else {
await assertPortAvailable(worktreePort);
}
return {
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
// source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName,
PASEO_WORKTREE_PORT: String(worktreePort),
};
}
export async function runWorktreeDestroyCommands(options: {
worktreePath: string;
branchName?: string;