Skip to content

Commit

Permalink
Initial release.
Browse files Browse the repository at this point in the history
  • Loading branch information
rhymeswithmogul committed Apr 10, 2019
1 parent 057407a commit 3587f81
Showing 1 changed file with 180 additions and 0 deletions.
180 changes: 180 additions & 0 deletions PSTimeMachine.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<#
.NOTES
PSTimeMachine.ps1 - Version 1.0
(c) 2019 Colin Cogle <[email protected]>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
.SYNOPSIS
Creates versioned, deduplicated backups of a folder.
.DESCRIPTION
This script creates backups in the style of Apple's Time Machine by copying files to a destination, placing them into deduplicated subfolders based on the backup time.
.NOTES
The backup destination's filesystem must support hard links for deduplication to occur. Hard links are supported on NTFS, HFS+, APFS, and all UNIX and Linux filesystems. They are not supported on FAT, exFAT, or ReFS volumes; and may or may not be supported on SMB shares.
.INPUTS
Instead of specifying SourcePath as a parameter, it may also be specified via the pipeline.
.OUTPUTS
None.
.PARAMETER SourcePath
The file or folder to back up, recursively. This may also be specified via the pipeline.
.PARAMETER DestinationPath
The folder in which to place the backed-up files.
.PARAMETER FailIfOldBackupsAreMissing
If the destination folder does not exist, or if previous backups cannot be found inside it, terminate the backup immediately. This can be used to strictly check if a destination disk is available before trying to copy files to it.
.PARAMETER NoHardLinks
All files will be copied, rather than hard-linked, even if they have not changed since the last backup. Note that this will massively increase space usage on the destination disk. However, it may be useful or required for destinations that do not support hard links (like FAT, exFAT, ReFS, or network volumes).
.PARAMETER NoLogging
Do not create a log file inside the backup folder. By default, all output is redirected to that log file.
.PARAMETER NoStatistics
Suppress statistics after a successful backup. Statistics are not reported for copy-only backups.
.EXAMPLE
PS C:\> .\PSTimeMachine.ps1 -SourcePath C:\Users\jdoe -DestinationPath D:\Backups
#>

#Require -Version 5

[CmdletBinding()]
Param(
[Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)]
[Alias("Source", "Path")]
[String]$SourcePath,

[Parameter(Mandatory=$true, Position=1)]
[Alias("Destination", "Target")]
[String]$DestinationPath,

[Switch]$FailIfOldBackupsAreMissing = $false,

[Alias("CopyOnlyBackup")]
[Switch]$NoHardLinks = $false,

[Switch]$NoLogging = $false,

[Switch]$NoStatistics = $false
)

# We're going to create a folder to hold the backup, named for the current date and time.
# Format everything except the year with leading zeroes.
New-Variable -Option Constant -Name Today -Value (Get-Date)
New-Variable -Option Constant -Name FolderName -Value (("{0:yyyy}-{0:MM}-{0:dd}T{0:hh}-{0:mm}-{0:ss}" -f $Today) + ".inProgress")

# Start logging?
If ($NoLogging) {
$global:LogFile = (New-TemporaryFile)
Start-Transcript -Path (($global:LogFile).Name)
}

# Header
Write-Output "Starting backup job at $Today."
Write-Output " Current Folder = $(Get-Location)"
Write-Output " Source = $SourcePath"
Write-Output " Destination = $(Join-Path $DestinationPath ($FolderName -CReplace '\.inProgress'))"
Write-Output " Other Options = $(If ($FailIfOldBackupsAreMissing){'NoFailIfOldBackupsAreMissing'}) $(If ($NoHardLinks){'NoHardLinks'}) $(If ($NoLogging){'NoLogging'}) $(If($NoStatistics){'NoStatistics'})`n"

# Check to see if previous backups exist, if we are asked.
:SanityCheck While ($FailIfOldBackupsAreMissing) {
Get-ChildItem $DestinationPath | ForEach-Object {
Write-Debug "Checking to see if $($_.Name) is a backup folder..."
If ($_.Attributes -CMatch "Directory" ) {
Break SanityCheck
}
}
}

# Create the backup destination.
New-Item -Type Directory -Path (Join-Path $DestinationPath $FolderName) -Force | Out-Null

# Keep some statistics.
$bytesCopied = 0
$bytesTotal = 0

Try {
# Has hardlinking support been disabled? If so, just do a copy.
If ($NoHardLinks) {
Write-Verbose "Hard-linking is disabled at user request. Copying all files."
Copy-Item -Path (Join-Path $SourcePath "*") -Destination (Join-Path $DestinationPath $FolderName) -Recurse
}
Else {
# Look for old backups in the same destination.
$PreviousBackups = (Get-ChildItem -Attributes Directory -Path $DestinationPath -Exclude "*.inProgress" -ErrorAction SilentlyContinue `
| Where-Object {$_.Name -CMatch "\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(\.inProgress)?"} `
| Sort-Object -Descending CreationTime
)
If ($PreviousBackups.Count -eq 0) {
If ($FailIfOldBackupsAreMissing) {
Throw [System.Management.Automation.ItemNotFoundException] "No previous backups were found. Exiting at user request."
}
Write-Verbose "No previous backups exist. Creating an initial backup."
Copy-Item -Path (Join-Path $SourcePath "*") -Destination (Join-Path $DestinationPath $FolderName) -Recurse
}
Else {
# Get the most recent backup name.
$PreviousBackup = $PreviousBackups[0].Name

Get-ChildItem -Path "$SourcePath\" -Recurse -ErrorAction Stop | ForEach-Object {
$RelativeSourceItemPath = (($_.FullName) -Replace [regex]::Escape("$((Get-Item $SourcePath).FullName)"),"")

# Create directories, but compare files.
If ($_.Attributes -CMatch "Directory") {
Write-Debug "Creating folder $(Join-Path $DestinationPath $FolderName $RelativeSourceItemPath)"
New-Item -Type Directory -Path (Join-Path $DestinationPath $FolderName $RelativeSourceItemPath) | Out-Null
}
Else {
# Compare file sizes and dates to determine if something has changed.
$PreviousCopyOfFile = Get-Item -Path (Join-Path $DestinationPath $PreviousBackup $RelativeSourceItemPath)
If (($_.LastWriteTime -eq $PreviousCopyOfFile.LastWriteTime) -And ($_.Length -eq $PreviousCopyOfFile.Length)) {
# The file has not changed since the last backup. Create a hard link.
$DestinationHardlink = @{
ItemType = "HardLink"
Target = ($PreviousCopyOfFile.FullName)
Path = ($PreviousCopyOfFile | Split-Path -Parent) -Replace [regex]::Escape($PreviousBackup),$FolderName
Name = ($PreviousCopyOfFile.Name)
ErrorAction = "Stop"
}
Write-Verbose "Linking: $RelativeSourceItemPath"
New-Item @DestinationHardlink | Out-Null
}
Else {
Write-Verbose "Copying: $RelativeSourceItemPath"
Copy-Item -Path $_.FullName -Destination (Join-Path $DestinationPath $FolderName $RelativeSourceItemPath) -ErrorAction Stop
$bytesCopied += $_.Length
}
$bytesTotal += $_.Length
}
}
}
}
Write-Output "Backup completed at $(Get-Date)."
Rename-Item -Path (Join-Path $DestinationPath $FolderName) -NewName ($FolderName -CReplace '\.inProgress')
If (-Not $NoStatistics -and $bytesCopied -gt 0 -and $bytesTotal -gt 0) {
$pctCopied = $bytesCopied / $bytesTotal
Write-Output "Copied $($bytesCopied / 1048576) MB out of $($bytesTotal / 1048576) MB: $('{0:p0}' -f (1 - $pctCopied)) percent savings."
}
}
Catch {
Write-Error "Backup failed at $(Get-Date); removing in-progress backup!`nError: $($_.Exception.Message)"
Remove-Item -Recurse -Force (Join-Path $DestinationPath $FolderName)
}
Finally {
If ($NoLogging) {
Write-Verbose "Moving log file to backup destination."
Stop-Transcript
Write-Debug "Moving $($_.FullName) to $(Join-Path $DestinationPath 'PSTimeMachine.log')"
Move-Item -Path (($global:LogFile).Name) -Destination (Join-Path $DestinationPath $FolderName "PSTimeMachine.log") -ErrorAction Continue
}
}

0 comments on commit 3587f81

Please sign in to comment.