diff --git a/packages/core/src/worktree/__tests__/worktree-include.spec.ts b/packages/core/src/worktree/__tests__/worktree-include.spec.ts index 7e9ce6557c3..f08bf360ecb 100644 --- a/packages/core/src/worktree/__tests__/worktree-include.spec.ts +++ b/packages/core/src/worktree/__tests__/worktree-include.spec.ts @@ -264,5 +264,45 @@ describe("WorktreeIncludeService", () => { expect(result).toContain("node_modules") }) + + it("should call progress callback with size-based progress", async () => { + // Set up files to copy + await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules\n.env.local") + await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules\n.env.local") + await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true }) + await fs.writeFile(path.join(sourceDir, "node_modules", "test.txt"), "test") + await fs.writeFile(path.join(sourceDir, ".env.local"), "LOCAL_VAR=value") + + const progressCalls: Array<{ bytesCopied: number; totalBytes: number; itemName: string }> = [] + const onProgress = vi.fn((progress: { bytesCopied: number; totalBytes: number; itemName: string }) => { + progressCalls.push({ ...progress }) + }) + + await service.copyWorktreeIncludeFiles(sourceDir, targetDir, onProgress) + + // Should be called multiple times (initial + after each copy + during polling) + expect(onProgress).toHaveBeenCalled() + + // All calls should have totalBytes > 0 (since we have files) + expect(progressCalls.every((p) => p.totalBytes > 0)).toBe(true) + + // Final call should have bytesCopied === totalBytes (complete) + const finalCall = progressCalls[progressCalls.length - 1] + expect(finalCall?.bytesCopied).toBe(finalCall?.totalBytes) + + // Each call should have an item name + expect(progressCalls.every((p) => typeof p.itemName === "string")).toBe(true) + }) + + it("should not fail when progress callback is not provided", async () => { + await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules") + await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules") + await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true }) + + // Should not throw when no callback is provided + const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir) + + expect(result).toContain("node_modules") + }) }) }) diff --git a/packages/core/src/worktree/index.ts b/packages/core/src/worktree/index.ts index 5daaeebcc44..ae07ef4aaaa 100644 --- a/packages/core/src/worktree/index.ts +++ b/packages/core/src/worktree/index.ts @@ -10,4 +10,4 @@ export * from "./types.js" // Services export { WorktreeService, worktreeService } from "./worktree-service.js" -export { WorktreeIncludeService, worktreeIncludeService } from "./worktree-include.js" +export { WorktreeIncludeService, worktreeIncludeService, type CopyProgressCallback } from "./worktree-include.js" diff --git a/packages/core/src/worktree/worktree-include.ts b/packages/core/src/worktree/worktree-include.ts index 40897468bfa..d22cc27d33f 100644 --- a/packages/core/src/worktree/worktree-include.ts +++ b/packages/core/src/worktree/worktree-include.ts @@ -5,7 +5,7 @@ * Used to copy untracked files (like node_modules) when creating worktrees. */ -import { execFile } from "child_process" +import { execFile, spawn } from "child_process" import * as fs from "fs/promises" import * as path from "path" import { promisify } from "util" @@ -14,6 +14,23 @@ import ignore, { type Ignore } from "ignore" import type { WorktreeIncludeStatus } from "./types.js" +/** + * Progress info for size-based copy tracking. + */ +export interface CopyProgress { + /** Current bytes copied */ + bytesCopied: number + /** Total bytes to copy */ + totalBytes: number + /** Name of current item being copied */ + itemName: string +} + +/** + * Callback for reporting copy progress during worktree file copying. + */ +export type CopyProgressCallback = (progress: CopyProgress) => void + const execFileAsync = promisify(execFile) /** @@ -93,9 +110,16 @@ export class WorktreeIncludeService { * Copy files matching .worktreeinclude patterns from source to target. * Only copies files that are ALSO in .gitignore (to avoid copying tracked files). * + * @param sourceDir - The source directory containing the files to copy + * @param targetDir - The target directory where files will be copied + * @param onProgress - Optional callback to report copy progress (size-based) * @returns Array of copied file/directory paths */ - async copyWorktreeIncludeFiles(sourceDir: string, targetDir: string): Promise { + async copyWorktreeIncludeFiles( + sourceDir: string, + targetDir: string, + onProgress?: CopyProgressCallback, + ): Promise { const worktreeIncludePath = path.join(sourceDir, ".worktreeinclude") const gitignorePath = path.join(sourceDir, ".gitignore") @@ -136,9 +160,30 @@ export class WorktreeIncludeService { // Find items that match BOTH patterns (intersection) const itemsToCopy = await this.findMatchingItems(sourceDir, worktreeIncludeMatcher, gitignoreMatcher) - // Copy the items + if (itemsToCopy.length === 0) { + return [] + } + + // Calculate total size of all items to copy (for accurate progress) + const itemSizes = await Promise.all( + itemsToCopy.map(async (item) => { + const sourcePath = path.join(sourceDir, item) + const size = await this.getPathSize(sourcePath) + return { item, size } + }), + ) + + const totalBytes = itemSizes.reduce((sum, { size }) => sum + size, 0) + let bytesCopied = 0 + + // Report initial progress + if (onProgress && totalBytes > 0) { + onProgress({ bytesCopied: 0, totalBytes, itemName: itemsToCopy[0]! }) + } + + // Copy the items with size-based progress tracking const copiedItems: string[] = [] - for (const item of itemsToCopy) { + for (const { item, size } of itemSizes) { const sourcePath = path.join(sourceDir, item) const targetPath = path.join(targetDir, item) @@ -146,23 +191,197 @@ export class WorktreeIncludeService { const stats = await fs.stat(sourcePath) if (stats.isDirectory()) { - // Use native cp for directories (much faster) - await this.copyDirectoryNative(sourcePath, targetPath) + // Use native cp for directories with progress polling + await this.copyDirectoryWithProgress( + sourcePath, + targetPath, + item, + bytesCopied, + totalBytes, + onProgress, + ) } else { + // Report progress before copying + onProgress?.({ bytesCopied, totalBytes, itemName: item }) + // Ensure parent directory exists await fs.mkdir(path.dirname(targetPath), { recursive: true }) await fs.copyFile(sourcePath, targetPath) } + + bytesCopied += size copiedItems.push(item) + + // Report progress after copying + onProgress?.({ bytesCopied, totalBytes, itemName: item }) } catch (error) { // Log but don't fail on individual copy errors console.error(`Failed to copy ${item}:`, error) + // Still count the size as "processed" to avoid progress getting stuck + bytesCopied += size } } return copiedItems } + /** + * Get the size on disk of a file (accounts for filesystem block allocation). + * Uses blksize to calculate actual disk usage including block overhead. + */ + private getSizeOnDisk(stats: { size: number; blksize?: number }): number { + // Calculate size on disk using filesystem block size + if (stats.blksize !== undefined && stats.blksize > 0) { + return stats.blksize * Math.ceil(stats.size / stats.blksize) + } + // Fallback to logical size when blksize not available + return stats.size + } + + /** + * Get the total size on disk of a file or directory (recursively). + * Uses native Node.js fs operations for cross-platform compatibility. + */ + private async getPathSize(targetPath: string): Promise { + try { + const stats = await fs.stat(targetPath) + + if (stats.isFile()) { + return this.getSizeOnDisk(stats) + } + + if (stats.isDirectory()) { + return await this.getDirectorySizeRecursive(targetPath) + } + + return 0 + } catch { + return 0 + } + } + + /** + * Recursively calculate directory size on disk using Node.js fs. + * Uses parallel processing for better performance on large directories. + */ + private async getDirectorySizeRecursive(dirPath: string): Promise { + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + const sizes = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(dirPath, entry.name) + try { + if (entry.isFile()) { + const stats = await fs.stat(entryPath) + return this.getSizeOnDisk(stats) + } else if (entry.isDirectory()) { + return await this.getDirectorySizeRecursive(entryPath) + } + return 0 + } catch { + return 0 // Skip inaccessible files + } + }), + ) + return sizes.reduce((sum, size) => sum + size, 0) + } catch { + return 0 + } + } + + /** + * Get the current size of a directory (for progress tracking). + */ + private async getCurrentDirectorySize(dirPath: string): Promise { + try { + await fs.access(dirPath) + return await this.getDirectorySizeRecursive(dirPath) + } catch { + return 0 + } + } + + /** + * Copy directory with progress polling. + * Starts native copy and polls target directory size to report progress. + */ + private async copyDirectoryWithProgress( + source: string, + target: string, + itemName: string, + bytesCopiedBefore: number, + totalBytes: number, + onProgress?: CopyProgressCallback, + ): Promise { + // Ensure parent directory exists + await fs.mkdir(path.dirname(target), { recursive: true }) + + const isWindows = process.platform === "win32" + const expectedSize = await this.getPathSize(source) + + // Start the copy process + const copyPromise = new Promise((resolve, reject) => { + let proc: ReturnType + + if (isWindows) { + proc = spawn("robocopy", [source, target, "/E", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP"], { + windowsHide: true, + }) + } else { + proc = spawn("cp", ["-r", "--", source, target]) + } + + proc.on("close", (code) => { + if (isWindows) { + // robocopy returns non-zero for success (values < 8) + if (code !== null && code < 8) { + resolve() + } else { + reject(new Error(`robocopy failed with code ${code}`)) + } + } else { + if (code === 0) { + resolve() + } else { + reject(new Error(`cp failed with code ${code}`)) + } + } + }) + + proc.on("error", reject) + }) + + // Poll progress while copying + const pollInterval = 500 // Poll every 500ms + let polling = true + + const pollProgress = async () => { + while (polling) { + const currentSize = await this.getCurrentDirectorySize(target) + const totalCopied = bytesCopiedBefore + currentSize + + onProgress?.({ + bytesCopied: Math.min(totalCopied, bytesCopiedBefore + expectedSize), + totalBytes, + itemName, + }) + + await new Promise((resolve) => setTimeout(resolve, pollInterval)) + } + } + + // Start polling and wait for copy to complete + const pollPromise = pollProgress() + + try { + await copyPromise + } finally { + polling = false + // Wait for final poll iteration to complete + await pollPromise.catch(() => {}) + } + } + /** * Parse a .gitignore-style file and return the patterns */ @@ -213,43 +432,6 @@ export class WorktreeIncludeService { return matchingItems } - - /** - * Copy directory using native cp command for performance. - * This is 10-20x faster than Node.js fs.cp for large directories like node_modules. - */ - private async copyDirectoryNative(source: string, target: string): Promise { - // Ensure parent directory exists - await fs.mkdir(path.dirname(target), { recursive: true }) - - // Use platform-appropriate copy command - const isWindows = process.platform === "win32" - - if (isWindows) { - // Use robocopy on Windows (more reliable than xcopy) - // robocopy returns non-zero for success, so we check the exit code - try { - await execFileAsync( - "robocopy", - [source, target, "/E", "/NFL", "/NDL", "/NJH", "/NJS", "/nc", "/ns", "/np"], - { windowsHide: true }, - ) - } catch (error) { - // robocopy returns non-zero for success (values < 8) - const exitCode = - typeof (error as { code?: unknown }).code === "number" - ? (error as { code: number }).code - : undefined - if (exitCode !== undefined && exitCode < 8) { - return // Success - } - throw error - } - } else { - // Use cp -r on Unix-like systems - await execFileAsync("cp", ["-r", "--", source, target]) - } - } } // Export singleton instance for convenience diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index cd36b081576..ba234b58f5c 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -103,6 +103,7 @@ export interface ExtensionMessage { // Worktree response types | "worktreeList" | "worktreeResult" + | "worktreeCopyProgress" | "branchList" | "worktreeDefaults" | "worktreeIncludeStatus" @@ -258,6 +259,10 @@ export interface ExtensionMessage { // branchWorktreeIncludeResult branch?: string hasWorktreeInclude?: boolean + // worktreeCopyProgress (size-based) + copyProgressBytesCopied?: number + copyProgressTotalBytes?: number + copyProgressItemName?: string } export interface OpenAiCodexRateLimitsMessage { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2af791b93ee..4e65f34abd3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3439,12 +3439,23 @@ export const webviewMessageHandler = async ( case "createWorktree": { try { - const { success, message: text } = await handleCreateWorktree(provider, { - path: message.worktreePath!, - branch: message.worktreeBranch, - baseBranch: message.worktreeBaseBranch, - createNewBranch: message.worktreeCreateNewBranch, - }) + const { success, message: text } = await handleCreateWorktree( + provider, + { + path: message.worktreePath!, + branch: message.worktreeBranch, + baseBranch: message.worktreeBaseBranch, + createNewBranch: message.worktreeCreateNewBranch, + }, + (progress) => { + provider.postMessageToWebview({ + type: "worktreeCopyProgress", + copyProgressBytesCopied: progress.bytesCopied, + copyProgressTotalBytes: progress.totalBytes, + copyProgressItemName: progress.itemName, + }) + }, + ) await provider.postMessageToWebview({ type: "worktreeResult", success, text }) } catch (error) { diff --git a/src/core/webview/worktree/handlers.ts b/src/core/webview/worktree/handlers.ts index 6910bda0695..59fffded5cf 100644 --- a/src/core/webview/worktree/handlers.ts +++ b/src/core/webview/worktree/handlers.ts @@ -17,7 +17,7 @@ import type { WorktreeListResponse, WorktreeDefaultsResponse, } from "@roo-code/types" -import { worktreeService, worktreeIncludeService } from "@roo-code/core" +import { worktreeService, worktreeIncludeService, type CopyProgressCallback } from "@roo-code/core" import type { ClineProvider } from "../ClineProvider" @@ -137,6 +137,7 @@ export async function handleCreateWorktree( baseBranch?: string createNewBranch?: boolean }, + onCopyProgress?: CopyProgressCallback, ): Promise { const cwd = provider.cwd @@ -154,7 +155,11 @@ export async function handleCreateWorktree( // If successful and .worktreeinclude exists, copy the files. if (result.success && result.worktree) { try { - const copiedItems = await worktreeIncludeService.copyWorktreeIncludeFiles(cwd, result.worktree.path) + const copiedItems = await worktreeIncludeService.copyWorktreeIncludeFiles( + cwd, + result.worktree.path, + onCopyProgress, + ) if (copiedItems.length > 0) { result.message += ` (copied ${copiedItems.length} item(s) from .worktreeinclude)` } diff --git a/webview-ui/src/components/worktrees/CreateWorktreeModal.tsx b/webview-ui/src/components/worktrees/CreateWorktreeModal.tsx index 2e54403b854..3e6a4505175 100644 --- a/webview-ui/src/components/worktrees/CreateWorktreeModal.tsx +++ b/webview-ui/src/components/worktrees/CreateWorktreeModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useMemo } from "react" +import prettyBytes from "pretty-bytes" import type { WorktreeDefaultsResponse, BranchInfo, WorktreeIncludeStatus } from "@roo-code/types" @@ -44,6 +45,11 @@ export const CreateWorktreeModal = ({ // UI state const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) + const [copyProgress, setCopyProgress] = useState<{ + bytesCopied: number + totalBytes: number + itemName: string + } | null>(null) // Fetch defaults and branches on open useEffect(() => { @@ -76,8 +82,17 @@ export const CreateWorktreeModal = ({ setIncludeStatus(message.worktreeIncludeStatus) break } + case "worktreeCopyProgress": { + setCopyProgress({ + bytesCopied: message.copyProgressBytesCopied ?? 0, + totalBytes: message.copyProgressTotalBytes ?? 0, + itemName: message.copyProgressItemName ?? "", + }) + break + } case "worktreeResult": { setIsCreating(false) + setCopyProgress(null) if (message.success) { if (openAfterCreate) { vscode.postMessage({ @@ -207,10 +222,42 @@ export const CreateWorktreeModal = ({

{error}

)} + + {/* Progress section - appears during file copying */} + {copyProgress && ( +
+
+ + {t("worktrees:copyingFiles")} + + + {copyProgress.totalBytes > 0 + ? Math.round((copyProgress.bytesCopied / copyProgress.totalBytes) * 100) + : 0} + % + +
+
+
0 ? (copyProgress.bytesCopied / copyProgress.totalBytes) * 100 : 0}%`, + }} + /> +
+
+ {t("worktrees:copyingProgress", { + item: copyProgress.itemName, + copied: prettyBytes(copyProgress.bytesCopied), + total: prettyBytes(copyProgress.totalBytes), + })} +
+
+ )}
-