Skip to content

Commit 7976687

Browse files
committed
feat(Sync-Directory): add ThreadCount parameter for multi-threaded robocopy support and enhance input validation
1 parent db19963 commit 7976687

2 files changed

Lines changed: 191 additions & 12 deletions

File tree

Functions/Utilities/Sync-Directory.ps1

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ function Sync-Directory
4040
For rsync: array of rsync flags (e.g., @('--compress', '--links'))
4141
For robocopy: array of robocopy switches (e.g., @('/MT:8', '/R:3'))
4242
43+
.PARAMETER ThreadCount
44+
Number of threads to use for robocopy on Windows (`/MT:n`).
45+
Ignored on macOS/Linux. Valid range is 1-128.
46+
If `-ExtraOptions` already includes `/MT` or `/MT:n`, that value is used instead.
47+
4348
.EXAMPLE
4449
PS > Sync-Directory -Source '.\MyProject' -Destination 'D:\Backup\MyProject'
4550
@@ -151,30 +156,41 @@ function Sync-Directory
151156
[String[]]$Exclude = @(),
152157

153158
[Parameter()]
154-
[String[]]$ExtraOptions = @()
159+
[String[]]$ExtraOptions = @(),
160+
161+
[Parameter()]
162+
[ValidateRange(1, 128)]
163+
[Int32]$ThreadCount = ([Math]::Min(32, [Math]::Max(4, [Environment]::ProcessorCount)))
155164
)
156165

157166
begin
158167
{
159168
Write-Verbose 'Starting Sync-Directory'
160169

161-
# Detect platform
162-
if ($PSVersionTable.PSVersion.Major -lt 6)
163-
{
164-
# PowerShell 5.1 - Windows only
165-
$IsWindowsPlatform = $true
166-
}
167-
else
168-
{
169-
# PowerShell Core - use built-in variables
170-
$IsWindowsPlatform = $IsWindows
171-
}
170+
# Detect platform once for path comparison and tool selection.
171+
$IsWindowsPlatform = $IsWindows -or $env:OS -eq 'Windows_NT'
172+
$pathComparison = if ($IsWindowsPlatform) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
173+
$separatorChars = @([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
172174

173175
Write-Verbose "Platform: $(if ($IsWindowsPlatform) { 'Windows' } else { 'macOS/Linux' })"
174176

175177
# Resolve paths to absolute paths (cross-platform compatible)
176178
$Source = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Source)
177179
$Destination = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Destination)
180+
$Source = [System.IO.Path]::GetFullPath($Source)
181+
$Destination = [System.IO.Path]::GetFullPath($Destination)
182+
183+
$sourceComparable = $Source.TrimEnd($separatorChars)
184+
if ([String]::IsNullOrEmpty($sourceComparable))
185+
{
186+
$sourceComparable = [System.IO.Path]::DirectorySeparatorChar.ToString()
187+
}
188+
189+
$destinationComparable = $Destination.TrimEnd($separatorChars)
190+
if ([String]::IsNullOrEmpty($destinationComparable))
191+
{
192+
$destinationComparable = [System.IO.Path]::DirectorySeparatorChar.ToString()
193+
}
178194

179195
Write-Verbose "Resolved source path: $Source"
180196
Write-Verbose "Resolved destination path: $Destination"
@@ -184,6 +200,45 @@ function Sync-Directory
184200
{
185201
throw "Source directory does not exist: $Source"
186202
}
203+
204+
if (Test-Path -Path $Destination -PathType Leaf)
205+
{
206+
throw "Destination path exists as a file. Specify a directory path instead: $Destination"
207+
}
208+
209+
# Prevent recursive self-sync scenarios.
210+
if ([String]::Equals($sourceComparable, $destinationComparable, $pathComparison))
211+
{
212+
throw "Source and destination cannot be the same directory: $Source"
213+
}
214+
215+
$sourcePrefix = if ($sourceComparable.EndsWith([System.IO.Path]::DirectorySeparatorChar.ToString()))
216+
{
217+
$sourceComparable
218+
}
219+
else
220+
{
221+
$sourceComparable + [System.IO.Path]::DirectorySeparatorChar
222+
}
223+
224+
$destinationPrefix = if ($destinationComparable.EndsWith([System.IO.Path]::DirectorySeparatorChar.ToString()))
225+
{
226+
$destinationComparable
227+
}
228+
else
229+
{
230+
$destinationComparable + [System.IO.Path]::DirectorySeparatorChar
231+
}
232+
233+
if ($destinationComparable.StartsWith($sourcePrefix, $pathComparison))
234+
{
235+
throw "Destination cannot be inside source: $Destination"
236+
}
237+
238+
if ($Delete -and $sourceComparable.StartsWith($destinationPrefix, $pathComparison))
239+
{
240+
throw "Source cannot be inside destination when -Delete is used: $Source"
241+
}
187242
}
188243

189244
process
@@ -240,6 +295,13 @@ function Sync-Directory
240295
$robocopyArgs += '/R:1'
241296
$robocopyArgs += '/W:1'
242297

298+
# Enable multi-threaded copy by default for better performance
299+
$hasThreadingOption = $ExtraOptions | Where-Object { $_ -match '^/MT(?::\d+)?$' }
300+
if (-not $hasThreadingOption)
301+
{
302+
$robocopyArgs += "/MT:$ThreadCount"
303+
}
304+
243305
# Handle exclusions
244306
foreach ($Pattern in $Exclude)
245307
{

Tests/Unit/Utilities/Sync-Directory.Tests.ps1

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ Describe 'Sync-Directory' -Tag 'Unit' {
4747
(Get-Command Sync-Directory).Parameters['ExtraOptions'].ParameterType | Should -Be ([String[]])
4848
}
4949

50+
It 'Should have optional ThreadCount parameter' {
51+
(Get-Command Sync-Directory).Parameters['ThreadCount'].ParameterType | Should -Be ([Int32])
52+
}
53+
5054
It 'Should support ShouldProcess' {
5155
(Get-Command Sync-Directory).Parameters.ContainsKey('WhatIf') | Should -BeTrue
5256
(Get-Command Sync-Directory).Parameters.ContainsKey('Confirm') | Should -BeTrue
@@ -86,6 +90,75 @@ Describe 'Sync-Directory' -Tag 'Unit' {
8690
if (Test-Path $TestDest) { Remove-Item -Path $TestDest -Recurse -Force }
8791
}
8892
}
93+
94+
It 'Should throw error when source and destination are the same directory' {
95+
$TempPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "sync-test-same-path-$(Get-Random)"
96+
97+
try
98+
{
99+
New-Item -ItemType Directory -Path $TempPath -Force | Out-Null
100+
101+
{ Sync-Directory -Source $TempPath -Destination $TempPath -DryRun -ErrorAction Stop } |
102+
Should -Throw '*same directory*'
103+
}
104+
finally
105+
{
106+
if (Test-Path $TempPath) { Remove-Item -Path $TempPath -Recurse -Force }
107+
}
108+
}
109+
110+
It 'Should throw error when destination is inside source' {
111+
$TestSource = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "sync-test-nested-source-$(Get-Random)"
112+
$TestDest = Join-Path -Path $TestSource -ChildPath 'nested-destination'
113+
114+
try
115+
{
116+
New-Item -ItemType Directory -Path $TestSource -Force | Out-Null
117+
118+
{ Sync-Directory -Source $TestSource -Destination $TestDest -DryRun -ErrorAction Stop } |
119+
Should -Throw '*Destination cannot be inside source*'
120+
}
121+
finally
122+
{
123+
if (Test-Path $TestSource) { Remove-Item -Path $TestSource -Recurse -Force }
124+
}
125+
}
126+
127+
It 'Should throw error when source is inside destination and -Delete is used' {
128+
$TestDest = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "sync-test-parent-dest-$(Get-Random)"
129+
$TestSource = Join-Path -Path $TestDest -ChildPath 'nested-source'
130+
131+
try
132+
{
133+
New-Item -ItemType Directory -Path $TestSource -Force | Out-Null
134+
135+
{ Sync-Directory -Source $TestSource -Destination $TestDest -Delete -DryRun -ErrorAction Stop } |
136+
Should -Throw '*Source cannot be inside destination when -Delete is used*'
137+
}
138+
finally
139+
{
140+
if (Test-Path $TestDest) { Remove-Item -Path $TestDest -Recurse -Force }
141+
}
142+
}
143+
144+
It 'Should throw error when destination path exists as a file' {
145+
$TestSource = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "sync-test-dest-file-source-$(Get-Random)"
146+
$TestDestFile = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "sync-test-dest-file-$(Get-Random).txt"
147+
148+
try
149+
{
150+
New-Item -ItemType Directory -Path $TestSource -Force | Out-Null
151+
'file destination' | Out-File -FilePath $TestDestFile
152+
153+
{ Sync-Directory -Source $TestSource -Destination $TestDestFile -DryRun -ErrorAction Stop } |
154+
Should -Throw '*exists as a file*'
155+
}
156+
finally
157+
{
158+
if (Test-Path $TestSource) { Remove-Item -Path $TestSource -Recurse -Force }
159+
if (Test-Path $TestDestFile) { Remove-Item -Path $TestDestFile -Force }
160+
}
161+
}
89162
}
90163

91164
Context 'Platform Detection' {
@@ -324,6 +397,50 @@ Describe 'Sync-Directory' -Tag 'Unit' {
324397
if (Test-Path $TestDest) { Remove-Item -Path $TestDest -Recurse -Force }
325398
}
326399
}
400+
401+
It 'Should validate ThreadCount range' {
402+
$TestSource = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'sync-test-threadcount-source'
403+
$TestDest = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'sync-test-threadcount-dest'
404+
405+
try
406+
{
407+
New-Item -ItemType Directory -Path $TestSource -Force | Out-Null
408+
'test' | Out-File (Join-Path -Path $TestSource -ChildPath 'test.txt')
409+
410+
{ Sync-Directory -Source $TestSource -Destination $TestDest -ThreadCount 0 -DryRun -ErrorAction Stop } |
411+
Should -Throw
412+
}
413+
finally
414+
{
415+
if (Test-Path $TestSource) { Remove-Item -Path $TestSource -Recurse -Force }
416+
if (Test-Path $TestDest) { Remove-Item -Path $TestDest -Recurse -Force }
417+
}
418+
}
419+
420+
It 'Should include ThreadCount in robocopy command when running on Windows' {
421+
if (-not $IsWindowsPlatform)
422+
{
423+
Set-ItResult -Skipped -Because 'robocopy thread count is Windows-specific'
424+
return
425+
}
426+
427+
$TestSource = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'sync-test-threadcount-win-source'
428+
$TestDest = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'sync-test-threadcount-win-dest'
429+
430+
try
431+
{
432+
New-Item -ItemType Directory -Path $TestSource -Force | Out-Null
433+
'test' | Out-File (Join-Path -Path $TestSource -ChildPath 'test.txt')
434+
435+
$Result = Sync-Directory -Source $TestSource -Destination $TestDest -ThreadCount 12 -DryRun
436+
$Result.Command | Should -Match '/MT:12'
437+
}
438+
finally
439+
{
440+
if (Test-Path $TestSource) { Remove-Item -Path $TestSource -Recurse -Force }
441+
if (Test-Path $TestDest) { Remove-Item -Path $TestDest -Recurse -Force }
442+
}
443+
}
327444
}
328445

329446
Context 'Verbose Output' {

0 commit comments

Comments
 (0)