Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/core/src/worktree/__tests__/worktree-include.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
})
2 changes: 1 addition & 1 deletion packages/core/src/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
268 changes: 225 additions & 43 deletions packages/core/src/worktree/worktree-include.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)

/**
Expand Down Expand Up @@ -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<string[]> {
async copyWorktreeIncludeFiles(
sourceDir: string,
targetDir: string,
onProgress?: CopyProgressCallback,
): Promise<string[]> {
const worktreeIncludePath = path.join(sourceDir, ".worktreeinclude")
const gitignorePath = path.join(sourceDir, ".gitignore")

Expand Down Expand Up @@ -136,33 +160,228 @@ 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)

try {
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<number> {
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<number> {
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<number> {
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<void> {
// 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<void>((resolve, reject) => {
let proc: ReturnType<typeof spawn>

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
*/
Expand Down Expand Up @@ -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<void> {
// 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
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export interface ExtensionMessage {
// Worktree response types
| "worktreeList"
| "worktreeResult"
| "worktreeCopyProgress"
| "branchList"
| "worktreeDefaults"
| "worktreeIncludeStatus"
Expand Down Expand Up @@ -258,6 +259,10 @@ export interface ExtensionMessage {
// branchWorktreeIncludeResult
branch?: string
hasWorktreeInclude?: boolean
// worktreeCopyProgress (size-based)
copyProgressBytesCopied?: number
copyProgressTotalBytes?: number
copyProgressItemName?: string
}

export interface OpenAiCodexRateLimitsMessage {
Expand Down
Loading
Loading