Skip to content
Draft
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
94 changes: 94 additions & 0 deletions packages/core/src/worktree/__tests__/worktree-include.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
})
120 changes: 111 additions & 9 deletions packages/core/src/worktree/worktree-include.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ export interface WebviewMessage {
worktreeCreateNewBranch?: boolean
worktreeForce?: boolean
worktreeNewWindow?: boolean
worktreeUseHardLinks?: boolean
worktreeIncludeContent?: string
}

Expand Down
1 change: 1 addition & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3390,6 +3390,7 @@ export const webviewMessageHandler = async (
copyProgressItemName: progress.itemName,
})
},
message.worktreeUseHardLinks,
)

await provider.postMessageToWebview({ type: "worktreeResult", success, text })
Expand Down
2 changes: 2 additions & 0 deletions src/core/webview/worktree/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export async function handleCreateWorktree(
createNewBranch?: boolean
},
onCopyProgress?: CopyProgressCallback,
useHardLinks?: boolean,
): Promise<WorktreeResult> {
const cwd = provider.cwd

Expand All @@ -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)`
Expand Down
24 changes: 22 additions & 2 deletions webview-ui/src/components/worktrees/CreateWorktreeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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">
Expand Down
2 changes: 2 additions & 0 deletions webview-ui/src/i18n/locales/ca/worktrees.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions webview-ui/src/i18n/locales/de/worktrees.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions webview-ui/src/i18n/locales/en/worktrees.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions webview-ui/src/i18n/locales/es/worktrees.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions webview-ui/src/i18n/locales/fr/worktrees.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions webview-ui/src/i18n/locales/hi/worktrees.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading