mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
e25b1f2768
commit
001cea3eb0
7 changed files with 233 additions and 11 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -686,6 +686,7 @@ export interface WebviewMessage {
|
|||
worktreeCreateNewBranch?: boolean
|
||||
worktreeForce?: boolean
|
||||
worktreeNewWindow?: boolean
|
||||
worktreeUseHardLinks?: boolean
|
||||
worktreeIncludeContent?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3390,6 +3390,7 @@ export const webviewMessageHandler = async (
|
|||
copyProgressItemName: progress.itemName,
|
||||
})
|
||||
},
|
||||
message.worktreeUseHardLinks,
|
||||
)
|
||||
|
||||
await provider.postMessageToWebview({ type: "worktreeResult", success, text })
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue