Skip to content

Instantly share code, notes, and snippets.

@swbbl
Last active February 9, 2024 06:24
Show Gist options
  • Save swbbl/59f8825da3272b57c4a439fb50227f9a to your computer and use it in GitHub Desktop.
Save swbbl/59f8825da3272b57c4a439fb50227f9a to your computer and use it in GitHub Desktop.
Backup/Copy files and folders while remaining folder structure
# Place the file into the source root directory. All files will be recursively copied to target directory.
$ErrorActionPreference = 'STOP'
$sourceRootDirPath = $PSScriptRoot
# Set the target directory. Ensure that you don't select a parent directory.
# If you place the script to e.g. "C:\ThisIsMySource", you must provide "ThisIsMySourceDirectory" in the target directory path -> "D:\ThisIsMySourceDirectory"
# Don't use just "D:\"
$targetRootDirPath = 'E:\Games\Steam\steamapps\common\' + ([System.IO.DirectoryInfo]$sourceRootDirPath).Name
# if $useFileChecksum is $false, "LastWriteTimeUtc" and "Size" is used.
# WARNING: huge performance impact due file content reading and hashing for even already identical files. Use it only if really necessary.
$useFileChecksum = $false
$backupBeforeOverwrite = $false # includes handling of orphaned files, where $true means MOVE to backup dir and $false just remove
# backup all files recursively based from the sourceRootDirPath
$recursiveBackup = $true
# include system and hidden files. This will just set the -Force parameter for Get-ChildItem and Get-Item
$includeSystemFiles = $true
$logProgress = $true
function Get-LogTime([switch]$BasicFormat) {
$logTime = [datetime]::UtcNow.ToString('o')
if ($BasicFormat) {
# ISO 8601 Basic Format
$logTime -replace '[:-]'
} else {
# ISO 8601 Extended Format
$logTime
}
}
$fsDateTimeStamp = Get-LogTime -BasicFormat
$backupDirName = '_backup'
$backupRootDirPath = Join-Path -Path $targetRootDirPath -ChildPath $backupDirName
$backupDirPath = Join-Path -Path $backupRootDirPath -ChildPath $fsDateTimeStamp
$backupLogFilePath = Join-Path -Path $backupRootDirPath -ChildPath "$fsDateTimeStamp.log"
$logFormat = '{0} {1} "{2}" {3}'
if ($useFileChecksum) {
$logFormatFile = $logFormat + ' {4}:{5}'
} else {
$logFormatFile = $logFormat
}
trap {
# for all unhandled exceptions
$_
if ($logProgress) {
# log
$logFormat -f (Get-LogTime), '[ERROR.UNHANDLED]', $_, $_.ScriptStackTrace >> $backupLogFilePath
}
break
}
$scriptExecutionTimer = [System.Diagnostics.Stopwatch]::StartNew()
if ($targetRootDirPath -in $null, '') {
$targetRootDirPath = Read-Host -Prompt 'Please define the target root directory'
}
if (-not (Test-Path -LiteralPath $targetRootDirPath)) {
try {
[void] (New-Item -Path $targetRootDirPath -ItemType Directory -Force)
} catch {
Write-Error -ErrorRecord $_
return
}
}
if (($backupBeforeOverwrite -or $logProgress) -and -not (Test-Path -LiteralPath $backupRootDirPath)) {
try {
[void] (New-Item -Path $backupRootDirPath -ItemType Directory -Force)
} catch {
Write-Error -ErrorRecord $_
return
}
}
# target files in advance to figure out which files aren't necessary anymore. Will be moved to backup dir if flag is set.
$targetFiles = Get-ChildItem -LiteralPath $targetRootDirPath -File -Recurse:$recursiveBackup -Force:$includeSystemFiles
$orphanedTargetFilesLookup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
# Using Set-Location to easily use Resolve-Path with param -Relative (alternative: Push-Location w/ or w/o Pop-Location)
try {
Set-Location -LiteralPath $targetRootDirPath -ErrorAction Stop
} catch {
Write-Error -ErrorRecord $_
return
}
foreach ($targetFile in $targetFiles) {
$relativeFilePath = (Resolve-Path -LiteralPath $targetFile.FullName -Relative) -replace '^\.'
if ($targetFile.FullName -notlike "$backupRootDirPath\*") {
$null = $orphanedTargetFilesLookup.Add($relativeFilePath)
}
}
$sourceFiles = Get-ChildItem -LiteralPath $sourceRootDirPath -File -Recurse:$recursiveBackup -Force:$includeSystemFiles
# Using Set-Location to easily use Resolve-Path with param -Relative (alternative: Push-Location w/ or w/o Pop-Location)
try {
Set-Location -LiteralPath $sourceRootDirPath -ErrorAction Stop
} catch {
Write-Error -ErrorRecord $_
return
}
[uint64]$aggregatedSize = [System.Linq.Enumerable]::Sum([uint64[]]$sourceFiles.GetEnumerator().Length)
[uint64]$aggregatedSizeMb = $aggregatedSize / 1MB
[uint64]$sourceFilesCount = ([array]$sourceFiles).Count
$aggregatedSizeMbStringLength = $aggregatedSizeMb.ToString('#,##0.00').Length
$sourceFilesCountStringLength = $sourceFilesCount.Count.ToString('#,##0').Length
[uint64]$processedSize = 0
if ($logProgress) {
# log
"Src: $sourceRootDirPath" >> $backupLogFilePath
"Dst: $targetRootDirPath" >> $backupLogFilePath
'' >> $backupLogFilePath
"SrcCount: $sourceFilesCount" >> $backupLogFilePath
"SrcSize : $aggregatedSizeMb MB" >> $backupLogFilePath
'' >> $backupLogFilePath
"UseFileChecksum : $useFileChecksum" >> $backupLogFilePath
"BackupBeforeOverwrite: $backupBeforeOverwrite" >> $backupLogFilePath
"RecursiveBackup : $recursiveBackup" >> $backupLogFilePath
"IncludeSystemFiles : $includeSystemFiles" >> $backupLogFilePath
'' >> $backupLogFilePath
"Start: $(Get-LogTime)" >> $backupLogFilePath
}
#region Progress bar definition
#! https://github.com/PowerShell/PowerShell/issues/18848
#! https://github.com/PowerShell/PowerShell/issues/13005
$progressId = 0
$progressActivity = 'Copy new and modified files'
$progressCount = ([array]$sourceFiles).Count
$progressUpdateInterval = 0 # seconds (0 means for each file)
$progressItemSizeThreshold = 100MB
$progressCounter = 0 # init 0
$progressLastUpdateTime = [timespan]0 # init 0
$progressUpdateTimer = [System.Diagnostics.Stopwatch]::StartNew()
#endregion
foreach ($sourceFile in $sourceFiles) {
$relativeFilePath = (Resolve-Path -LiteralPath $sourceFile.FullName -Relative) -replace '^\.'
# remove target from lookup as there is a corresponding file in source
$null = $orphanedTargetFilesLookup.Remove($relativeFilePath)
#region Progress bar execution
$progressCounter++
# this condition would only update the progress after defined update interval or if it exceeds the defined threshold size
if (-not $progressUpdateInterval -or ($sourceFile.Length -ge $progressItemSizeThreshold) -or ($progressUpdateTimer.Elapsed.TotalSeconds - $progressLastUpdateTime.TotalSeconds -ge $progressUpdateInterval) -or ($progressLastUpdateTime.Ticks -eq 0)) {
<#
$fileSize = switch ($sourceFile.Length) {
{ $_ % 1KB -eq $_ } { '{0:#,##0.00} {1}' -f ($_), 'B' ; break }
{ $_ % 1MB -eq $_ } { '{0:#,##0.00} {1}' -f ($_ / 1KB), 'KB'; break }
{ $_ % 1GB -eq $_ } { '{0:#,##0.00} {1}' -f ($_ / 1MB), 'MB'; break }
{ $_ % 1TB -eq $_ } { '{0:#,##0.00} {1}' -f ($_ / 1GB), 'GB'; break }
{ $_ % 1PB -eq $_ } { '{0:#,##0.00} {1}' -f ($_ / 1TB), 'TB'; break }
}
#>
$num = $sourceFile.Length
$units = 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'
$format = '{0:N2} {1}'
if ($num -lt 0) {
$num*= -1
$format = $format.Insert(0, '-')
}
$pow = [math]::Min([double]$units.Count - 1, [math]::Floor([math]::Log($num, 1KB)))
if ($pow -in ([double]::NegativeInfinity, [double]::PositiveInfinity)) {
$pow = 0
}
$fileSize = $format -f ($num / [math]::Pow(1KB, $pow)), $units[$pow]
$itemProgressStatus = "{0,$sourceFilesCountStringLength} / {1} files" -f $progressCounter.ToString('#,##0'), $progressCount.ToString('#,##0')
$sizeProgressStatus = "{0,$aggregatedSizeMbStringLength} / {1} MB" -f ($processedSize / 1MB).ToString('#,##0.00'), ($aggregatedSize / 1MB).ToString('#,##0.00')
Write-Progress -Activity $progressActivity -Status $itemProgressStatus -PercentComplete ($progressCounter / $progressCount * 100) -Id $progressId
# PS6+ compatible (to show current file)
Write-Progress -Activity 'Progress by file size' -Status "$sizeProgressStatus - $relativeFilePath [$fileSize]" -PercentComplete ($processedSize / $aggregatedSize * 100) -Id ($progressId + 1) -ParentId $progressId
#Write-Progress -Activity 'Progress by file size' -CurrentOperation "Current File: $relativeFilePath [$fileSize]" -Status $sizeProgressStatus -PercentComplete ($processedSize / $aggregatedSize * 100) -Id ($progressId + 1) -ParentId $progressId
$progresslastUpdateTime = $progressUpdateTimer.Elapsed
}
# skip this script and any backup directories located in source directory
if (($sourceFile.FullName -eq $PSCommandPath) -or ($relativeFilePath -like "\$backupDirName\*")) {
if ($logProgress) {
# log
$logFormat -f (Get-LogTime), '[SKIP.EXCLUDE]', $relativeFilePath, $sourceFile.Length >> $backupLogFilePath
}
continue
}
$targetFilePath = Join-Path -Path $targetRootDirPath -ChildPath $relativeFilePath
$targetDirPath = Split-Path -Path $targetFilePath -Parent
try {
$targetFileIsIdentical = $false
$sourceFileChecksum = $targetFileChecksum = $null
# in case the source file already exists in target directory, compare and create a backup if necessary and defined
if (Test-Path -LiteralPath $targetFilePath -PathType Leaf) {
$targetFile = Get-Item -LiteralPath $targetFilePath -Force:$includeSystemFiles
if ($useFileChecksum) {
$sourceFileChecksum = Get-FileHash -LiteralPath $sourceFile.FullName -Algorithm SHA1
$targetFileChecksum = Get-FileHash -LiteralPath $targetFilePath -Algorithm SHA1
$targetFileIsIdentical = $sourceFileChecksum.Hash -eq $targetFileChecksum.Hash
} else {
# check whether source and target file are identical (based on LastWriteTimeUtc and Size)
$targetFileIsIdentical = ($sourceFile.LastWriteTimeUtc -eq $targetFile.LastWriteTimeUtc) -and ($sourceFile.Length -eq $targetFile.Length)
}
if ($backupBeforeOverwrite -and -not $targetFileIsIdentical) {
$backupFilePath = Join-Path -Path $backupDirPath -ChildPath $relativeFilePath
$backupFileDirPath = Split-Path -LiteralPath $backupFilePath
if (-not (Test-Path -LiteralPath $backupFileDirPath)) {
[void](New-Item -Path $backupFileDirPath -ItemType Directory -Force)
}
if ($logProgress) {
# log
$logFormatFile -f (Get-LogTime), '[BACKUP]', $relativeFilePath, $targetFile.Length, $targetFileChecksum.Algorithm, $targetFileChecksum.Hash >> $backupLogFilePath
}
Copy-Item -LiteralPath $targetFile.FullName -Destination $backupFilePath -Force
}
}
if (-not $targetFileIsIdentical) {
if (-not (Test-Path -LiteralPath $targetDirPath -PathType Container)) {
[void](New-Item -Path $targetDirPath -ItemType Directory -Force)
}
if ($logProgress) {
# log
$logFormatFile -f (Get-LogTime), '[COPY]', $relativeFilePath, $sourceFile.Length, $sourceFileChecksum.Algorithm, $sourceFileChecksum.Hash >> $backupLogFilePath
}
Copy-Item -LiteralPath $sourceFile.FullName -Destination $targetFilePath -Force
} else {
if ($logProgress) {
# log
$logFormatFile -f (Get-LogTime), '[SKIP.IDENTICAL]', $relativeFilePath, $sourceFile.Length, $sourceFileChecksum.Algorithm, $sourceFileChecksum.Hash >> $backupLogFilePath
}
}
} catch {
if ($logProgress) {
# log
$logFormat -f (Get-LogTime), '[ERROR]', $relativeFilePath, $_ >> $backupLogFilePath
}
Write-Error -ErrorRecord $_ -ErrorAction Continue
# return
}
$processedSize += $sourceFile.Length
}
#region Progress completed
Write-Progress -Activity $progressActivity -Completed -Id ($progressId + 1)
Write-Progress -Activity $progressActivity -Completed -Id $progressId
$progressUpdateTimer.Stop()
#region Progress bar definition
$progressId = 0
$progressActivity = 'Remove/move orphaned target files'
$progressCount = ([array]$sourceFiles).Count
$progressUpdateInterval = 0 # seconds (0 means for each file)
$progressItemSizeThreshold = 100MB
$progressCounter = 0 # init 0
$progressLastUpdateTime = [timespan]0 # init 0
$progressUpdateTimer = [System.Diagnostics.Stopwatch]::StartNew()
#endregion
foreach ($relativeFilePath in $orphanedTargetFilesLookup) {
$targetFilePath = Join-Path -Path $targetRootDirPath -ChildPath $relativeFilePath
$targetFile = Get-Item -LiteralPath $targetFilePath -Force:$includeSystemFiles
if ($backupBeforeOverwrite) {
$backupFilePath = Join-Path -Path $backupDirPath -ChildPath $relativeFilePath
$backupFileDirPath = Split-Path -LiteralPath $backupFilePath
if (-not (Test-Path -LiteralPath $backupFileDirPath)) {
[void](New-Item -Path $backupFileDirPath -ItemType Directory -Force)
}
if ($logProgress) {
# log
$logFormat -f (Get-LogTime), '[BACKUP.ORPHANED]', $relativeFilePath, $targetFile.Length >> $backupLogFilePath
$logFormat -f (Get-LogTime), '[REMOVE.ORPHANED]', $relativeFilePath, $targetFile.Length >> $backupLogFilePath
}
Move-Item -LiteralPath $targetFile.FullName -Destination $backupFilePath -Force
} else {
if ($logProgress) {
# log
$logFormat -f (Get-LogTime), '[REMOVE.ORPHANED]', $relativeFilePath, $targetFile.Length >> $backupLogFilePath
}
Remove-Item -LiteralPath $targetFile.FullName -Force
}
}
$scriptExecutionTimer.Stop()
if ($logProgress) {
# log
"End: $(Get-LogTime)" >> $backupLogFilePath
"`nDuration: $($scriptExecutionTimer.Elapsed.ToString('dd\.hh\:mm\:ss\.fff'))" >> $backupLogFilePath
}
#endregion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment