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
This commit is contained in:
Roo Code 2026-02-26 14:39:26 +00:00
parent e25b1f2768
commit 001cea3eb0
7 changed files with 233 additions and 11 deletions

View file

@ -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)
})
})
})
})

View file

@ -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<string[]> {
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<void> {
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<number> {
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<number> {
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
*/

View file

@ -686,6 +686,7 @@ export interface WebviewMessage {
worktreeCreateNewBranch?: boolean
worktreeForce?: boolean
worktreeNewWindow?: boolean
worktreeUseHardLinks?: boolean
worktreeIncludeContent?: string
}

View file

@ -3390,6 +3390,7 @@ export const webviewMessageHandler = async (
copyProgressItemName: progress.itemName,
})
},
message.worktreeUseHardLinks,
)
await provider.postMessageToWebview({ type: "worktreeResult", success, text })

View file

@ -137,6 +137,7 @@ export async function handleCreateWorktree(
createNewBranch?: boolean
},
onCopyProgress?: CopyProgressCallback,
useHardLinks?: boolean,
): Promise<WorktreeResult> {
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)`

View file

@ -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<BranchInfo | null>(null)
const [includeStatus, setIncludeStatus] = useState<WorktreeIncludeStatus | null>(null)
// Hard links option
const [useHardLinks, setUseHardLinks] = useState(true)
// UI state
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(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 = ({
/>
</div>
{/* Hard links option - only show when .worktreeinclude exists */}
{includeStatus?.exists && (
<div className="flex items-center gap-2 ml-2" title={t("worktrees:useHardLinksTooltip")}>
<Link className="size-4 shrink-0" />
<label className="flex items-center gap-2 text-sm text-vscode-foreground cursor-pointer">
<input
type="checkbox"
checked={useHardLinks}
onChange={(e) => setUseHardLinks(e.target.checked)}
className="accent-vscode-button-background"
/>
{t("worktrees:useHardLinks")}
</label>
</div>
)}
{/* Error message */}
{error && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-vscode-inputValidation-errorBackground border border-vscode-inputValidation-errorBorder text-sm">

View file

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