PowerShell: Synchronizing a Folder (and Sub-Folders) Part 1

There are plenty of commercially available systems for synchronizing the contents of two folders (I’ve used Allway Sync a bit and it works well).  But what if you just want to quickly sync a folder (and sub-folders) to another location using PowerShell?

Ideally I’d want it to take a source folder and synchronize it to a target folder with any missing content being recreated.  If the target folder is already populated, I’d want to update any newer versions of the files and remove anything that has been deleted from the source.

The script (and a breakdown on how it works) follows after the line.

Update : I’ve revisited this script a few times with new additions and modifications.The latest full version of the script is here.  That post also includes links covering the other revisions to the script.

function Sync-Folder
{
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory=$True)]
        [string]$SourceFolder,
        [parameter(Mandatory=$True)]
        [string]$TargetFolder

    )
    function Sync-OneFolder
    {
        param
        (
            [parameter(Mandatory=$True)]
            [string]$SourceFolder,
            [parameter(Mandatory=$True)]
            [string]$TargetFolder

        )
        Write-Verbose "Source Folder : $SourceFolder"
        Write-Verbose "Target Folder : $TargetFolder"
        if (!(Test-Path -Path $TargetFolder -PathType Container))
        {
            Write-Verbose "Creating Folder : $($TargetFolder)"
            New-Item $TargetFolder -ItemType "Directory" > $null
        }

        $SourceList=gci $SourceFolder
        $TargetList=gci $TargetFolder
        $SourceFiles=$TargetFiles=@()
        $SourceFolders=$TargetFolders=@()
        $SourceFiles+=$SourceList | ? {$_.PSIsContainer -eq $False}
        $TargetFiles+=$TargetList | ? {$_.PSIsContainer -eq $False}
        $SourceFolders+=$SourceList | ? {$_.PSIsContainer -eq $True}
        $TargetFolders+=$TargetList | ? {$_.PSIsContainer -eq $True}
        $MissingFiles=Compare-Object $SourceFiles $TargetFiles -Property Name
        $MissingFolders=Compare-Object $SourceFolders $TargetFolders -Property Name
        foreach ($MissingFile in $MissingFiles)
        {
            if($MissingFile.SideIndicator -eq "<=")             {                 Write-Verbose "Copying missing file : $($TargetFolder+"\"+$MissingFile.Name)"                 Copy-Item ($SourceFolder+"\"+$MissingFile.Name) ($TargetFolder+"\"+$MissingFile.Name)             }elseif ($MissingFile.SideIndicator -eq "=>")
            {
                Write-Verbose "Removing file missing from Source : $($TargetFolder+"\"+$MissingFile.Name)"
                Remove-Item ($TargetFolder+"\"+$MissingFile.Name)
            }
        }
        foreach ($MissingFolder in $MissingFolders)
        {
            if ($MissingFolder.SideIndicator -eq "=>")
            {
                Write-Verbose "Removing folder  missing from Source : $($TargetFolder+"\"+$MissingFolder.Name)"
                Remove-Item ($TargetFolder+"\"+$MissingFolder.Name)
            }
        }
        foreach ($SourceFile in $SourceFiles)
        {
            if ($SourceFile.LastWriteTime -gt (gci ($TargetFolder+"\"+$SourceFile.Name)).LastWriteTime)
            {
                Write-Verbose "Copying updated file : $($TargetFolder+"\"+$SourceFile.Name)"
                Copy-Item ($SourceFolder+"\"+$SourceFile.Name) ($TargetFolder+"\"+$SourceFile.Name) -Force
            }
        }
        foreach($SingleFolder in $SourceFolders)
        {
            Sync-OneFolder $SingleFolder.FullName ($TargetFolder+"\"+$SingleFolder.Name)
        }
    }
        Sync-OneFolder $SourceFolder $TargetFolder

}

I’ll break the script down below;

function Sync-Folder
{
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory=$True)]
        [string]$SourceFolder,
        [parameter(Mandatory=$True)]
        [string]$TargetFolder

    )
    function Sync-OneFolder
    {
        param
        (
            [parameter(Mandatory=$True]
            [string]$SourceFolder,
            [parameter(Mandatory=$True)]
            [string]$TargetFolder

        )

The first part of the script declares the function and the two parameters I’m interested in; the SourceFolder to backup and the TargetFolder to copy to. Within that I’ve declared another function (Sync-OneFolder) that takes the same parameters. This is so I can call it recursively; I want to sync the files in one folder and then recursively call the same function for each other folder within the TargetFolder.

Note the use of [CmdletBinding()] at the beginning; that allows us to pass the -Verbose switch to the function when we call it and display any Write-Verbose statements we’ve defined; without it the script will run silently.

        Write-Verbose "Source Folder : $SourceFolder"
        Write-Verbose "Target Folder : $TargetFolder"
        if (!(Test-Path -Path $TargetFolder -PathType Container))
        {
            Write-Verbose "Creating Folder : $($TargetFolder)"
            New-Item $TargetFolder -ItemType "Directory" > $null
        }

        $SourceList=gci $SourceFolder
        $TargetList=gci $TargetFolder
        $SourceFiles=$TargetFiles=@()
        $SourceFolders=$TargetFolders=@()
        $SourceFiles+=$SourceList | ? {$_.PSIsContainer -eq $False}
        $TargetFiles+=$TargetList | ? {$_.PSIsContainer -eq $False}
        $SourceFolders+=$SourceList | ? {$_.PSIsContainer -eq $True}
        $TargetFolders+=$TargetList | ? {$_.PSIsContainer -eq $True}
        $MissingFiles=Compare-Object $SourceFiles $TargetFiles -Property Name
        $MissingFolders=Compare-Object $SourceFolders $TargetFolders -Property Name

Write-Verbose is used initially to show what source and target folders we’ve passed to the function. If TargetFolder doesn’t exist as a folder then we create it.

The following few definitions could probably defined in fewer statements with fewer variables but I tend to err on the side of clarity so I can remember what I was doing later on. What I want to define are variables that represent the files and folders in the source and target folders.

An interesting quirk worth mention is to do with the lines;

 $SourceFiles=$TargetFiles=@()
 $SourceFolders=$TargetFolders=@()

This is to cover for the case where either the SourceFolder or TargetFolder is empty. If that was the case, the Get-ChildItem command (gci) would return $null which would then be stored in the relevant variable. Later on, a $null in the Compare-Object command causes an exception. Instead we want the variables to be an empty array if the folder is empty (rather than $null). To do this I set the variables to an empty array first and then add the results of gci to them. Problem solved!

Last we set MissingFiles and MissingFolders to the results of a Compare-Object command using the Name property for comparison. This will show where the files exist (source or target).

        foreach ($MissingFile in $MissingFiles)
        {
            if($MissingFile.SideIndicator -eq "<=")             {                 Write-Verbose "Copying missing file : $($TargetFolder+"\"+$MissingFile.Name)"                 Copy-Item ($SourceFolder+"\"+$MissingFile.Name) ($TargetFolder+"\"+$MissingFile.Name)             }elseif ($MissingFile.SideIndicator -eq "=>")
            {
                Write-Verbose "Removing file missing from Source : $($TargetFolder+"\"+$MissingFile.Name)"
                Remove-Item ($TargetFolder+"\"+$MissingFile.Name)
            }
        }
        foreach ($MissingFolder in $MissingFolders)
        {
            if ($MissingFolder.SideIndicator -eq "=>")
            {
                Write-Verbose "Removing folder  missing from Source : $($TargetFolder+"\"+$MissingFolder.Name)"
                Remove-Item ($TargetFolder+"\"+$MissingFolder.Name)
            }
        }

Next I process the results of the two Compare-Object commands. In the results each item has a “SideIndicator” property. This shows where the criteria matches. As we performed Compare-Object [Source] [Target] -Property Name a SideIndicator of “<=” (‘pointing’ to the left) means the item exists in the source but not the target. So in this case we want to copy the source file to the target folder. If the opposite SideIndicator is there (a file exists in the target folder but not the source) we remove it from the target folder (we’re syncing a deletion).

We run the same logic against the list of folders where a source or target is missing with one change; we don’t create missing folders in the target. As we’re going to call this function recursively for each sub-folder we don’t need to; the first line in the function creates a missing target folder if necessary.

        foreach ($SourceFile in $SourceFiles)
        {
            if ($SourceFile.LastWriteTime -gt (gci ($TargetFolder+"\"+$SourceFile.Name)).LastWriteTime)
            {
                Write-Verbose "Copying updated file : $($TargetFolder+"\"+$SourceFile.Name)"
                Copy-Item ($SourceFolder+"\"+$SourceFile.Name) ($TargetFolder+"\"+$SourceFile.Name) -Force
            }
        }
        foreach($SingleFolder in $SourceFolders)
        {
            Sync-OneFolder $SingleFolder.FullName ($TargetFolder+"\"+$SingleFolder.Name)
        }
    }
    Sync-OneFolder $SourceFolder $TargetFolder
}

After running the previous piece of code we know that the source files all have an equivalent in the target folder so the last thing to do is to copy any files that are more recent in the source folder over old versions in the target folder. To do this we compare the LastWriteTime properties on the two files and then use Copy-Item with the -Force switch so that existing files are overwritten silently.

Once all the files are synchronized we recursively call the Sync-OneFolder function on all the folders within the source folder so an entire folder tree will be synchronized.
The final line starts the recursion process. So after running Sync-Folder it calls Sync-OneFolder within the script with the passed SourceFolder and TargetFolder to get the ball rolling.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: