From 27aa7ded8fe3c88b434252d7c39842af17fb8d21 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 11:37:11 -0700 Subject: [PATCH 1/2] add verbosity to worktree creation path --- .../task-detail/workspaceSetupSaga.test.ts | 2 +- .../src/task-detail/workspaceSetupSaga.ts | 5 +- packages/git/src/queries.ts | 4 +- packages/git/src/worktree.ts | 256 ++++++++++++++---- .../components/WorkspaceSetupPrompt.tsx | 4 +- .../src/services/archive/archive.ts | 3 + .../src/services/folders/folders.ts | 2 + .../src/services/suspension/suspension.ts | 2 + .../src/services/workspace/workspace.ts | 13 +- .../worktree-checkpoint.ts | 3 + 10 files changed, 239 insertions(+), 55 deletions(-) diff --git a/packages/core/src/task-detail/workspaceSetupSaga.test.ts b/packages/core/src/task-detail/workspaceSetupSaga.test.ts index f2da401279..a13104f125 100644 --- a/packages/core/src/task-detail/workspaceSetupSaga.test.ts +++ b/packages/core/src/task-detail/workspaceSetupSaga.test.ts @@ -61,7 +61,7 @@ describe("WorkspaceSetupSaga.setupWorkspace", () => { expect(result).toEqual({ success: false, - error: "Failed to set up workspace", + error: "boom", }); expect(executor.ensureWorkspace).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/task-detail/workspaceSetupSaga.ts b/packages/core/src/task-detail/workspaceSetupSaga.ts index 8cf445368e..8ae70b1934 100644 --- a/packages/core/src/task-detail/workspaceSetupSaga.ts +++ b/packages/core/src/task-detail/workspaceSetupSaga.ts @@ -39,8 +39,9 @@ export class WorkspaceSetupSaga { this.log.info("Workspace setup complete", { taskId, path }); return { success: true }; } catch (error) { - this.log.error("Failed to set up workspace", { error }); - return { success: false, error: "Failed to set up workspace" }; + this.log.error("Failed to set up workspace", { taskId, path, error }); + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; } } } diff --git a/packages/git/src/queries.ts b/packages/git/src/queries.ts index 467092bc53..62eb39335e 100644 --- a/packages/git/src/queries.ts +++ b/packages/git/src/queries.ts @@ -1077,12 +1077,14 @@ export async function fetchRef( git: GitLike, remote: string, ref: string, + options?: { onError?: (message: string) => void }, ): Promise { try { // `--` keeps a ref beginning with `-` from being parsed as an option. await git.raw(["fetch", "--quiet", "--no-tags", remote, "--", ref]); return true; - } catch { + } catch (error) { + options?.onError?.(error instanceof Error ? error.message : String(error)); return false; } } diff --git a/packages/git/src/worktree.ts b/packages/git/src/worktree.ts index 3f0f161fac..3f24120abf 100644 --- a/packages/git/src/worktree.ts +++ b/packages/git/src/worktree.ts @@ -1,6 +1,7 @@ import { type ChildProcess, execFile, spawn } from "node:child_process"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import type { SagaLogger } from "@posthog/shared"; import { matchesExcludePatterns, parseExcludePatterns, @@ -30,8 +31,16 @@ export interface WorktreeInfo { export interface WorktreeConfig { mainRepoPath: string; worktreeBasePath?: string; + logger?: SagaLogger; } +const noopLogger: SagaLogger = { + info: () => {}, + debug: () => {}, + warn: () => {}, + error: () => {}, +}; + const WORKTREE_FOLDER_NAME = ".posthog-code"; const WORKTREE_ADD_TIMEOUT_MS = 120_000; @@ -67,11 +76,13 @@ export class WorktreeManager { private mainRepoPath: string; private worktreeBasePath: string | null; private repoName: string; + private log: SagaLogger; constructor(config: WorktreeConfig) { this.mainRepoPath = config.mainRepoPath; this.worktreeBasePath = config.worktreeBasePath ?? null; this.repoName = path.basename(config.mainRepoPath); + this.log = config.logger ?? noopLogger; } private usesExternalPath(): boolean { @@ -163,7 +174,11 @@ export class WorktreeManager { /** Base the worktree on `origin/` after fetching; falls back to the local ref if the fetch fails. */ fetchBeforeCreate?: boolean; }): Promise { - const manager = getGitOperationManager(); + this.log.info("createWorktree started", { + mainRepoPath: this.mainRepoPath, + baseBranch: options?.baseBranch ?? null, + fetchBeforeCreate: options?.fetchBeforeCreate ?? false, + }); const setupPromises: Promise[] = []; @@ -196,12 +211,16 @@ export class WorktreeManager { ? await this.resolveFreshBaseRef(baseBranch, options?.onOutput) : baseBranch; - options?.onOutput?.(`Creating worktree from ${baseRef}...\n`); - const output = await manager.executeWrite(this.mainRepoPath, async () => { - return this.spawnWorktreeAdd(["--detach", targetPath, baseRef], { - onOutput: options?.onOutput, - }); + this.log.info("Resolved worktree target", { + worktreeName, + worktreePath, + baseRef, }); + options?.onOutput?.(`Creating worktree from ${baseRef}...\n`); + const output = await this.runWorktreeAdd( + ["--detach", targetPath, baseRef], + options?.onOutput, + ); await this.finalizeWorktree(worktreePath, options?.onOutput); @@ -220,7 +239,11 @@ export class WorktreeManager { preferredName?: string, options?: { onOutput?: (data: string) => void }, ): Promise { - const manager = getGitOperationManager(); + this.log.info("createWorktreeForExistingBranch started", { + mainRepoPath: this.mainRepoPath, + branch, + preferredName: preferredName ?? null, + }); const exists = await branchExists(this.mainRepoPath, branch); if (!exists) { @@ -231,11 +254,15 @@ export class WorktreeManager { const { worktreePath, targetPath } = await this.prepareWorktreePath(worktreeName); - const output = await manager.executeWrite(this.mainRepoPath, async () => { - return this.spawnWorktreeAdd([targetPath, branch], { - onOutput: options?.onOutput, - }); + this.log.info("Resolved worktree target", { + worktreeName, + worktreePath, + branch, }); + const output = await this.runWorktreeAdd( + [targetPath, branch], + options?.onOutput, + ); await this.finalizeWorktree(worktreePath, options?.onOutput); @@ -259,10 +286,16 @@ export class WorktreeManager { preferredName?: string, options?: { onOutput?: (data: string) => void; remote?: string }, ): Promise { - const manager = getGitOperationManager(); const remote = options?.remote ?? "origin"; const remoteRef = `${remote}/${branch}`; + this.log.info("createWorktreeForRemoteBranch started", { + mainRepoPath: this.mainRepoPath, + branch, + remote, + preferredName: preferredName ?? null, + }); + options?.onOutput?.(`Fetching ${remoteRef}...\n`); const fetched = await this.fetchRefWithTimeout(remote, branch); if (!fetched) { @@ -288,13 +321,17 @@ export class WorktreeManager { const { worktreePath, targetPath } = await this.prepareWorktreePath(worktreeName); + this.log.info("Resolved worktree target", { + worktreeName, + worktreePath, + remoteRef, + }); // `-b ` creates a local branch at the fetched remote ref // and sets it up to track the remote branch. - const output = await manager.executeWrite(this.mainRepoPath, async () => { - return this.spawnWorktreeAdd(["-b", branch, targetPath, remoteRef], { - onOutput: options?.onOutput, - }); - }); + const output = await this.runWorktreeAdd( + ["-b", branch, targetPath, remoteRef], + options?.onOutput, + ); await this.finalizeWorktree(worktreePath, options?.onOutput); @@ -328,6 +365,12 @@ export class WorktreeManager { if (isRegistered || existsOnDisk) { worktreeName = `${this.generateWorktreeName()}${Date.now()}`; + this.log.warn("Preferred worktree name unavailable, generated new", { + preferredName, + isRegistered, + existsOnDisk, + worktreeName, + }); } } else if (await this.worktreeExists(worktreeName)) { worktreeName = `${this.generateWorktreeName()}${Date.now()}`; @@ -368,10 +411,33 @@ export class WorktreeManager { worktreePath: string, onOutput?: (data: string) => void, ): Promise { + this.log.info("Finalizing worktree", { worktreePath }); await this.symlinkClaudeConfig(worktreePath); - await processWorktreeLink(this.mainRepoPath, worktreePath, { onOutput }); - await processWorktreeInclude(this.mainRepoPath, worktreePath, { onOutput }); - await runPostCheckoutHook(this.mainRepoPath, worktreePath, { onOutput }); + const linkWarnings = await processWorktreeLink( + this.mainRepoPath, + worktreePath, + { onOutput }, + ); + const includeWarnings = await processWorktreeInclude( + this.mainRepoPath, + worktreePath, + { onOutput }, + ); + for (const warning of [...linkWarnings, ...includeWarnings]) { + this.log.warn("Worktree setup warning", { worktreePath, ...warning }); + } + const hookWarning = await runPostCheckoutHook( + this.mainRepoPath, + worktreePath, + { onOutput }, + ); + if (hookWarning) { + this.log.warn("post-checkout hook failed", { + worktreePath, + ...hookWarning, + }); + } + this.log.info("Worktree finalized", { worktreePath }); } async createDetachedWorktreeAtCommit( @@ -379,17 +445,25 @@ export class WorktreeManager { preferredName?: string, options?: { onOutput?: (data: string) => void }, ): Promise { - const manager = getGitOperationManager(); + this.log.info("createDetachedWorktreeAtCommit started", { + mainRepoPath: this.mainRepoPath, + commit, + preferredName: preferredName ?? null, + }); const worktreeName = await this.resolveAvailableWorktreeName(preferredName); const { worktreePath, targetPath } = await this.prepareWorktreePath(worktreeName); - const output = await manager.executeWrite(this.mainRepoPath, async () => { - return this.spawnWorktreeAdd(["--detach", targetPath, commit], { - onOutput: options?.onOutput, - }); + this.log.info("Resolved worktree target", { + worktreeName, + worktreePath, + commit, }); + const output = await this.runWorktreeAdd( + ["--detach", targetPath, commit], + options?.onOutput, + ); await this.finalizeWorktree(worktreePath, options?.onOutput); @@ -419,6 +493,10 @@ export class WorktreeManager { const fetched = await this.fetchRefWithTimeout(remote, baseBranch); if (!fetched) { + this.log.warn("Fetch failed, falling back to local ref", { + remoteRef, + baseBranch, + }); onOutput?.( `Fetch failed for ${remoteRef}, falling back to local ${baseBranch}.\n`, ); @@ -430,6 +508,10 @@ export class WorktreeManager { (git) => hasRef(git, remoteRef), ); if (!remoteRefExists) { + this.log.warn("Remote ref missing after fetch, falling back to local", { + remoteRef, + baseBranch, + }); onOutput?.( `Remote ref ${remoteRef} not found after fetch, falling back to local ${baseBranch}.\n`, ); @@ -455,11 +537,26 @@ export class WorktreeManager { ): Promise { const manager = getGitOperationManager(); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), GIT_FETCH_TIMEOUT_MS); + const timer = setTimeout(() => { + this.log.warn("git fetch timed out, aborting", { + remote, + ref, + timeoutMs: GIT_FETCH_TIMEOUT_MS, + }); + controller.abort(); + }, GIT_FETCH_TIMEOUT_MS); try { return await manager.executeWrite( this.mainRepoPath, - (git) => fetchRef(git, remote, ref), + (git) => + fetchRef(git, remote, ref, { + onError: (message) => + this.log.warn("git fetch failed", { + remote, + ref, + error: message, + }), + }), { signal: controller.signal }, ); } finally { @@ -467,21 +564,47 @@ export class WorktreeManager { } } + // The repo write lock has no timeout; log both sides of the wait so a create that queues forever is visible. + private async runWorktreeAdd( + args: string[], + onOutput?: (data: string) => void, + ): Promise { + const manager = getGitOperationManager(); + this.log.info("Waiting for repo write lock", { + mainRepoPath: this.mainRepoPath, + }); + onOutput?.("Waiting for other git operations to finish...\n"); + return manager.executeWrite(this.mainRepoPath, async () => { + this.log.info("Repo write lock acquired", { + mainRepoPath: this.mainRepoPath, + }); + return this.spawnWorktreeAdd(args, { onOutput }); + }); + } + private spawnWorktreeAdd( args: string[], options?: { onOutput?: (data: string) => void }, ): Promise { return new Promise((resolve, reject) => { const chunks: string[] = []; - const proc = spawn( - "git", - ["-c", "core.hooksPath=/dev/null", "worktree", "add", ...args], - { - cwd: this.mainRepoPath, - stdio: ["ignore", "pipe", "pipe"], - env: getCleanEnv(), - }, - ); + const argv = [ + "-c", + "core.hooksPath=/dev/null", + "worktree", + "add", + ...args, + ]; + this.log.info("Spawning git worktree add", { + cwd: this.mainRepoPath, + argv: argv.join(" "), + }); + const startedAt = Date.now(); + const proc = spawn("git", argv, { + cwd: this.mainRepoPath, + stdio: ["ignore", "pipe", "pipe"], + env: getCleanEnv(), + }); const handleData = (data: Buffer) => { const text = data.toString("utf-8"); @@ -496,11 +619,19 @@ export class WorktreeManager { proc.on("error", (err) => { timeout.clear(); + this.log.error("git worktree add failed to spawn", { + error: err.message, + }); reject(err); }); proc.on("close", (code) => { timeout.clear(); + const durationMs = Date.now() - startedAt; if (timeout.timedOut()) { + this.log.error("git worktree add timed out", { + durationMs, + output: chunks.join(""), + }); reject( new Error( `git worktree add timed out after ${WORKTREE_ADD_TIMEOUT_MS}ms`, @@ -509,6 +640,11 @@ export class WorktreeManager { return; } if (code !== 0) { + this.log.error("git worktree add failed", { + code, + durationMs, + output: chunks.join(""), + }); reject( new Error( `git worktree add exited with code ${code}: ${chunks.join("")}`, @@ -516,6 +652,7 @@ export class WorktreeManager { ); return; } + this.log.info("git worktree add succeeded", { durationMs }); resolve(chunks.join("")); }); }); @@ -576,7 +713,13 @@ export class WorktreeManager { }) // A null (rename couldn't happen) or a throw (prune failed, rename rolled // back) both fall through to the in-place remove below. - .catch(() => null); + .catch((error) => { + this.log.warn("Trash-based worktree delete failed, removing in place", { + worktreePath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + }); if (trashedPath) { void forceRemove(trashedPath).catch(() => {}); @@ -584,7 +727,14 @@ export class WorktreeManager { await manager.executeWrite(this.mainRepoPath, async (git) => { try { await git.raw(["worktree", "remove", worktreePath, "--force"]); - } catch { + } catch (removeError) { + this.log.warn("git worktree remove failed, force-removing", { + worktreePath, + error: + removeError instanceof Error + ? removeError.message + : String(removeError), + }); await forceRemove(worktreePath); await git.raw(["worktree", "prune"]); } @@ -657,7 +807,11 @@ export class WorktreeManager { baseBranch: "", createdAt: "", })); - } catch { + } catch (error) { + this.log.warn("git worktree list failed, returning empty list", { + mainRepoPath: this.mainRepoPath, + error: error instanceof Error ? error.message : String(error), + }); return []; } } @@ -991,25 +1145,31 @@ export async function runPostCheckoutHook( const timeout = armProcessTimeout(proc, POST_CHECKOUT_HOOK_TIMEOUT_MS); + const warn = (error: string): WorktreeSetupWarning => { + options?.onOutput?.(`Warning: ${error}\n`); + return { path: hookPath, error }; + }; + proc.on("error", (err) => { timeout.clear(); - resolve({ path: hookPath, error: err.message }); + resolve(warn(`post-checkout hook failed to spawn: ${err.message}`)); }); proc.on("close", (code) => { timeout.clear(); if (timeout.timedOut()) { - resolve({ - path: hookPath, - error: `post-checkout hook timed out after ${POST_CHECKOUT_HOOK_TIMEOUT_MS}ms`, - }); + resolve( + warn( + `post-checkout hook timed out after ${POST_CHECKOUT_HOOK_TIMEOUT_MS}ms`, + ), + ); return; } if (code !== 0) { - resolve({ - path: hookPath, - error: + resolve( + warn( `post-checkout hook exited with code ${code}: ${chunks.join("")}`.trim(), - }); + ), + ); return; } resolve(null); diff --git a/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx b/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx index 31901d482b..38052b08fc 100644 --- a/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx +++ b/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx @@ -50,7 +50,9 @@ export function WorkspaceSetupPrompt({ const result = await setupSaga.setupWorkspace(executor, taskId, path); if (!result.success) { - toast.error("Failed to set up workspace. Please try again."); + toast.error("Failed to set up workspace", { + description: result.error, + }); } setSelectedPath(""); diff --git a/packages/workspace-server/src/services/archive/archive.ts b/packages/workspace-server/src/services/archive/archive.ts index f9762befb0..e7ab427c62 100644 --- a/packages/workspace-server/src/services/archive/archive.ts +++ b/packages/workspace-server/src/services/archive/archive.ts @@ -287,6 +287,7 @@ export class ArchiveService { const manager = new WorktreeManager({ mainRepoPath: folderPath, worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + logger: this.log, }); await manager.deleteWorktree(worktreePath); const parentDir = path.dirname(worktreePath); @@ -434,6 +435,7 @@ export class ArchiveService { const manager = new WorktreeManager({ mainRepoPath: folderPath, worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + logger: this.log, }); const worktreePath = await this.deriveWorktreePath( folderPath, @@ -638,6 +640,7 @@ export class ArchiveService { branchName: archive.branchName, checkpointId: archive.checkpointId, recreateBranch, + logger: this.log, }); if (worktree) { diff --git a/packages/workspace-server/src/services/folders/folders.ts b/packages/workspace-server/src/services/folders/folders.ts index 18e80e3ed9..e8a07c2ee4 100644 --- a/packages/workspace-server/src/services/folders/folders.ts +++ b/packages/workspace-server/src/services/folders/folders.ts @@ -99,6 +99,7 @@ export class FoldersService { return new WorktreeManager({ mainRepoPath, worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + logger: this.log, }); } @@ -302,6 +303,7 @@ export class FoldersService { const manager = new WorktreeManager({ mainRepoPath: repo.path, worktreeBasePath, + logger: this.log, }); await manager.deleteWorktree(worktree.path); } catch (error) { diff --git a/packages/workspace-server/src/services/suspension/suspension.ts b/packages/workspace-server/src/services/suspension/suspension.ts index ca2028d8da..3b6f276d57 100644 --- a/packages/workspace-server/src/services/suspension/suspension.ts +++ b/packages/workspace-server/src/services/suspension/suspension.ts @@ -307,6 +307,7 @@ export class SuspensionService extends TypedEventEmitter const worktreeManager = new WorktreeManager({ mainRepoPath, worktreeBasePath, + logger: this.log, }); let worktree: WorktreeInfo; @@ -676,8 +677,13 @@ export class WorkspaceService extends TypedEventEmitter const selectedBranch = branch ?? defaultBranch; const isTrunkSelected = selectedBranch === defaultBranch; + // Renderer provisioning output is dropped when no view subscribes; mirror it into the main log. const onOutput = (data: string) => { this.provisioning.emitOutput(taskId, data); + const trimmed = data.trim(); + if (trimmed) { + this.log.info(`[worktree:${taskId}] ${trimmed}`); + } }; const existingWorktree = reuseExistingWorktree @@ -779,7 +785,8 @@ export class WorkspaceService extends TypedEventEmitter } } catch (error) { this.log.error(`Failed to create worktree for task ${taskId}:`, error); - throw new Error(`Failed to create worktree: ${String(error)}`); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to create worktree: ${message}`); } const createdWorkspace = this.workspaceRepo.create({ @@ -1284,6 +1291,7 @@ export class WorkspaceService extends TypedEventEmitter const worktreeManager = new WorktreeManager({ mainRepoPath, worktreeBasePath, + logger: this.log, }); let worktree: WorktreeInfo; @@ -1309,7 +1317,8 @@ export class WorkspaceService extends TypedEventEmitter `Failed to create worktree for promoted task ${taskId}:`, error, ); - throw new Error(`Failed to promote task to worktree: ${String(error)}`); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to promote task to worktree: ${message}`); } const workspace = this.workspaceRepo.findByTaskId(taskId); diff --git a/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.ts b/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.ts index 2e8034f5f6..4a1ce61e43 100644 --- a/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.ts +++ b/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.ts @@ -5,6 +5,7 @@ import { RevertCheckpointSaga, } from "@posthog/git/sagas/checkpoint"; import { type WorktreeInfo, WorktreeManager } from "@posthog/git/worktree"; +import type { SagaLogger } from "@posthog/shared"; export interface RestoreWorktreeFromCheckpointParams { mainRepoPath: string; @@ -14,6 +15,7 @@ export interface RestoreWorktreeFromCheckpointParams { branchName: string | null; checkpointId: string; recreateBranch?: boolean; + logger?: SagaLogger; } /** @@ -27,6 +29,7 @@ export async function restoreWorktreeFromCheckpoint( const manager = new WorktreeManager({ mainRepoPath: params.mainRepoPath, worktreeBasePath: params.worktreeBasePath, + logger: params.logger, }); let newWorktree: WorktreeInfo; From d8c0495df02afdc9d7c8ac213efbf2a00039b73d Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 11:51:50 -0700 Subject: [PATCH 2/2] Update packages/git/src/worktree.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- packages/git/src/worktree.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/git/src/worktree.ts b/packages/git/src/worktree.ts index 3f24120abf..4f2e164194 100644 --- a/packages/git/src/worktree.ts +++ b/packages/git/src/worktree.ts @@ -573,7 +573,6 @@ export class WorktreeManager { this.log.info("Waiting for repo write lock", { mainRepoPath: this.mainRepoPath, }); - onOutput?.("Waiting for other git operations to finish...\n"); return manager.executeWrite(this.mainRepoPath, async () => { this.log.info("Repo write lock acquired", { mainRepoPath: this.mainRepoPath,