Use PowerShell to Copy Files to Multiple Locations

During the course of my work week, I often need to backup files. Most of the time, I need to copy files to another directory or location. On occasion, I find myself needing to copy the same files to multiple locations. Yes, I could wait for the first copy command to finish, hit the up arrow to get the last command, modify it, and copy again. This process seems like a lot of work, and I feel there has to be a more efficient way. As a result, I set out to see what I could do with PowerShell. I’m in PowerShell all the time so if I can get this to work, it will probably be faster than using the GUI. Plus, I may experience a scenario in the future where I need to do this without the GUI.

For the sake of my demonstration, I’m going to define two different destinations.

$destA = "F:\BackupTest"
$destB = \\jdh-nvnas\temp
$destination = $destA,$destB

One very easy approach is to simply pass the results of the first copy operation to another.

dir c:\work\*.txt | copy-item -Destination $destA -PassThru | copy -dest $destB -PassThru

Copy-Item by default doesn’t write anything to the pipeline unless you use –Passthru. In this one-line command, I am copying all the .txt files from C:\work to the first destination. As files are copied, they are copied again to the second destination. Technically the files in $destB are copies of the files in $destA. This sequential approach works nicely if the file sets are small, as files can’t get copied to the second destination until they are copied to the first.
Another approach is to use background jobs. I can define a hashtable of parameters to splat to Copy-Item.

$paramHash = @{
 Path = 'C:\work'
 Destination = $DestA
 Recurse = $True
 Container = $True
 PassThru = $True
}

It takes a little PowerShell sleight of hand to pass this hashtable, but I can create the first job.

Start-Job {Param($paramHash) Copy-Item @paramHash} -ArgumentList $paramHash -Name TestA

The only thing that needs to change for the second job is the destination.

$paramHash.Destination = $DestB

Then I can create another job.

Start-Job {Param($paramHash) Copy-Item @paramHash} -ArgumentList $paramHash -Name TestB

My copy task in this example is the entire C:\Work folder including subfolders so this might take some time to copy. But it is copying to two locations simulataneously. I’m using –Passthru so I can always receive the job results if I want to verify. There is the potential for access violations depending on where you are copying and file size.

In my examples one destination is an external USB 3 drive and the other is a location on my NAS. I can’t guarantee this approach is hassle or error free.
Another idea I had was to spin up a second PowerShell window. Remember, ideally I’d like the copy process to run simultaneously. What I need to do is launch a command like this:

powershell -nologo -noprofile -command '&{copy -path c:\work\ -recurse -container -destination f:\backuptest -passthru}'

The challenge is to construct this dynamically for both locations.

foreach ($item in $destination) {
#define copy-item parameter hashtable
$paramHash = @{
 Path = 'C:\work';
 Recurse = $True;
 Destination = $Item;
 Container = $True;
 PassThru = $True;
 Force = $True;
}
#convert hashtable to original parameters
$paramHash.GetEnumerator() |
foreach -begin {$h = "@{"} -process {
 if ($_.Value -is [string]) {
    $v = "'$($_.value)'"
}
elseif ($_.Value -is [Boolean]) {
    $v = "`${0}" -f ($_.Value).ToString()
}
else {
    $v = $_.Value
}
$h+="$($_.key) = $v ;"
} -end {
$h+="}"
}
start-process -FilePath PowerShell.exe -ArgumentList "-nologo -noprofile -command &{`$p = $h ;copy @p ; start-sleep -seconds 2}"
}

The tricky part was getting the hashtable into the command scriptblock with all of the quoting and variable expansion issues. I found it easier to take the original parameter multi-line hashtable and recreate as a single entry with each key/value pair separated by a semi-colon. In the end $h looks like this:

@{PassThru = $True ;Recurse = $True ;Force = $True ;Destination = '\\jdh-nvnas\temp' ;Container = $True ;Path = 'C:\work' ;}

That made it easier to pass as an argument and then splat. I also included a brief delay to avoid any access violations. This worked pretty well although it doesn’t lend itself well to a pipelined expression. I have to know in advance what I want to copy. But I have an idea.
While the original Copy-Item doesn’t accept multiple locations for the Destination parameter, perhaps I can create my own. So I created a proxy version of Copy-Item and inserted my own core copy commands using multiple PowerShell sessions.

Function Copy-ItemtoMany {
[CmdletBinding(DefaultParameterSetName='Path', SupportsShouldProcess=$true, ConfirmImpact='Medium', SupportsTransactions=$true, HelpUri='')]
 param(
     [Parameter(ParameterSetName='Path', Mandatory=$true, Position=0)]
     [ValidateNotNullorEmpty()]
     [string[]]$Path,
     [Parameter(ParameterSetName='LiteralPath', Mandatory=$true)]
     [Alias('PSPath')]
     [ValidateNotNullorEmpty()]
     [string[]]$LiteralPath,
     [Parameter(Position=1,Mandatory=$true,HelpMessage="Enter the destination path")]
     [ValidateScript({
        Foreach ($item in $_) {
            If (Test-Path $item) {
                $True
            }
            else {
                Throw "Could not validate destination $item"
            }
        }
     })]
     [string[]]$Destination,
     [switch]$Container,
     [switch]$Force,
     [string]$Filter,
     [string[]]$Include,
     [string[]]$Exclude,
     [switch]$Recurse,
     [switch]$PassThru,
     [Parameter(ValueFromPipelineByPropertyName=$true)]
     [pscredential]
     [System.Management.Automation.CredentialAttribute()]$Credential
     )
 begin {
     Write-Verbose "Starting $($MyInvocation.Mycommand)"
     Write-Verbose ($PSBoundParameters | out-string)
     $global:test = $PSBoundParameters
 } #begin
 process {
 foreach ($item in $Destination) {
    $params = $PSBoundParameters
    $params.destination = $item
    Write-Verbose "copying from $Path to $item"
    #Convert PSBoundParameters back to a string so
    #it can be passed back to PowerShell.exe as a command argument.
    $PSBoundParameters.GetEnumerator() |
    foreach -begin {$h = "@{"} -process {
        Write-Verbose "analyzing $($_.key)"
        if ($_.Value -is [array]) {
            #$v = "'$($_.value)'"
            Write-Verbose "..array"
            $v = "'$($_.value -join "','")'"
        }
        elseif ($_.Value -is [string]) {
            Write-Verbose "..string"
            $v = "'$($_.value)'"
        }
        elseif (($_.Value -is [Boolean]) -OR ($_.Value -is [Switch])) {
            Write-Verbose "..boolean or switch"
            $v = "`${0}" -f ($_.Value).ToString()
        }
        else {
            Write-Verbose "..$($_.Value).GetType()"
            Write-verbose $_.key
            $v = $_.Value -join ","
        }
        $h+="$($_.key) = $v ;"
    } -end {
    $h+="}"
    }
    #launch a new PowerShell process and sleep for 2 seconds after completion
    Write-Verbose "LAUNCHING: powershell -nologo -noprofile -command &{`$params = $h ; Copy-Item @params ; start-sleep -seconds 2}"
    Start-Process PowerShell "-nologo -noprofile -command &{`$params = $h ; Copy-Item @params ; start-sleep -seconds 2}"
    #separate each process by 500ms to avoid any possible file access conflicts
    Start-Sleep -Milliseconds 500
 } #foreach
 } #Process
 end {
    Write-Verbose "Ending $($MyInvocation.Mycommand)"
 } #end
} #end function Copy-ItemtoMany

I made the Destination parameter mandatory and configured it to accept an array. Now I can do this:

Copy-ItemToMany -Path c:\work\*.zip -Destination $destA,$destB

I removed the pipeline binding attribute for the Path parameter for performance reasons. A command like this won’t work:

dir c:\work\*.xml | copy-itemtomany -Destination $destA,$destB


I can live with this solution for what I need to copy.
Looking forward, I should probably add a parameter to control the Window style, or I can do something in conjunction with –Passthru, so that if you want to see the results, then you can. However, because the copy process is running in separate PowerShell sessions, I can’t really direct the output to the original shell without resorting to some other PowerShell voodoo. But it is something to consider or perhaps something you’d like tackle.