From 85297d232333f762627b8209eb099233fad4ee1a Mon Sep 17 00:00:00 2001 From: Paul Bouzian Date: Tue, 27 Jan 2026 17:30:34 +0100 Subject: [PATCH] feat: add archive script support for worktrees Add the ability to configure commands that run when a workspace is archived, mirroring the existing setup script functionality. Changes: - Add archive-worktree, archive-worktree-unix, archive-worktree-windows config keys - Add getArchiveCommands() and executeWorktreeArchive() functions - Integrate archive script execution in chats.archive mutation - Add Archive Commands section to project worktree settings UI - Update header to "Worktree Scripts" to reflect both setup and archive The archive script runs whenever a workspace with a worktree is archived, before the worktree is deleted (if deletion is requested). This allows users to run cleanup tasks like git fetch --prune, branch deletion, etc. Co-Authored-By: Claude Opus 4.5 --- src/main/lib/git/worktree-config.ts | 111 ++++++++ src/main/lib/trpc/routers/chats.ts | 62 +++-- src/main/lib/trpc/routers/worktree-config.ts | 3 + .../agents-project-worktree-tab.tsx | 258 +++++++++++++++++- 4 files changed, 405 insertions(+), 29 deletions(-) diff --git a/src/main/lib/git/worktree-config.ts b/src/main/lib/git/worktree-config.ts index 30529a26..a51385f0 100644 --- a/src/main/lib/git/worktree-config.ts +++ b/src/main/lib/git/worktree-config.ts @@ -9,6 +9,9 @@ export interface WorktreeConfig { "setup-worktree-unix"?: string[] | string "setup-worktree-windows"?: string[] | string "setup-worktree"?: string[] | string + "archive-worktree-unix"?: string[] | string + "archive-worktree-windows"?: string[] | string + "archive-worktree"?: string[] | string } export type WorktreeConfigSource = "custom" | "cursor" | "1code" | null @@ -161,6 +164,24 @@ export function getSetupCommands(config: WorktreeConfig): string[] | string | nu return config["setup-worktree-unix"] ?? null } +/** + * Get archive commands for current platform + */ +export function getArchiveCommands(config: WorktreeConfig): string[] | string | null { + // Generic archive-worktree takes priority (cross-platform) + if (config["archive-worktree"]) { + return config["archive-worktree"] + } + + // Fall back to platform-specific commands + if (process.platform === "win32") { + return config["archive-worktree-windows"] ?? null + } + + // Unix (darwin, linux) + return config["archive-worktree-unix"] ?? null +} + export interface WorktreeSetupResult { success: boolean commandsRun: number @@ -249,3 +270,93 @@ export async function executeWorktreeSetup( return result } + +export interface WorktreeArchiveResult { + success: boolean + commandsRun: number + output: string[] + errors: string[] +} + +/** + * Execute worktree archive commands + * Runs when a workspace is archived (before worktree deletion if applicable) + * Useful for cleanup tasks like pruning branches, syncing, etc. + */ +export async function executeWorktreeArchive( + worktreePath: string, + mainRepoPath: string, +): Promise { + const result: WorktreeArchiveResult = { + success: true, + commandsRun: 0, + output: [], + errors: [], + } + + // Detect config from main repo + const detected = await detectWorktreeConfig(mainRepoPath) + if (!detected.config) { + result.output.push("No worktree config found, skipping archive commands") + return result + } + + // Get commands for current platform + const commands = getArchiveCommands(detected.config) + if (!commands) { + result.output.push("No archive commands for current platform") + return result + } + + // Normalize to array + const commandList = Array.isArray(commands) ? commands : [commands] + if (commandList.length === 0) { + result.output.push("Empty archive command list") + return result + } + + console.log(`[worktree-archive] Running ${commandList.length} archive commands in ${worktreePath}`) + + // Execute each command + for (const cmd of commandList) { + if (!cmd.trim()) continue + + try { + result.output.push(`$ ${cmd}`) + + const { stdout, stderr } = await execAsync(cmd, { + cwd: worktreePath, + env: { + ...process.env, + ROOT_WORKTREE_PATH: mainRepoPath, + }, + timeout: 300_000, // 5 minutes per command + }) + + if (stdout) { + result.output.push(stdout.trim()) + } + if (stderr) { + result.output.push(`[stderr] ${stderr.trim()}`) + } + + result.commandsRun++ + console.log(`[worktree-archive] ✓ ${cmd}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + result.errors.push(`Command failed: ${cmd}\n${errorMsg}`) + result.output.push(`[error] ${errorMsg}`) + console.error(`[worktree-archive] ✗ ${cmd}: ${errorMsg}`) + // Continue with next command, don't fail entirely + } + } + + result.success = result.errors.length === 0 + + console.log( + `[worktree-archive] Completed: ${result.commandsRun}/${commandList.length} commands, ` + + `${result.errors.length} errors` + ) + + return result +} diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index 99af10b4..e1e2d574 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -22,6 +22,7 @@ import { computeContentHash, gitCache } from "../../git/cache" import { splitUnifiedDiffByFile } from "../../git/diff-parser" import { execWithShellEnv } from "../../git/shell-env" import { applyRollbackStash } from "../../git/stash" +import { executeWorktreeArchive } from "../../git/worktree-config" import { checkInternetConnection, checkOllamaStatus } from "../../ollama" import { terminalManager } from "../../terminal/manager" import { publicProcedure, router } from "../index" @@ -468,8 +469,8 @@ export const chatsRouter = router({ console.error(`[chats.archive] Error killing processes:`, error) }) - // Optionally delete worktree in background (don't await) - if (input.deleteWorktree && chat?.worktreePath && chat?.branch) { + // Run archive script if workspace has a worktree (in background) + if (chat?.worktreePath && chat?.branch) { const project = db .select() .from(projects) @@ -477,24 +478,45 @@ export const chatsRouter = router({ .get() if (project) { - removeWorktree(project.path, chat.worktreePath).then((worktreeResult) => { - if (worktreeResult.success) { - console.log( - `[chats.archive] Deleted worktree for workspace ${input.id}`, - ) - // Clear worktreePath since it's deleted (keep branch for reference) - db.update(chats) - .set({ worktreePath: null }) - .where(eq(chats.id, input.id)) - .run() - } else { - console.warn( - `[chats.archive] Failed to delete worktree: ${worktreeResult.error}`, - ) - } - }).catch((error) => { - console.error(`[chats.archive] Error removing worktree:`, error) - }) + // Execute archive script first, then optionally delete worktree + executeWorktreeArchive(chat.worktreePath, project.path) + .then((archiveResult) => { + if (archiveResult.commandsRun > 0) { + console.log( + `[chats.archive] Ran ${archiveResult.commandsRun} archive command(s) for workspace ${input.id}`, + ) + } + if (archiveResult.errors.length > 0) { + console.warn( + `[chats.archive] Archive script had ${archiveResult.errors.length} error(s)`, + ) + } + + // Now delete worktree if requested + if (input.deleteWorktree) { + return removeWorktree(project.path, chat.worktreePath!) + } + return null + }) + .then((worktreeResult) => { + if (worktreeResult?.success) { + console.log( + `[chats.archive] Deleted worktree for workspace ${input.id}`, + ) + // Clear worktreePath since it's deleted (keep branch for reference) + db.update(chats) + .set({ worktreePath: null }) + .where(eq(chats.id, input.id)) + .run() + } else if (worktreeResult && !worktreeResult.success) { + console.warn( + `[chats.archive] Failed to delete worktree: ${worktreeResult.error}`, + ) + } + }) + .catch((error) => { + console.error(`[chats.archive] Error in archive flow:`, error) + }) } } diff --git a/src/main/lib/trpc/routers/worktree-config.ts b/src/main/lib/trpc/routers/worktree-config.ts index bc6109ed..e71019d5 100644 --- a/src/main/lib/trpc/routers/worktree-config.ts +++ b/src/main/lib/trpc/routers/worktree-config.ts @@ -13,6 +13,9 @@ const WorktreeConfigSchema = z.object({ "setup-worktree-unix": z.union([z.array(z.string()), z.string()]).optional(), "setup-worktree-windows": z.union([z.array(z.string()), z.string()]).optional(), "setup-worktree": z.union([z.array(z.string()), z.string()]).optional(), + "archive-worktree-unix": z.union([z.array(z.string()), z.string()]).optional(), + "archive-worktree-windows": z.union([z.array(z.string()), z.string()]).optional(), + "archive-worktree": z.union([z.array(z.string()), z.string()]).optional(), }) export const worktreeConfigRouter = router({ diff --git a/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx index 302df6ea..08c2d654 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx @@ -114,13 +114,19 @@ export function AgentsProjectWorktreeTab({ const [showDeleteDialog, setShowDeleteDialog] = useState(false) - // Local state + // Local state - Setup commands const [saveTarget, setSaveTarget] = useState<"cursor" | "1code">("1code") const [commands, setCommands] = useState([""]) const [unixCommands, setUnixCommands] = useState([]) const [windowsCommands, setWindowsCommands] = useState([]) const [showPlatformSpecific, setShowPlatformSpecific] = useState(false) + // Local state - Archive commands + const [archiveCommands, setArchiveCommands] = useState([""]) + const [archiveUnixCommands, setArchiveUnixCommands] = useState([]) + const [archiveWindowsCommands, setArchiveWindowsCommands] = useState([]) + const [showArchivePlatformSpecific, setShowArchivePlatformSpecific] = useState(false) + // Sync from server data useEffect(() => { if (configData) { @@ -131,7 +137,7 @@ export function AgentsProjectWorktreeTab({ } if (configData.config) { - // Generic commands + // Setup commands - Generic const generic = configData.config["setup-worktree"] setCommands( Array.isArray(generic) @@ -141,7 +147,7 @@ export function AgentsProjectWorktreeTab({ : [""], ) - // Platform-specific + // Setup commands - Platform-specific const unix = configData.config["setup-worktree-unix"] const win = configData.config["setup-worktree-windows"] @@ -156,10 +162,39 @@ export function AgentsProjectWorktreeTab({ if (unix || win) { setShowPlatformSpecific(true) } + + // Archive commands - Generic + const archiveGeneric = configData.config["archive-worktree"] + setArchiveCommands( + Array.isArray(archiveGeneric) + ? [...archiveGeneric, ""] + : archiveGeneric + ? [archiveGeneric, ""] + : [""], + ) + + // Archive commands - Platform-specific + const archiveUnix = configData.config["archive-worktree-unix"] + const archiveWin = configData.config["archive-worktree-windows"] + + setArchiveUnixCommands( + Array.isArray(archiveUnix) ? archiveUnix : archiveUnix ? [archiveUnix] : [], + ) + setArchiveWindowsCommands( + Array.isArray(archiveWin) ? archiveWin : archiveWin ? [archiveWin] : [], + ) + + // Show archive platform section if any platform-specific commands exist + if (archiveUnix || archiveWin) { + setShowArchivePlatformSpecific(true) + } } else { setCommands([""]) setUnixCommands([]) setWindowsCommands([]) + setArchiveCommands([""]) + setArchiveUnixCommands([]) + setArchiveWindowsCommands([]) } } }, [configData]) @@ -168,6 +203,8 @@ export function AgentsProjectWorktreeTab({ if (!projectId) return const config: Record = {} + + // Setup commands const filteredCommands = commands.filter((c) => c.trim()) const filteredUnix = unixCommands.filter((c) => c.trim()) const filteredWin = windowsCommands.filter((c) => c.trim()) @@ -182,6 +219,21 @@ export function AgentsProjectWorktreeTab({ config["setup-worktree-windows"] = filteredWin } + // Archive commands + const filteredArchiveCommands = archiveCommands.filter((c) => c.trim()) + const filteredArchiveUnix = archiveUnixCommands.filter((c) => c.trim()) + const filteredArchiveWin = archiveWindowsCommands.filter((c) => c.trim()) + + if (filteredArchiveCommands.length > 0) { + config["archive-worktree"] = filteredArchiveCommands + } + if (filteredArchiveUnix.length > 0) { + config["archive-worktree-unix"] = filteredArchiveUnix + } + if (filteredArchiveWin.length > 0) { + config["archive-worktree-windows"] = filteredArchiveWin + } + saveMutation.mutate({ projectId, config, @@ -221,9 +273,9 @@ export function AgentsProjectWorktreeTab({ {!isNarrowScreen && (
-

Worktree Setup

+

Worktree Scripts

- Configure setup commands that run when a new worktree is created + Configure commands that run when worktrees are created or archived

@@ -513,17 +565,205 @@ export function AgentsProjectWorktreeTab({ )}
-
+
+ + + {/* Archive Commands */} +
+
+
+

+ Archive Commands +

+

+ Commands run when a workspace is archived +

+
+
+ +
+
+
+ + + use $ROOT_WORKTREE_PATH for main repo path + +
+
+ {archiveCommands.map((cmd, i) => ( +
+ + updateCommand(i, e.target.value, archiveCommands, setArchiveCommands) + } + placeholder="git fetch --prune" + className="flex-1 font-mono text-sm" + /> + {archiveCommands.length > 1 && ( + + )} +
+ ))} +
+ + {/* Platform-specific toggle */} +
+ + + {showArchivePlatformSpecific && ( +
+ {/* Unix Commands */} +
+ + macOS / Linux + + {archiveUnixCommands.length === 0 ? ( +

+ Falls back to "All Platforms" +

+ ) : ( +
+ {archiveUnixCommands.map((cmd, i) => ( +
+ + updateCommand( + i, + e.target.value, + archiveUnixCommands, + setArchiveUnixCommands, + ) + } + placeholder="git fetch --prune" + className="flex-1 font-mono text-sm" + /> + +
+ ))} +
+ )} + +
+ + {/* Windows Commands */} +
+ + Windows + + {archiveWindowsCommands.length === 0 ? ( +

+ Falls back to "All Platforms" +

+ ) : ( +
+ {archiveWindowsCommands.map((cmd, i) => ( +
+ + updateCommand( + i, + e.target.value, + archiveWindowsCommands, + setArchiveWindowsCommands, + ) + } + placeholder="git fetch --prune" + className="flex-1 font-mono text-sm" + /> + +
+ ))} +
+ )} + +
+
+ )} +
+ + {/* Save Button */} +
+ +
) }