mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge main
This commit is contained in:
@@ -259,6 +259,7 @@ export default function HomeScreen() {
|
||||
retry: false,
|
||||
});
|
||||
const repoInfo = repoInfoQuery.data ?? null;
|
||||
const refetchRepoInfo = repoInfoQuery.refetch;
|
||||
const repoRequestError = repoInfoQuery.error as Error | null;
|
||||
const repoRequestStatus: "idle" | "loading" | "success" | "error" =
|
||||
!shouldInspectRepo || repoAvailabilityError
|
||||
@@ -358,8 +359,11 @@ export default function HomeScreen() {
|
||||
if (mode !== "attach") {
|
||||
setSelectedWorktreePath("");
|
||||
}
|
||||
if (mode !== "none") {
|
||||
refetchRepoInfo();
|
||||
}
|
||||
},
|
||||
[worktreeSlug]
|
||||
[worktreeSlug, refetchRepoInfo]
|
||||
);
|
||||
|
||||
const validateWorktreeName = useCallback(
|
||||
|
||||
@@ -235,15 +235,15 @@ type EnvDaemonConfig = {
|
||||
};
|
||||
|
||||
function parseEnvDaemonDefaults(): HostProfile[] {
|
||||
const envDaemons = (() => {
|
||||
const envDaemons = ((): EnvDaemonConfig[] => {
|
||||
// Primary: allow a JSON array string like
|
||||
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","endpoint":"10.0.0.1:6767"}]'
|
||||
const jsonList = process.env.EXPO_PUBLIC_DAEMONS;
|
||||
if (jsonList) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonList) as EnvDaemonConfig[];
|
||||
const parsed = JSON.parse(jsonList) as unknown;
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed;
|
||||
return parsed as EnvDaemonConfig[];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[DaemonRegistry] Failed to parse EXPO_PUBLIC_DAEMONS:", error);
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import type { HighlightedDiffResponse } from "@server/shared/messages";
|
||||
import { getNowMs, isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
|
||||
const HIGHLIGHTED_DIFF_STALE_TIME = 30_000;
|
||||
const HIGHLIGHTED_DIFF_LOG_TAG = "[HighlightedDiff]";
|
||||
|
||||
function highlightedDiffQueryKey(serverId: string, agentId: string) {
|
||||
return ["highlightedDiff", serverId, agentId] as const;
|
||||
}
|
||||
|
||||
interface UseHighlightedDiffQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export type ParsedDiffFile = HighlightedDiffResponse["payload"]["files"][number];
|
||||
export type DiffHunk = ParsedDiffFile["hunks"][number];
|
||||
export type DiffLine = DiffHunk["lines"][number];
|
||||
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
|
||||
export function useHighlightedDiffQuery({ serverId, agentId }: UseHighlightedDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: highlightedDiffQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const shouldLog = isPerfLoggingEnabled();
|
||||
const startMs = shouldLog ? getNowMs() : 0;
|
||||
const response = await client.getHighlightedDiff(agentId);
|
||||
if (shouldLog) {
|
||||
let hunkCount = 0;
|
||||
let lineCount = 0;
|
||||
let tokenCount = 0;
|
||||
for (const file of response.files) {
|
||||
hunkCount += file.hunks.length;
|
||||
for (const hunk of file.hunks) {
|
||||
lineCount += hunk.lines.length;
|
||||
for (const line of hunk.lines) {
|
||||
if (line.tokens) {
|
||||
tokenCount += line.tokens.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const durationMs = getNowMs() - startMs;
|
||||
const metrics = measurePayload(response);
|
||||
perfLog(HIGHLIGHTED_DIFF_LOG_TAG, {
|
||||
event: "fetch",
|
||||
serverId,
|
||||
agentId,
|
||||
durationMs: Math.round(durationMs),
|
||||
fileCount: response.files.length,
|
||||
hunkCount,
|
||||
lineCount,
|
||||
tokenCount,
|
||||
payloadApproxBytes: metrics.approxBytes,
|
||||
payloadFieldCount: metrics.fieldCount,
|
||||
});
|
||||
}
|
||||
return response.files;
|
||||
},
|
||||
enabled: !!client && isConnected && !!agentId,
|
||||
staleTime: HIGHLIGHTED_DIFF_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
// Revalidate when sidebar opens with "changes" tab active
|
||||
useEffect(() => {
|
||||
if (!isOpen || explorerTab !== "changes" || !agentId) {
|
||||
return;
|
||||
}
|
||||
// Invalidate to trigger background refetch (shows stale data while fetching)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: highlightedDiffQueryKey(serverId, agentId),
|
||||
});
|
||||
}, [isOpen, explorerTab, serverId, agentId, queryClient]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
return {
|
||||
files: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -1161,6 +1161,7 @@ const TOOL_NAME_MAP: Record<string, string> = {
|
||||
Bash: "Shell",
|
||||
read_file: "Read",
|
||||
apply_patch: "Edit",
|
||||
paseo_worktree_setup: "Setup",
|
||||
"agent-control.set_title": "Set title",
|
||||
"agent-control.set_branch": "Set branch",
|
||||
set_title: "Set title",
|
||||
|
||||
@@ -961,7 +961,7 @@ export class DaemonClientV2 {
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
20000,
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
@@ -990,7 +990,7 @@ export class DaemonClientV2 {
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
20000,
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
@@ -1020,7 +1020,7 @@ export class DaemonClientV2 {
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
20000,
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
@@ -1051,7 +1051,7 @@ export class DaemonClientV2 {
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
20000,
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
@@ -1082,7 +1082,7 @@ export class DaemonClientV2 {
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
30000,
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
@@ -1109,7 +1109,7 @@ export class DaemonClientV2 {
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
30000,
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
@@ -1137,7 +1137,7 @@ export class DaemonClientV2 {
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
20000,
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
|
||||
@@ -469,6 +469,18 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.updatedAt = new Date();
|
||||
this.recordTimeline(agent, item);
|
||||
this.dispatchStream(agentId, {
|
||||
type: "timeline",
|
||||
item,
|
||||
provider: agent.provider,
|
||||
});
|
||||
await this.persistSnapshot(agent);
|
||||
}
|
||||
|
||||
streamAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
|
||||
@@ -788,29 +788,57 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
try {
|
||||
session = await client.createSession(config);
|
||||
|
||||
const prompt = [
|
||||
"IMPORTANT: You must execute every step using the exact tool mentioned.",
|
||||
"Do not skip steps, and do not claim completion without running the tools.",
|
||||
"1. Run the command `printf 'stdout-marker'` using your shell tool.",
|
||||
"2. Run the command `printf 'stderr-marker' 1>&2` using your shell tool.",
|
||||
"3. Use apply_patch to create a new file named tool-create.txt containing only the line 'alpha'.",
|
||||
"4. Use apply_patch to edit tool-create.txt, replacing 'alpha' with 'beta'.",
|
||||
"5. Read the file tool-create.txt using read_file tool.",
|
||||
"6. Call the MCP tool test.echo with input {\"text\":\"mcp-ok\"}.",
|
||||
"7. Reply DONE and stop.",
|
||||
].join("\n");
|
||||
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (event.type === "timeline" && providerFromEvent(event) === "codex") {
|
||||
if (event.item.type === "tool_call") {
|
||||
toolCalls.push(event.item);
|
||||
async function runStep(prompt: string): Promise<void> {
|
||||
for await (const event of session!.stream(prompt)) {
|
||||
if (event.type === "timeline" && providerFromEvent(event) === "codex") {
|
||||
if (event.item.type === "tool_call") {
|
||||
toolCalls.push(event.item);
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await runStep(
|
||||
[
|
||||
"Use your shell tool to run the exact command: `printf 'stdout-marker'`.",
|
||||
"Do not run any other commands. Reply DONE.",
|
||||
].join(" ")
|
||||
);
|
||||
await runStep(
|
||||
[
|
||||
"Use your shell tool to run the exact command: `printf 'stderr-marker' 1>&2`.",
|
||||
"Do not run any other commands. Reply DONE.",
|
||||
].join(" ")
|
||||
);
|
||||
await runStep(
|
||||
[
|
||||
"Use apply_patch to create tool-create.txt with exactly this content:",
|
||||
"alpha",
|
||||
"Reply DONE.",
|
||||
].join("\n")
|
||||
);
|
||||
await runStep(
|
||||
[
|
||||
"Use apply_patch to edit tool-create.txt, replacing 'alpha' with 'beta'.",
|
||||
"Reply DONE.",
|
||||
].join(" ")
|
||||
);
|
||||
await runStep(
|
||||
[
|
||||
"Read tool-create.txt using the read_file tool.",
|
||||
"Reply DONE.",
|
||||
].join(" ")
|
||||
);
|
||||
await runStep(
|
||||
[
|
||||
"Call the MCP tool test.echo with input exactly: {\"text\":\"mcp-ok\"}.",
|
||||
"Reply DONE.",
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
const commandCalls = toolCalls.filter(
|
||||
(item) => item.name === "shell" && item.status === "completed"
|
||||
);
|
||||
@@ -853,16 +881,30 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
const readCall = toolCalls.find(
|
||||
(item) => item.name === "read_file" && item.status === "completed"
|
||||
);
|
||||
expect.soft(readCall).toBeTruthy();
|
||||
expect.soft(stringifyUnknown(readCall?.input)).toContain("tool-create.txt");
|
||||
expect.soft(stringifyUnknown(readCall?.output)).toContain("beta");
|
||||
const shellReadCall = toolCalls.find((item) => {
|
||||
if (item.name !== "shell" || item.status !== "completed") {
|
||||
return false;
|
||||
}
|
||||
const input = stringifyUnknown(item.input);
|
||||
const output = commandOutputText(item.output) ?? "";
|
||||
return input.includes("tool-create.txt") && output.includes("beta");
|
||||
});
|
||||
expect.soft(readCall ?? shellReadCall).toBeTruthy();
|
||||
if (readCall) {
|
||||
expect.soft(stringifyUnknown(readCall.input)).toContain("tool-create.txt");
|
||||
expect.soft(stringifyUnknown(readCall.output)).toContain("beta");
|
||||
}
|
||||
|
||||
// MCP tool calls can be flaky depending on provider behavior; MCP mapping is
|
||||
// covered more directly in other tests in this suite. If we do see the tool
|
||||
// call here, assert we captured input/output.
|
||||
const mcpCall = toolCalls.find(
|
||||
(item) => item.name === "test.echo"
|
||||
(item) => item.name === "test.echo" || item.name.startsWith("test.echo")
|
||||
);
|
||||
expect.soft(mcpCall).toBeTruthy();
|
||||
expect.soft(stringifyUnknown(mcpCall?.input)).toContain("mcp-ok");
|
||||
expect.soft(stringifyUnknown(mcpCall?.output)).toContain("mcp-ok");
|
||||
if (mcpCall) {
|
||||
expect.soft(stringifyUnknown(mcpCall.input)).toContain("mcp-ok");
|
||||
expect.soft(stringifyUnknown(mcpCall.output)).toContain("mcp-ok");
|
||||
}
|
||||
|
||||
const callIdStatuses = new Map<string, Set<string>>();
|
||||
for (const toolCall of toolCalls) {
|
||||
|
||||
@@ -13,6 +13,98 @@ function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
): Promise<T> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`Timed out after ${timeoutMs}ms (${label})`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findTimelineToolCall(
|
||||
messages: SessionOutboundMessage[],
|
||||
agentId: string,
|
||||
predicate: (item: AgentTimelineItem) => boolean
|
||||
): AgentTimelineItem | null {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const msg = messages[i];
|
||||
if (msg?.type !== "agent_stream") {
|
||||
continue;
|
||||
}
|
||||
if (msg.payload.agentId !== agentId) {
|
||||
continue;
|
||||
}
|
||||
const event = msg.payload.event as any;
|
||||
if (event?.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
const item = event.item as AgentTimelineItem;
|
||||
if (item?.type === "tool_call" && predicate(item)) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForTimelineToolCall(
|
||||
ctx: DaemonTestContext,
|
||||
agentId: string,
|
||||
predicate: (item: AgentTimelineItem) => boolean,
|
||||
timeoutMs = 10000
|
||||
): Promise<Extract<AgentTimelineItem, { type: "tool_call" }>> {
|
||||
const existing = findTimelineToolCall(
|
||||
ctx.client.getMessageQueue(),
|
||||
agentId,
|
||||
predicate
|
||||
);
|
||||
if (existing && existing.type === "tool_call") {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let unsub = () => {};
|
||||
const timeout = setTimeout(() => {
|
||||
unsub();
|
||||
reject(new Error(`Timed out waiting for timeline tool_call (${agentId})`));
|
||||
}, timeoutMs);
|
||||
|
||||
unsub = ctx.client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.agentId !== agentId) {
|
||||
return;
|
||||
}
|
||||
const event = message.payload.event as any;
|
||||
if (event?.type !== "timeline") {
|
||||
return;
|
||||
}
|
||||
const item = event.item as AgentTimelineItem;
|
||||
if (item?.type !== "tool_call") {
|
||||
return;
|
||||
}
|
||||
if (!predicate(item)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
resolve(item);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||
@@ -277,6 +369,178 @@ describe("daemon E2E", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("worktree setup", () => {
|
||||
test(
|
||||
"runs paseo.json setup asynchronously and reports status via timeline tool_call",
|
||||
async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
|
||||
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const setupCommand =
|
||||
'while [ ! -f "$PASEO_ROOT_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({ worktree: { setup: [setupCommand] } })
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add paseo.json'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const agent = await withTimeout(
|
||||
ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd: repoRoot,
|
||||
title: "Async Worktree Setup Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "async-setup-test",
|
||||
worktreeSlug: "async-setup-test",
|
||||
},
|
||||
}),
|
||||
2500,
|
||||
"createAgent should not block on setup"
|
||||
);
|
||||
|
||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
|
||||
|
||||
const started = await waitForTimelineToolCall(
|
||||
ctx,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_setup" && item.status === "running",
|
||||
10000
|
||||
);
|
||||
|
||||
expect(started.callId).toBeTruthy();
|
||||
|
||||
writeFileSync(path.join(repoRoot, "allow-setup"), "ok\n");
|
||||
|
||||
const completed = await waitForTimelineToolCall(
|
||||
ctx,
|
||||
agent.id,
|
||||
(item) =>
|
||||
item.name === "paseo_worktree_setup" &&
|
||||
item.callId === started.callId &&
|
||||
item.status === "completed",
|
||||
20000
|
||||
);
|
||||
|
||||
expect(completed.output).toBeTruthy();
|
||||
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true);
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
},
|
||||
60000
|
||||
);
|
||||
|
||||
test(
|
||||
"reports failures via timeline tool_call without deleting the created worktree",
|
||||
async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
|
||||
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const setupCommand =
|
||||
'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7';
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({ worktree: { setup: [setupCommand] } })
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const agent = await withTimeout(
|
||||
ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd: repoRoot,
|
||||
title: "Async Worktree Setup Failure Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "async-setup-failure-test",
|
||||
worktreeSlug: "async-setup-failure-test",
|
||||
},
|
||||
}),
|
||||
2500,
|
||||
"createAgent should not block on failing setup"
|
||||
);
|
||||
|
||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||
expect(existsSync(agent.cwd)).toBe(true);
|
||||
|
||||
const started = await waitForTimelineToolCall(
|
||||
ctx,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_setup" && item.status === "running",
|
||||
10000
|
||||
);
|
||||
|
||||
const failed = await waitForTimelineToolCall(
|
||||
ctx,
|
||||
agent.id,
|
||||
(item) =>
|
||||
item.name === "paseo_worktree_setup" &&
|
||||
item.callId === started.callId &&
|
||||
item.status === "failed",
|
||||
20000
|
||||
);
|
||||
|
||||
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
|
||||
|
||||
const output = failed.output as any;
|
||||
const commands = output?.commands as any[] | undefined;
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands?.[0]?.exitCode).toBe(7);
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
},
|
||||
60000
|
||||
);
|
||||
});
|
||||
|
||||
describe("createAgent with worktree", () => {
|
||||
test(
|
||||
"creates agent in .paseo/worktrees when worktree is requested",
|
||||
|
||||
@@ -211,20 +211,17 @@ describe("daemon E2E", () => {
|
||||
logToolCall("CODEX_SHELL", tc);
|
||||
}
|
||||
|
||||
const shellCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "shell");
|
||||
expect(shellCall).toBeDefined();
|
||||
expect(shellCall?.name).toBe("shell");
|
||||
expect(shellCall?.input).toBeDefined();
|
||||
// Command text should be in input.command (Codex may run setup commands like `pwd` first).
|
||||
const shellCalls = toolCalls.filter(
|
||||
(tc) => tc.type === "tool_call" && tc.name === "shell"
|
||||
);
|
||||
expect(shellCalls.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
shellCalls.some((tc) =>
|
||||
((tc.input as { command?: string } | undefined)?.command ?? "").includes("echo")
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const echoCall = shellCalls.find((tc) => {
|
||||
const shellInput = tc.input as { command?: string } | undefined;
|
||||
return typeof shellInput?.command === "string" &&
|
||||
shellInput.command.includes("echo");
|
||||
});
|
||||
expect(echoCall).toBeDefined();
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -13,7 +13,7 @@ function tmpCwd(): string {
|
||||
|
||||
/**
|
||||
* Tests for waitForAgentIdle edge cases.
|
||||
* Uses haiku for speed. 10s timeout per operation - if slower, it's a bug.
|
||||
* Uses haiku for speed. Allow higher timeouts in CI / congested environments.
|
||||
*/
|
||||
describe("waitForAgentIdle edge cases", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
@@ -24,7 +24,7 @@ describe("waitForAgentIdle edge cases", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 15000);
|
||||
}, 30000);
|
||||
|
||||
test("waitForAgentIdle immediately after sendMessage", async () => {
|
||||
const cwd = tmpCwd();
|
||||
@@ -39,13 +39,13 @@ describe("waitForAgentIdle edge cases", () => {
|
||||
|
||||
// This was the original bug: waitForAgentIdle returned old idle states
|
||||
await ctx.client.sendMessage(agent.id, "Say 'hello'");
|
||||
const state = await ctx.client.waitForAgentIdle(agent.id, 10000);
|
||||
const state = await ctx.client.waitForAgentIdle(agent.id, 30000);
|
||||
|
||||
expect(state.status).toBe("idle");
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
}, 45000);
|
||||
|
||||
test("rapid fire messages then single wait", async () => {
|
||||
const cwd = tmpCwd();
|
||||
@@ -64,7 +64,7 @@ describe("waitForAgentIdle edge cases", () => {
|
||||
await ctx.client.sendMessage(agent.id, "Say 'two'");
|
||||
await ctx.client.sendMessage(agent.id, "Say 'three'");
|
||||
|
||||
const state = await ctx.client.waitForAgentIdle(agent.id, 10000);
|
||||
const state = await ctx.client.waitForAgentIdle(agent.id, 30000);
|
||||
expect(state.status).toBe("idle");
|
||||
|
||||
// Verify all 3 messages were recorded
|
||||
@@ -80,7 +80,7 @@ describe("waitForAgentIdle edge cases", () => {
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
}, 45000);
|
||||
|
||||
test("two agents: waitForAgentIdle filters by agent", async () => {
|
||||
const cwd1 = tmpCwd();
|
||||
@@ -107,11 +107,11 @@ describe("waitForAgentIdle edge cases", () => {
|
||||
await ctx.client.sendMessage(agent2.id, "Say 'agent two'");
|
||||
|
||||
// Wait for each - should not be confused by the other's state
|
||||
const state2 = await ctx.client.waitForAgentIdle(agent2.id, 10000);
|
||||
const state2 = await ctx.client.waitForAgentIdle(agent2.id, 30000);
|
||||
expect(state2.status).toBe("idle");
|
||||
expect(state2.id).toBe(agent2.id);
|
||||
|
||||
const state1 = await ctx.client.waitForAgentIdle(agent1.id, 10000);
|
||||
const state1 = await ctx.client.waitForAgentIdle(agent1.id, 30000);
|
||||
expect(state1.status).toBe("idle");
|
||||
expect(state1.id).toBe(agent1.id);
|
||||
|
||||
@@ -119,5 +119,5 @@ describe("waitForAgentIdle edge cases", () => {
|
||||
await ctx.client.deleteAgent(agent2.id);
|
||||
rmSync(cwd1, { recursive: true, force: true });
|
||||
rmSync(cwd2, { recursive: true, force: true });
|
||||
}, 25000);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentProvider,
|
||||
AgentPersistenceHandle,
|
||||
AgentTimelineItem,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { AgentRegistry, type StoredAgentRecord } from "./agent/agent-registry.js";
|
||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||
@@ -71,6 +72,10 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import {
|
||||
createWorktree,
|
||||
runWorktreeSetupCommands,
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
listPaseoWorktrees,
|
||||
@@ -1307,7 +1312,7 @@ export class Session {
|
||||
throw statError;
|
||||
}
|
||||
|
||||
const sessionConfig = await this.buildAgentSessionConfig(
|
||||
const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig(
|
||||
config,
|
||||
git,
|
||||
worktreeName
|
||||
@@ -1356,6 +1361,10 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
if (worktreeConfig) {
|
||||
void this.runAsyncWorktreeSetup(snapshot.id, worktreeConfig);
|
||||
}
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId: snapshot.id, provider: snapshot.provider },
|
||||
`Created agent ${snapshot.id} (${snapshot.provider})`
|
||||
@@ -1534,14 +1543,17 @@ export class Session {
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string
|
||||
): Promise<AgentSessionConfig> {
|
||||
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
|
||||
let cwd = expandTilde(config.cwd);
|
||||
const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
...config,
|
||||
cwd,
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1572,15 +1584,17 @@ export class Session {
|
||||
}' for branch ${targetBranch}`
|
||||
);
|
||||
|
||||
const worktreeConfig = await createWorktree({
|
||||
const createdWorktree = await createWorktree({
|
||||
branchName: targetBranch,
|
||||
cwd,
|
||||
baseBranch: normalized.createNewBranch
|
||||
? normalized.baseBranch
|
||||
: undefined,
|
||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||
runSetup: false,
|
||||
});
|
||||
cwd = worktreeConfig.worktreePath;
|
||||
cwd = createdWorktree.worktreePath;
|
||||
worktreeConfig = createdWorktree;
|
||||
} else if (normalized.createNewBranch) {
|
||||
await this.createBranchFromBase({
|
||||
cwd,
|
||||
@@ -1592,11 +1606,98 @@ export class Session {
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
cwd,
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
worktreeConfig,
|
||||
};
|
||||
}
|
||||
|
||||
private async runAsyncWorktreeSetup(
|
||||
agentId: string,
|
||||
worktree: WorktreeConfig
|
||||
): Promise<void> {
|
||||
const callId = uuidv4();
|
||||
let results: WorktreeSetupCommandResult[] = [];
|
||||
try {
|
||||
const started = await this.safeAppendTimelineItem(agentId, {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId,
|
||||
status: "running",
|
||||
input: {
|
||||
repoRoot: worktree.repoPath,
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
},
|
||||
});
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
results = await runWorktreeSetupCommands({
|
||||
repoRoot: worktree.repoPath,
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
});
|
||||
|
||||
await this.safeAppendTimelineItem(agentId, {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId,
|
||||
status: "completed",
|
||||
output: {
|
||||
worktreePath: worktree.worktreePath,
|
||||
commands: results.map((result) => ({
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
exitCode: result.exitCode,
|
||||
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
results = error.results;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await this.safeAppendTimelineItem(agentId, {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId,
|
||||
status: "failed",
|
||||
output: {
|
||||
worktreePath: worktree.worktreePath,
|
||||
commands: results.map((result) => ({
|
||||
command: result.command,
|
||||
cwd: result.cwd,
|
||||
exitCode: result.exitCode,
|
||||
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
||||
})),
|
||||
},
|
||||
error: { message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async safeAppendTimelineItem(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.agentManager.appendTimelineItem(agentId, item);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Unknown agent")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGitRepoInfoRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "git_repo_info_request" }>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { exec, execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { resolve } from "path";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { detectRepoInfo } from "./worktree.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -325,7 +325,7 @@ export async function getCheckoutDiff(
|
||||
}
|
||||
|
||||
if (compare.includeStructured) {
|
||||
return { diff, structured: parseDiff(diff) };
|
||||
return { diff, structured: await parseAndHighlightDiff(diff, cwd) };
|
||||
}
|
||||
return { diff };
|
||||
}
|
||||
|
||||
@@ -142,6 +142,29 @@ describe("createWorktree", () => {
|
||||
expect(setupLog).toContain("branch=setup-test");
|
||||
});
|
||||
|
||||
it("does not run setup commands when runSetup=false", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
setup: ['echo "setup ran" > setup.log'],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'",
|
||||
{ cwd: repoDir }
|
||||
);
|
||||
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "no-setup-test",
|
||||
runSetup: false,
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up worktree if setup command fails", async () => {
|
||||
// Create paseo.json with failing setup command
|
||||
const paseoConfig = {
|
||||
|
||||
@@ -22,13 +22,31 @@ interface RepoInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface WorktreeConfig {
|
||||
export interface WorktreeConfig {
|
||||
branchName: string;
|
||||
worktreePath: string;
|
||||
repoType: "bare" | "normal";
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
export type WorktreeSetupCommandResult = {
|
||||
command: string;
|
||||
cwd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
};
|
||||
|
||||
export class WorktreeSetupError extends Error {
|
||||
readonly results: WorktreeSetupCommandResult[];
|
||||
|
||||
constructor(message: string, results: WorktreeSetupCommandResult[]) {
|
||||
super(message);
|
||||
this.name = "WorktreeSetupError";
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaseoWorktreeInfo {
|
||||
path: string;
|
||||
branchName?: string;
|
||||
@@ -47,6 +65,104 @@ interface CreateWorktreeOptions {
|
||||
cwd: string;
|
||||
baseBranch?: string;
|
||||
worktreeSlug?: string;
|
||||
runSetup?: boolean;
|
||||
}
|
||||
|
||||
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
|
||||
const paseoConfigPath = join(repoRoot, "paseo.json");
|
||||
if (!existsSync(paseoConfigPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(paseoConfigPath, "utf8"));
|
||||
} catch {
|
||||
throw new Error(`Failed to parse paseo.json`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
const setupCommands = config?.worktree?.setup;
|
||||
if (!setupCommands || setupCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
}
|
||||
|
||||
async function execSetupCommand(
|
||||
command: string,
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv }
|
||||
): Promise<WorktreeSetupCommandResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
shell: "/bin/bash",
|
||||
});
|
||||
return {
|
||||
command,
|
||||
cwd: options.cwd,
|
||||
stdout: stdout ?? "",
|
||||
stderr: stderr ?? "",
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
command,
|
||||
cwd: options.cwd,
|
||||
stdout: error?.stdout ?? "",
|
||||
stderr:
|
||||
error?.stderr ??
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
exitCode: typeof error?.code === "number" ? error.code : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWorktreeSetupCommands(options: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
cleanupOnFailure: boolean;
|
||||
}): Promise<WorktreeSetupCommandResult[]> {
|
||||
const setupCommands = getWorktreeSetupCommands(options.repoRoot);
|
||||
if (setupCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const setupEnv = {
|
||||
...process.env,
|
||||
PASEO_ROOT_PATH: options.repoRoot,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: options.branchName,
|
||||
};
|
||||
|
||||
const results: WorktreeSetupCommandResult[] = [];
|
||||
for (const cmd of setupCommands) {
|
||||
const result = await execSetupCommand(cmd, {
|
||||
cwd: options.worktreePath,
|
||||
env: setupEnv,
|
||||
});
|
||||
results.push(result);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
if (options.cleanupOnFailure) {
|
||||
try {
|
||||
await execAsync(`git worktree remove "${options.worktreePath}" --force`, {
|
||||
cwd: options.repoRoot,
|
||||
});
|
||||
} catch {
|
||||
rmSync(options.worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
throw new WorktreeSetupError(
|
||||
`Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(),
|
||||
results
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,6 +480,7 @@ export async function createWorktree({
|
||||
cwd,
|
||||
baseBranch,
|
||||
worktreeSlug,
|
||||
runSetup = true,
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> {
|
||||
// Validate branch name
|
||||
const validation = validateBranchSlug(branchName);
|
||||
@@ -431,48 +548,13 @@ export async function createWorktree({
|
||||
await execAsync(command, { cwd: repoInfo.path });
|
||||
worktreePath = finalWorktreePath;
|
||||
|
||||
// Run setup commands from paseo.json if present (look in source worktree, not bare repo)
|
||||
const paseoConfigPath = join(repoInfo.path, "paseo.json");
|
||||
if (existsSync(paseoConfigPath)) {
|
||||
let config: PaseoConfig;
|
||||
try {
|
||||
config = JSON.parse(readFileSync(paseoConfigPath, "utf8"));
|
||||
} catch {
|
||||
throw new Error(`Failed to parse paseo.json`);
|
||||
}
|
||||
|
||||
const setupCommands = config.worktree?.setup;
|
||||
if (setupCommands && setupCommands.length > 0) {
|
||||
const setupEnv = {
|
||||
...process.env,
|
||||
PASEO_ROOT_PATH: repoInfo.path,
|
||||
PASEO_WORKTREE_PATH: worktreePath,
|
||||
PASEO_BRANCH_NAME: newBranchName,
|
||||
};
|
||||
|
||||
for (const cmd of setupCommands) {
|
||||
try {
|
||||
await execAsync(cmd, {
|
||||
cwd: worktreePath,
|
||||
env: setupEnv,
|
||||
shell: "/bin/bash",
|
||||
});
|
||||
} catch (error) {
|
||||
// Cleanup worktree on setup failure
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: repoInfo.path,
|
||||
});
|
||||
} catch {
|
||||
// If git worktree remove fails, try rmSync
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
throw new Error(
|
||||
`Worktree setup command failed: ${cmd}\n${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
repoRoot: repoInfo.path,
|
||||
worktreePath,
|
||||
branchName: newBranchName,
|
||||
cleanupOnFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user