<# .SYNOPSIS Moves (or copies) a list of folders to another drive with robocopy, keeping their structure. .DESCRIPTION For each source folder, works out a matching path under -DestinationRoot and hands the job to robocopy, which is far better than Move-Item at big trees, long paths, retries and logging. Each source gets its own log file and its own result object, and one failure doesn't stop the rest. Supports -WhatIf. .PARAMETER Source One or more folders to move, e.g. C:\Media, D:\Projects. .PARAMETER DestinationRoot Where they go, e.g. E:\ or E:\Migrated. .PARAMETER IncludeDriveLetter Put each source under a folder named for its drive (E:\C\Media, E:\D\Media). Use this when two sources share a name on different drives; the script refuses to merge them otherwise. .PARAMETER Copy Copy instead of move. Sources are left alone. .PARAMETER LogFolder Where robocopy logs go. Default: the current folder. .PARAMETER Threads robocopy /MT value. Default 8. Use 1 for USB drives and old spinning disks. .EXAMPLE .\Move-FolderToDrive.ps1 -Source C:\Media, D:\Projects -DestinationRoot E:\ -WhatIf .EXAMPLE .\Move-FolderToDrive.ps1 -Source C:\Media, D:\Media -DestinationRoot E:\Migrated -IncludeDriveLetter -Copy #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })][string[]]$Source, [Parameter(Mandatory)][string]$DestinationRoot, [switch]$IncludeDriveLetter, [switch]$Copy, [string]$LogFolder = (Get-Location).Path, [ValidateRange(1, 128)][int]$Threads = 8 ) $exitMeaning = @{ 0 = 'Nothing to do'; 1 = 'Files copied'; 2 = 'Extra files at destination'; 3 = 'Files copied, extras at destination' 4 = 'Mismatches found'; 5 = 'Files copied, mismatches found'; 6 = 'Extras and mismatches'; 7 = 'Files copied, extras and mismatches' } # Work out every destination first, so collisions are caught before anything moves. $plan = foreach ($src in $Source) { $full = (Resolve-Path -LiteralPath $src).ProviderPath.TrimEnd('\') if ($full -notmatch '^([A-Za-z]):\\(.+)$') { throw "$full isn't a folder on a local drive. Point the script at folders like D:\Media, not drive roots or UNC paths." } $child = if ($IncludeDriveLetter) { '{0}\{1}' -f $Matches[1].ToUpper(), $Matches[2] } else { $Matches[2] } # Plain string join: Join-Path insists the destination drive already exists, which breaks -WhatIf planning. [pscustomobject]@{ Source = $full; Destination = '{0}\{1}' -f $DestinationRoot.TrimEnd('\'), $child } } $dupes = $plan | Group-Object Destination | Where-Object Count -gt 1 if ($dupes) { throw "These sources would land in the same place: $(($dupes.Group.Source) -join ', '). Use -IncludeDriveLetter." } $verb = if ($Copy) { 'Copy' } else { 'Move' } foreach ($item in $plan) { if (-not $PSCmdlet.ShouldProcess($item.Source, "$verb to $($item.Destination)")) { continue } $log = Join-Path $LogFolder ('robocopy_{0}_{1:yyyyMMdd-HHmmss}.log' -f ($item.Source -replace '[:\\ ]+', '_').Trim('_'), (Get-Date)) $rcArgs = @($item.Source, $item.Destination, '/E', '/COPY:DAT', '/DCOPY:DAT', '/XJ', '/R:2', '/W:5', "/MT:$Threads", '/NP', "/LOG:$log") if (-not $Copy) { $rcArgs += '/MOVE' } Write-Verbose "robocopy $($rcArgs -join ' ')" try { robocopy @rcArgs | Out-Null $code = $LASTEXITCODE [pscustomobject]@{ Source = $item.Source Destination = $item.Destination ExitCode = $code Result = if ($code -ge 8) { 'FAILED - see log' } else { $exitMeaning[$code] } Log = $log } if ($code -ge 8) { Write-Warning "$($item.Source): robocopy exit code $code. Check $log" } } catch { Write-Warning "$($item.Source): $($_.Exception.Message)" } }