From 001cea3eb0692a406766eb34e5927a4a5c240be9 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 26 Feb 2026 14:39:26 +0000 Subject: [PATCH] feat: use hard links for file copies when processing .worktreeinclude Adds an optional useHardLinks parameter to copyWorktreeIncludeFiles that creates hard links instead of full copies when the filesystem supports them. This saves disk space and speeds up worktree creation for large directories like node_modules. - Add hardLinkWithFallback method that falls back to copy on failure - Add hardLinkDirectoryRecursive for recursive directory hard-linking - Thread useHardLinks option through handler and message types - Add "Use hard links" checkbox to CreateWorktreeModal (defaults to on) - Add i18n keys for the new UI elements - Add tests for hard link behavior including inode verification Closes #11758 --- .../__tests__/worktree-include.spec.ts | 94 ++++++++++++++ .../core/src/worktree/worktree-include.ts | 120 ++++++++++++++++-- packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/webviewMessageHandler.ts | 1 + src/core/webview/worktree/handlers.ts | 2 + .../worktrees/CreateWorktreeModal.tsx | 24 +++- webview-ui/src/i18n/locales/en/worktrees.json | 2 + 7 files changed, 233 insertions(+), 11 deletions(-) diff --git a/packages/core/src/worktree/__tests__/worktree-include.spec.ts b/packages/core/src/worktree/__tests__/worktree-include.spec.ts index 88069b95da..743430b38f 100644 --- a/packages/core/src/worktree/__tests__/worktree-include.spec.ts +++ b/packages/core/src/worktree/__tests__/worktree-include.spec.ts @@ -302,5 +302,99 @@ describe("WorktreeIncludeService", () => { expect(result).toContain("node_modules") }) + + describe("useHardLinks", () => { + it("should hard-link single files when useHardLinks is true", async () => { + await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), ".env.local") + await fs.writeFile(path.join(sourceDir, ".gitignore"), ".env.local") + await fs.writeFile(path.join(sourceDir, ".env.local"), "LOCAL_VAR=value") + + const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir, undefined, true) + + expect(result).toContain(".env.local") + const copiedContent = await fs.readFile(path.join(targetDir, ".env.local"), "utf-8") + expect(copiedContent).toBe("LOCAL_VAR=value") + + // Verify it's a hard link (same inode) + const sourceStats = await fs.stat(path.join(sourceDir, ".env.local")) + const targetStats = await fs.stat(path.join(targetDir, ".env.local")) + expect(targetStats.ino).toBe(sourceStats.ino) + }) + + it("should hard-link directory contents recursively when useHardLinks is true", 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", "pkg"), { recursive: true }) + await fs.writeFile(path.join(sourceDir, "node_modules", "pkg", "index.js"), "module.exports = {}") + await fs.writeFile(path.join(sourceDir, "node_modules", "test.txt"), "test") + + const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir, undefined, true) + + expect(result).toContain("node_modules") + + // Verify file contents + const copiedContent = await fs.readFile( + path.join(targetDir, "node_modules", "pkg", "index.js"), + "utf-8", + ) + expect(copiedContent).toBe("module.exports = {}") + + // Verify hard links (same inode) + const sourceStats = await fs.stat(path.join(sourceDir, "node_modules", "test.txt")) + const targetStats = await fs.stat(path.join(targetDir, "node_modules", "test.txt")) + expect(targetStats.ino).toBe(sourceStats.ino) + }) + + it("should fall back to copy when hard link fails (e.g., cross-device)", async () => { + // Even if hard linking were to fail, the fallback ensures files are still copied + await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), ".env.local") + await fs.writeFile(path.join(sourceDir, ".gitignore"), ".env.local") + await fs.writeFile(path.join(sourceDir, ".env.local"), "LOCAL_VAR=value") + + // We can't easily simulate cross-device in a unit test, but we can verify + // that when useHardLinks is true, the file is still accessible in the target + const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir, undefined, true) + + expect(result).toContain(".env.local") + const copiedContent = await fs.readFile(path.join(targetDir, ".env.local"), "utf-8") + expect(copiedContent).toBe("LOCAL_VAR=value") + }) + + it("should perform regular copies when useHardLinks is false", async () => { + await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), ".env.local") + await fs.writeFile(path.join(sourceDir, ".gitignore"), ".env.local") + await fs.writeFile(path.join(sourceDir, ".env.local"), "LOCAL_VAR=value") + + const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir, undefined, false) + + expect(result).toContain(".env.local") + const copiedContent = await fs.readFile(path.join(targetDir, ".env.local"), "utf-8") + expect(copiedContent).toBe("LOCAL_VAR=value") + + // Verify it's NOT a hard link (different inode for regular copy) + const sourceStats = await fs.stat(path.join(sourceDir, ".env.local")) + const targetStats = await fs.stat(path.join(targetDir, ".env.local")) + expect(targetStats.ino).not.toBe(sourceStats.ino) + }) + + it("should report progress when hard-linking directories", 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 }) + await fs.writeFile(path.join(sourceDir, "node_modules", "test.txt"), "test content") + + const progressCalls: Array<{ bytesCopied: number; itemName: string }> = [] + const onProgress = vi.fn((progress: { bytesCopied: number; itemName: string }) => { + progressCalls.push({ ...progress }) + }) + + await service.copyWorktreeIncludeFiles(sourceDir, targetDir, onProgress, true) + + expect(onProgress).toHaveBeenCalled() + expect(progressCalls.length).toBeGreaterThan(0) + const finalCall = progressCalls[progressCalls.length - 1] + expect(finalCall?.bytesCopied).toBeGreaterThan(0) + }) + }) }) }) diff --git a/packages/core/src/worktree/worktree-include.ts b/packages/core/src/worktree/worktree-include.ts index 09156eb28c..d40c76f60d 100644 --- a/packages/core/src/worktree/worktree-include.ts +++ b/packages/core/src/worktree/worktree-include.ts @@ -112,12 +112,14 @@ export class WorktreeIncludeService { * @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) + * @param useHardLinks - If true, use hard links instead of copies when possible (defaults to false) * @returns Array of copied file/directory paths */ async copyWorktreeIncludeFiles( sourceDir: string, targetDir: string, onProgress?: CopyProgressCallback, + useHardLinks?: boolean, ): Promise { const worktreeIncludePath = path.join(sourceDir, ".worktreeinclude") const gitignorePath = path.join(sourceDir, ".gitignore") @@ -180,21 +182,37 @@ export class WorktreeIncludeService { const stats = await fs.stat(sourcePath) if (stats.isDirectory()) { - // Copy directory with progress tracking - bytesCopied = await this.copyDirectoryWithProgress( - sourcePath, - targetPath, - item, - bytesCopied, - onProgress, - ) + if (useHardLinks) { + // Recursively hard-link directory contents + bytesCopied = await this.hardLinkDirectoryWithProgress( + sourcePath, + targetPath, + item, + bytesCopied, + onProgress, + ) + } else { + // Copy directory with progress tracking + bytesCopied = await this.copyDirectoryWithProgress( + sourcePath, + targetPath, + item, + bytesCopied, + onProgress, + ) + } } else { // Report progress before copying onProgress?.({ bytesCopied, itemName: item }) // Ensure parent directory exists await fs.mkdir(path.dirname(targetPath), { recursive: true }) - await fs.copyFile(sourcePath, targetPath) + + if (useHardLinks) { + await this.hardLinkWithFallback(sourcePath, targetPath) + } else { + await fs.copyFile(sourcePath, targetPath) + } // Update bytes copied bytesCopied += this.getSizeOnDisk(stats) @@ -372,6 +390,90 @@ export class WorktreeIncludeService { return bytesCopiedBefore + finalSize } + /** + * Create a hard link for a single file, falling back to copy if hard linking fails + * (e.g., cross-device link, unsupported filesystem, or permissions issue). + */ + private async hardLinkWithFallback(source: string, target: string): Promise { + try { + await fs.link(source, target) + } catch { + // Fallback to regular copy if hard link fails + await fs.copyFile(source, target) + } + } + + /** + * Recursively hard-link all files in a directory from source to target. + * Creates the directory structure with fs.mkdir and hard-links each file. + * Falls back to copyDirectoryWithProgress if the initial hard-link attempt fails. + * Returns the updated bytesCopied count. + */ + private async hardLinkDirectoryWithProgress( + source: string, + target: string, + itemName: string, + bytesCopiedBefore: number, + onProgress?: CopyProgressCallback, + ): Promise { + try { + const bytesCopied = await this.hardLinkDirectoryRecursive( + source, + target, + itemName, + bytesCopiedBefore, + onProgress, + ) + return bytesCopied + } catch { + // If recursive hard-linking fails entirely, fall back to native copy + return this.copyDirectoryWithProgress(source, target, itemName, bytesCopiedBefore, onProgress) + } + } + + /** + * Recursively walk a directory, creating directories and hard-linking files. + * Reports progress as files are linked. + */ + private async hardLinkDirectoryRecursive( + source: string, + target: string, + itemName: string, + bytesCopiedBefore: number, + onProgress?: CopyProgressCallback, + ): Promise { + await fs.mkdir(target, { recursive: true }) + + const entries = await fs.readdir(source, { withFileTypes: true }) + let bytesCopied = bytesCopiedBefore + + for (const entry of entries) { + const sourcePath = path.join(source, entry.name) + const targetPath = path.join(target, entry.name) + + if (entry.isDirectory()) { + bytesCopied = await this.hardLinkDirectoryRecursive( + sourcePath, + targetPath, + itemName, + bytesCopied, + onProgress, + ) + } else if (entry.isFile()) { + await this.hardLinkWithFallback(sourcePath, targetPath) + const stats = await fs.stat(sourcePath) + bytesCopied += this.getSizeOnDisk(stats) + + onProgress?.({ + bytesCopied, + itemName, + }) + } + } + + return bytesCopied + } + /** * Parse a .gitignore-style file and return the patterns */ diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 253ce3e55d..cddaf6a8df 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -686,6 +686,7 @@ export interface WebviewMessage { worktreeCreateNewBranch?: boolean worktreeForce?: boolean worktreeNewWindow?: boolean + worktreeUseHardLinks?: boolean worktreeIncludeContent?: string } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4ec715cf10..6c47fd32a4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3390,6 +3390,7 @@ export const webviewMessageHandler = async ( copyProgressItemName: progress.itemName, }) }, + message.worktreeUseHardLinks, ) await provider.postMessageToWebview({ type: "worktreeResult", success, text }) diff --git a/src/core/webview/worktree/handlers.ts b/src/core/webview/worktree/handlers.ts index 67c88b910e..aa6efc7b64 100644 --- a/src/core/webview/worktree/handlers.ts +++ b/src/core/webview/worktree/handlers.ts @@ -137,6 +137,7 @@ export async function handleCreateWorktree( createNewBranch?: boolean }, onCopyProgress?: CopyProgressCallback, + useHardLinks?: boolean, ): Promise { const cwd = provider.cwd @@ -158,6 +159,7 @@ export async function handleCreateWorktree( cwd, result.worktree.path, onCopyProgress, + useHardLinks, ) 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 84743c1650..0e482d8b03 100644 --- a/webview-ui/src/components/worktrees/CreateWorktreeModal.tsx +++ b/webview-ui/src/components/worktrees/CreateWorktreeModal.tsx @@ -7,7 +7,7 @@ import { vscode } from "@/utils/vscode" import { useAppTranslation } from "@/i18n/TranslationContext" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Button, Input } from "@/components/ui" import { SearchableSelect, type SearchableSelectOption } from "@/components/ui/searchable-select" -import { CornerDownRight, Folder, FolderSearch, Info } from "lucide-react" +import { CornerDownRight, Folder, FolderSearch, Info, Link } from "lucide-react" interface CreateWorktreeModalProps { open: boolean @@ -34,6 +34,9 @@ export const CreateWorktreeModal = ({ const [branches, setBranches] = useState(null) const [includeStatus, setIncludeStatus] = useState(null) + // Hard links option + const [useHardLinks, setUseHardLinks] = useState(true) + // UI state const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -121,8 +124,9 @@ export const CreateWorktreeModal = ({ worktreeBranch: branchName, worktreeBaseBranch: baseBranch, worktreeCreateNewBranch: true, + worktreeUseHardLinks: useHardLinks, }) - }, [worktreePath, branchName, baseBranch]) + }, [worktreePath, branchName, baseBranch, useHardLinks]) const isValid = branchName.trim() && worktreePath.trim() && baseBranch.trim() @@ -215,6 +219,22 @@ export const CreateWorktreeModal = ({ /> + {/* Hard links option - only show when .worktreeinclude exists */} + {includeStatus?.exists && ( +
+ + +
+ )} + {/* Error message */} {error && (
diff --git a/webview-ui/src/i18n/locales/en/worktrees.json b/webview-ui/src/i18n/locales/en/worktrees.json index a9c901ede0..4fe10ae93d 100644 --- a/webview-ui/src/i18n/locales/en/worktrees.json +++ b/webview-ui/src/i18n/locales/en/worktrees.json @@ -43,6 +43,8 @@ "creating": "Creating...", "copyingFiles": "Copying files...", "copyingProgress": "{{item}} — {{copied}} copied", + "useHardLinks": "Use hard links for included files", + "useHardLinksTooltip": "Creates hard links instead of copies, saving disk space and speeding up worktree creation. Disable if source and target are on different filesystems.", "cancel": "Cancel", "deleteWorktree": "Delete Worktree",