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",