├── Build ├── build.depend.psd1 ├── build.ps1 ├── build.psake.ps1 └── deploy.psdeploy.ps1 ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Documentation ├── Compare-FileHash.md └── Copy-FileHash.md ├── HashCopy ├── HashCopy.psd1 ├── HashCopy.psm1 ├── Private │ └── Get-DestinationFilePath.ps1 └── Public │ ├── Compare-FileHash.ps1 │ └── Copy-FileHash.ps1 ├── PSScriptAnalyzerSettings.psd1 ├── README.md ├── Tests ├── Common │ ├── Help.Tests.ps1 │ ├── Manifest.Tests.ps1 │ └── PSSA.Tests.ps1 ├── Compare-FileHash.tests.ps1 └── Copy-FileHash.tests.ps1 ├── azure-pipelines.yml └── coverage.xml /Build/build.depend.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | # Defaults for all dependencies 3 | PSDependOptions = @{ 4 | Target = 'CurrentUser' 5 | Parameters = @{ 6 | # Use a local repository for offline support 7 | Repository = 'PSGallery' 8 | SkipPublisherCheck = $true 9 | } 10 | } 11 | 12 | # Dependency Management modules 13 | # PackageManagement = '1.2.2' 14 | # PowerShellGet = '2.0.1' 15 | 16 | # Common modules 17 | BuildHelpers = '2.0.1' 18 | Pester = '5.5.0' 19 | PlatyPS = '0.12.0' 20 | psake = '4.7.4' 21 | PSDeploy = '1.0.1' 22 | PSScriptAnalyzer = '1.17.1' 23 | # 'VMware.VimAutomation.Cloud' = '11.0.0.10379994' 24 | } 25 | -------------------------------------------------------------------------------- /Build/build.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [Parameter()] 4 | [System.String[]] 5 | $TaskList = 'Default', 6 | 7 | [Parameter()] 8 | [System.Collections.Hashtable] 9 | $Parameters, 10 | 11 | [Parameter()] 12 | [System.Collections.Hashtable] 13 | $Properties, 14 | 15 | [Parameter()] 16 | [Switch] 17 | $ResolveDependency 18 | ) 19 | 20 | Write-Output "`nSTARTED TASKS: $($TaskList -join ',')`n" 21 | 22 | Write-Output "`nPowerShell Version Information:" 23 | $PSVersionTable 24 | 25 | # Load dependencies 26 | if ($PSBoundParameters.Keys -contains 'ResolveDependency') { 27 | # Bootstrap environment 28 | Get-PackageProvider -Name 'NuGet' -ForceBootstrap | Out-Null 29 | 30 | # Install PSDepend module if it is not already installed 31 | if (-not (Get-Module -Name 'PSDepend' -ListAvailable)) { 32 | Write-Output "`nPSDepend is not yet installed...installing PSDepend now..." 33 | Install-Module -Name 'PSDepend' -Scope 'CurrentUser' -Force 34 | } else { 35 | Write-Output "`nPSDepend already installed...skipping." 36 | } 37 | 38 | # Install build dependencies 39 | $psdependencyConfigPath = Join-Path -Path $PSScriptRoot -ChildPath 'build.depend.psd1' 40 | Write-Output "Checking / resolving module dependencies from [$psdependencyConfigPath]..." 41 | Import-Module -Name 'PSDepend' 42 | $invokePSDependParams = @{ 43 | Path = $psdependencyConfigPath 44 | # Tags = 'Bootstrap' 45 | Import = $true 46 | Confirm = $false 47 | Install = $true 48 | # Verbose = $true 49 | } 50 | Invoke-PSDepend @invokePSDependParams 51 | 52 | # Remove ResolveDependency PSBoundParameter ready for passthru to PSake 53 | $PSBoundParameters.Remove('ResolveDependency') 54 | } else { 55 | Write-Host "Skipping dependency check...`n" -ForegroundColor 'Yellow' 56 | } 57 | 58 | 59 | # Init BuildHelpers 60 | Set-BuildEnvironment -Force 61 | 62 | 63 | # Execute PSake tasts 64 | $invokePsakeParams = @{ 65 | buildFile = (Join-Path -Path $env:BHProjectPath -ChildPath 'Build\build.psake.ps1') 66 | nologo = $true 67 | } 68 | Invoke-Psake @invokePsakeParams @PSBoundParameters 69 | 70 | Write-Output "`nFINISHED TASKS: $($TaskList -join ',')" 71 | 72 | exit ( [int](-not $psake.build_success) ) 73 | -------------------------------------------------------------------------------- /Build/build.psake.ps1: -------------------------------------------------------------------------------- 1 | # PSake makes variables declared here available in other scriptblocks 2 | Properties { 3 | $ProjectRoot = $ENV:BHProjectPath 4 | 5 | if (-not $ProjectRoot) { 6 | $ProjectRoot = "$PSScriptRoot/.." 7 | } 8 | 9 | $Timestamp = Get-Date -UFormat '%Y%m%d-%H%M%S' 10 | $PSVersion = $PSVersionTable.PSVersion.Major 11 | $lines = '----------------------------------------------------------------------' 12 | 13 | # Pester 14 | $TestScripts = Get-ChildItem "$ProjectRoot\Tests\*.Tests.ps1" -Recurse 15 | $TestFile = "Test-Unit_$($TimeStamp).xml" 16 | 17 | # Script Analyzer 18 | [ValidateSet('Error', 'Warning', 'Any', 'None')] 19 | $ScriptAnalysisFailBuildOnSeverityLevel = 'Error' 20 | $ScriptAnalyzerSettingsPath = "$ProjectRoot\PSScriptAnalyzerSettings.psd1" 21 | 22 | # Build 23 | $ArtifactFolder = Join-Path -Path $ProjectRoot -ChildPath 'Artifacts' 24 | 25 | # Staging 26 | $StagingFolder = Join-Path -Path $ProjectRoot -ChildPath 'Staging' 27 | $StagingModulePath = Join-Path -Path $StagingFolder -ChildPath $env:BHProjectName 28 | $StagingModuleManifestPath = Join-Path -Path $StagingModulePath -ChildPath "$($env:BHProjectName).psd1" 29 | 30 | # Documentation 31 | $DocumentationPath = Join-Path -Path $ProjectRoot -ChildPath 'Documentation' 32 | } 33 | 34 | 35 | # Define top-level tasks 36 | Task 'Default' -Depends 'Test' 37 | 38 | 39 | # Show build variables 40 | Task 'Init' { 41 | $lines 42 | 43 | Set-Location $ProjectRoot 44 | 45 | "Build System Details:" 46 | Get-Item ENV:BH* 47 | "`n" 48 | } 49 | 50 | 51 | # Clean the Artifact and Staging folders 52 | Task 'Clean' -Depends 'Init' { 53 | $lines 54 | 55 | $foldersToClean = @( 56 | $ArtifactFolder 57 | $StagingFolder 58 | ) 59 | 60 | # Remove folders 61 | foreach ($folderPath in $foldersToClean) { 62 | Remove-Item -Path $folderPath -Recurse -Force -ErrorAction 'SilentlyContinue' 63 | New-Item -Path $folderPath -ItemType 'Directory' -Force | Out-String | Write-Verbose 64 | } 65 | } 66 | 67 | 68 | # Create a single .psm1 module file containing all functions 69 | # Copy new module and other supporting files (Documentation / Examples) to Staging folder 70 | Task 'CombineFunctionsAndStage' -Depends 'Clean' { 71 | $lines 72 | 73 | # Create folders 74 | New-Item -Path $StagingFolder -ItemType 'Directory' -Force | Out-String | Write-Verbose 75 | New-Item -Path $StagingModulePath -ItemType 'Directory' -Force | Out-String | Write-Verbose 76 | 77 | # Get public and private function files 78 | $publicFunctions = @( Get-ChildItem -Path "$env:BHModulePath\Public\*.ps1" -Recurse -ErrorAction 'SilentlyContinue' ) 79 | $privateFunctions = @( Get-ChildItem -Path "$env:BHModulePath\Private\*.ps1" -Recurse -ErrorAction 'SilentlyContinue' ) 80 | 81 | # Combine functions into a single .psm1 module 82 | $combinedModulePath = Join-Path -Path $StagingModulePath -ChildPath "$($env:BHProjectName).psm1" 83 | @($publicFunctions + $privateFunctions) | Get-Content | Add-Content -Path $combinedModulePath 84 | 85 | # Copy other required folders and files if they exist 86 | $PathsToCopy = @( 87 | Join-Path -Path $ProjectRoot -ChildPath 'Documentation' 88 | Join-Path -Path $ProjectRoot -ChildPath 'Examples' 89 | Join-Path -Path $ProjectRoot -ChildPath 'CHANGELOG.md' 90 | Join-Path -Path $ProjectRoot -ChildPath 'README.md' 91 | ) 92 | 93 | foreach ($Path in $PathsToCopy) { 94 | if (Test-Path $Path) { 95 | Copy-Item -Path $Path -Destination $StagingFolder -Recurse 96 | } 97 | } 98 | 99 | # Copy existing manifest 100 | Copy-Item -Path $env:BHPSModuleManifest -Destination $StagingModulePath -Recurse 101 | } 102 | 103 | 104 | # Import new module 105 | Task 'ImportStagingModule' -Depends 'Init' { 106 | $lines 107 | Write-Output "Reloading staged module from path: [$StagingModulePath]`n" 108 | 109 | # Reload module 110 | if (Get-Module -Name $env:BHProjectName) { 111 | Remove-Module -Name $env:BHProjectName 112 | } 113 | # Global scope used for UpdateDocumentation (PlatyPS) 114 | Import-Module -Name $StagingModulePath -ErrorAction 'Stop' -Force -Global 115 | } 116 | 117 | 118 | # Run PSScriptAnalyzer against code to ensure quality and best practices are used 119 | Task 'Analyze' -Depends 'ImportStagingModule' { 120 | $lines 121 | Write-Output "Running PSScriptAnalyzer on path: [$StagingModulePath]`n" 122 | 123 | $Results = Invoke-ScriptAnalyzer -Path $StagingModulePath -Recurse -Settings $ScriptAnalyzerSettingsPath -Verbose:$VerbosePreference 124 | $Results | Select-Object 'RuleName', 'Severity', 'ScriptName', 'Line', 'Message' | Format-List 125 | 126 | switch ($ScriptAnalysisFailBuildOnSeverityLevel) { 127 | 'None' { 128 | return 129 | } 130 | 'Error' { 131 | Assert -conditionToCheck ( 132 | ($Results | Where-Object 'Severity' -eq 'Error').Count -eq 0 133 | ) -failureMessage 'One or more ScriptAnalyzer errors were found. Build cannot continue!' 134 | } 135 | 'Warning' { 136 | Assert -conditionToCheck ( 137 | ($Results | Where-Object { 138 | $_.Severity -eq 'Warning' -or $_.Severity -eq 'Error' 139 | }).Count -eq 0) -failureMessage 'One or more ScriptAnalyzer warnings were found. Build cannot continue!' 140 | } 141 | default { 142 | Assert -conditionToCheck ($analysisResult.Count -eq 0) -failureMessage 'One or more ScriptAnalyzer issues were found. Build cannot continue!' 143 | } 144 | } 145 | } 146 | 147 | 148 | # Run Pester tests 149 | # Unit tests: verify inputs / outputs / expected execution path 150 | # Misc tests: verify manifest data, check comment-based help exists 151 | Task 'Test' -Depends 'ImportStagingModule' { 152 | $lines 153 | 154 | # Gather test results. Store them in a variable and file 155 | $CodeFiles = (Get-ChildItem $ENV:BHModulePath -Recurse -Include '*.ps1').FullName 156 | $TestFilePath = Join-Path -Path $ArtifactFolder -ChildPath $TestFile 157 | $TestResults = Invoke-Pester -Script $TestScripts -PassThru -CodeCoverage $CodeFiles -OutputFormat 'NUnitXml' -OutputFile $TestFilePath -PesterOption @{IncludeVSCodeMarker = $true } 158 | 159 | # Fail build if any tests fail 160 | if ($TestResults.FailedCount -gt 0) { 161 | Write-Error "Failed '$($TestResults.FailedCount)' tests, build failed" 162 | } 163 | 164 | #Update readme.md with Code Coverage result 165 | $CoveragePercent = [int]$TestResults.CodeCoverage.CoveragePercent 166 | 167 | Set-ShieldsIoBadge -Path (Join-Path $ProjectRoot 'README.md') -Subject 'coverage' -Status $CoveragePercent -AsPercentage 168 | } 169 | 170 | 171 | # Create new Documentation markdown files from comment-based help 172 | Task 'UpdateDocumentation' -Depends 'ImportStagingModule' { 173 | $lines 174 | Write-Output "Updating Markdown help in Staging folder: [$DocumentationPath]`n" 175 | 176 | If (Test-Path $DocumentationPath) { 177 | Remove-Item -Path $DocumentationPath -Recurse -Force -ErrorAction 'SilentlyContinue' 178 | Start-Sleep -Seconds 5 179 | } 180 | 181 | # Cleanup 182 | New-Item -Path $DocumentationPath -ItemType 'Directory' | Out-Null 183 | 184 | # Create new Documentation markdown files 185 | $platyPSParams = @{ 186 | Module = $env:BHProjectName 187 | OutputFolder = $DocumentationPath 188 | NoMetadata = $true 189 | } 190 | New-MarkdownHelp @platyPSParams -ErrorAction 'SilentlyContinue' -Verbose | Out-Null 191 | } 192 | 193 | 194 | # Create a versioned zip file of all staged files 195 | # NOTE: Admin Rights are needed if you run this locally 196 | Task 'CreateBuildArtifact' -Depends 'Init' { 197 | $lines 198 | 199 | # Create /Release folder 200 | New-Item -Path $ArtifactFolder -ItemType 'Directory' -Force | Out-String | Write-Verbose 201 | 202 | # Get current manifest version 203 | try { 204 | $manifest = Test-ModuleManifest -Path $StagingModuleManifestPath -ErrorAction 'Stop' 205 | [Version]$manifestVersion = $manifest.Version 206 | 207 | } 208 | catch { 209 | throw "Could not get manifest version from [$StagingModuleManifestPath]" 210 | } 211 | 212 | # Create zip file 213 | try { 214 | $releaseFilename = "$($env:BHProjectName)-v$($manifestVersion.ToString()).zip" 215 | $releasePath = Join-Path -Path $ArtifactFolder -ChildPath $releaseFilename 216 | Write-Host "Creating release artifact [$releasePath] using manifest version [$manifestVersion]" -ForegroundColor 'Yellow' 217 | Compress-Archive -Path "$StagingFolder/*" -DestinationPath $releasePath -Force -Verbose -ErrorAction 'Stop' 218 | } 219 | catch { 220 | throw "Could not create release artifact [$releasePath] using manifest version [$manifestVersion]" 221 | } 222 | 223 | Write-Output "`nFINISHED: Release artifact creation." 224 | } 225 | 226 | Task 'Deploy' -Depends 'Init' { 227 | $lines 228 | 229 | # Load the module, read the exported functions, update the psd1 FunctionsToExport 230 | Set-ModuleFunctions -Name $env:BHPSModuleManifest 231 | 232 | # Bump the module version 233 | try { 234 | $Version = Get-NextPSGalleryVersion -Name $env:BHProjectName -ErrorAction 'Stop' 235 | Update-Metadata -Path $env:BHPSModuleManifest -PropertyName 'ModuleVersion' -Value $Version -ErrorAction 'Stop' 236 | } 237 | catch { 238 | throw "Failed to update version for '$env:BHProjectName': $_.`n" 239 | } 240 | 241 | if (Get-Item "$ProjectRoot/CHANGELOG.md") { 242 | 243 | $ChangeLog = Get-Content "$ProjectRoot/CHANGELOG.md" 244 | 245 | if ($ChangeLog -contains '## !Deploy') { 246 | 247 | $Params = @{ 248 | Path = "$ProjectRoot/Build/deploy.psdeploy.ps1" 249 | Force = $true 250 | Recurse = $false 251 | } 252 | 253 | Invoke-PSDeploy @Verbose @Params 254 | 255 | # Update ChangeLog with deployment version and date 256 | $ChangeLog = $ChangeLog -replace '## !Deploy', "## [$Version] - $(Get-Date -Format 'yyyy-MM-dd')" 257 | Set-Content -Path "$ProjectRoot/CHANGELOG.md" -Value $ChangeLog 258 | } 259 | else { 260 | Write-Host 'CHANGELOG.md did not contain ## !Deploy. Skipping deployment.' 261 | } 262 | } 263 | else { 264 | Write-Host "$ProjectRoot/CHANGELOG.md not found. Skipping deployment." 265 | } 266 | } 267 | 268 | Task 'Commit' -Depends 'Init' { 269 | $lines 270 | 271 | Set-Location $ProjectRoot 272 | $Module = $env:BHProjectName 273 | 274 | git --version 275 | git config --global user.email "build@azuredevops.com" 276 | git config --global user.name "AzureDevOps" 277 | git checkout $env:BUILD_SOURCEBRANCHNAME 278 | git add Documentation/*.md 279 | git add README.md 280 | git add CHANGELOG.md 281 | git commit -m "[skip ci] AzureDevOps Build $($env:BUILD_BUILDID)" 282 | git push 283 | } 284 | -------------------------------------------------------------------------------- /Build/deploy.psdeploy.ps1: -------------------------------------------------------------------------------- 1 | # Config file for PSDeploy 2 | # Set-BuildEnvironment from BuildHelpers module has populated ENV:BHModulePath and related variables 3 | # Publish to gallery with a few restrictions 4 | if ($StagingModulePath) { 5 | $ModuleSourcePath = $StagingModulePath 6 | } 7 | else { 8 | $ModuleSourcePath = $env:BHPSModulePath 9 | } 10 | 11 | if ( 12 | $ModuleSourcePath -and 13 | $env:BHBuildSystem -ne 'Unknown' -and 14 | $env:BHBranchName -eq "master" -and 15 | $ENV:NugetApiKey 16 | ) { 17 | Deploy Module { 18 | By PSGalleryModule { 19 | FromSource $ModuleSourcePath 20 | To PSGallery 21 | WithOptions @{ 22 | ApiKey = $ENV:NugetApiKey 23 | } 24 | } 25 | } 26 | } else { 27 | "Skipping deployment: To deploy, ensure that...`n" + 28 | "`t* You are in a known build system (Current: $ENV:BHBuildSystem)`n" + 29 | "`t* You are committing to the master branch (Current: $ENV:BHBranchName) `n" + 30 | "`t* You have access to the Nuget API key" | 31 | Write-Host 32 | } 33 | 34 | # Publish to AppVeyor if we're in AppVeyor 35 | if ($env:BHPSModulePath -and $env:BHBuildSystem -eq 'AppVeyor') { 36 | Deploy DeveloperBuild { 37 | By AppVeyorModule { 38 | FromSource $ModuleSourcePath 39 | To AppVeyor 40 | WithOptions @{ 41 | Version = $env:APPVEYOR_BUILD_VERSION 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.0.57] - 2023-07-02 4 | 5 | * [Feature] `Compare-FileHash` now has the same `-Exclude` parameter that was added to `Copy-FileHash` for excluding one or more files from the comparison. 6 | 7 | ## [1.0.56] - 2023-06-28 8 | 9 | * [Feature] `Copy-FileHash` now has an `-Exclude` parameter that can be used to exclude one or more files from being copied. Thanks [@shayki5](https://github.com/shayki5)! 10 | 11 | ## [1.0.55] - 2020-02-25 12 | 13 | * [Feature] `Copy-FileHash` now has a `-Mirror` parameter that can be used to remove any files from the Destination folder that are not in the Source path ([#5](https://github.com/markwragg/PowerShell-HashCopy/issues/5)). This has had limited testing so use with caution, always check `-WhatIf` first. 14 | 15 | ## [1.0.54] - 2020-02-21 16 | 17 | * [Feature] `Copy-FileHash` can now accept an array of file paths rather than just directory paths. Thanks [@Marc05](https://github.com/Marc05)! 18 | 19 | ## [1.0.53] - 2019-09-09 20 | 21 | * Testing new deployment pipeline. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Guidelines 4 | 5 | Contributions to this module are welcomed. Please consider the following guidance when making a contribution: 6 | 7 | - Create an Issue for any features/bugs/questions and (if you submit a code change) reference this via your Pull Request. 8 | - In general, this repository follows the guidance in the PowerShell Best Practices and Style Guide here: https://github.com/PoshCode/PowerShellPracticeAndStyle. In particular: 9 | - Use full cmdlet names 10 | - Be consistent with the existing code layout style (this is Stroustrup and its the default behaviour of the code auto-formatter for PowerShell in VSCode which I encourage you to use). 11 | - Function names should follow the Verb-Noun structure (even Private functions). 12 | - Where possible please create a Pester test to cover any code changes you make and/or modify existing tests. 13 | - Please ensure you update the comment-based help text for any new parameters you add as well as add new examples where it may be useful. 14 | 15 | ## Deployment 16 | 17 | This module uses Azure Pipelines for CI/CD. When a version of the code is ready for deployment, the following change needs to be committed in order for a new version to be deployed to the PowerShell Gallery: 18 | 19 | - Update [CHANGELOG.md](CHANGELOG.md) to have a new section, titled `## !Deploy` followed by a list of changes for the new version. 20 | 21 | As part of the CI/CD pipeline the CHANGELOG.md file will then be automatically updated with the date and version of the module deployed, replacing the `!Deploy` text. 22 | 23 | Note deployments only occur from the Master branch. 24 | -------------------------------------------------------------------------------- /Documentation/Compare-FileHash.md: -------------------------------------------------------------------------------- 1 | # Compare-FileHash 2 | 3 | ## SYNOPSIS 4 | Compares files from one location to another based on determining change via computed hash value. 5 | 6 | ## SYNTAX 7 | 8 | ### Path 9 | ``` 10 | Compare-FileHash -Path -Destination [-Algorithm ] [-Exclude ] [-Recurse] 11 | [] 12 | ``` 13 | 14 | ### LiteralPath 15 | ``` 16 | Compare-FileHash -LiteralPath -Destination [-Algorithm ] [-Exclude ] 17 | [-Recurse] [] 18 | ``` 19 | 20 | ## DESCRIPTION 21 | The Compare-FileHash cmdlet uses the Get-FileHash cmdlet to compute the hash value of one or more files and then returns any changed 22 | and new files from the specified source path. 23 | If you use the -Recurse parameter the cmdlet will synchronise a full directory 24 | tree, preserving the structure and creating any missing directories in the destination path as required. 25 | 26 | The purpose of this cmdlet is to compare specific file changes between two paths in situations where you cannot rely on the modified 27 | date of the files to determine if a file has changed. 28 | This can occur in situations where file modified dates have been changed, such 29 | as when cloning a set of files from a source control system. 30 | 31 | ## EXAMPLES 32 | 33 | ### EXAMPLE 1 34 | ``` 35 | Compare-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse 36 | ``` 37 | 38 | Compares the files between the two trees and returns any where they have different contents as determined via hash value comparison. 39 | 40 | ## PARAMETERS 41 | 42 | ### -Path 43 | The path to the source file/s or folder/s to copy any new or changed files from. 44 | 45 | ```yaml 46 | Type: String[] 47 | Parameter Sets: Path 48 | Aliases: 49 | 50 | Required: True 51 | Position: Named 52 | Default value: None 53 | Accept pipeline input: True (ByPropertyName, ByValue) 54 | Accept wildcard characters: False 55 | ``` 56 | 57 | ### -LiteralPath 58 | The literal path to the source file/s or folder/s to copy any new or changed files from. 59 | Unlike the Path parameter, the value of 60 | LiteralPath is used exactly as it is typed. 61 | No characters are interpreted as wildcards. 62 | 63 | ```yaml 64 | Type: String[] 65 | Parameter Sets: LiteralPath 66 | Aliases: 67 | 68 | Required: True 69 | Position: Named 70 | Default value: None 71 | Accept pipeline input: True (ByPropertyName) 72 | Accept wildcard characters: False 73 | ``` 74 | 75 | ### -Destination 76 | The Destination folder to compare to -Path or -LiteralPath and return any changed or new files. 77 | 78 | ```yaml 79 | Type: String 80 | Parameter Sets: (All) 81 | Aliases: 82 | 83 | Required: True 84 | Position: Named 85 | Default value: None 86 | Accept pipeline input: False 87 | Accept wildcard characters: False 88 | ``` 89 | 90 | ### -Algorithm 91 | Specifies the cryptographic hash function to use for computing the hash value of the contents of the specified file. 92 | A cryptographic 93 | hash function includes the property that it is not possible to find two distinct inputs that generate the same hash values. 94 | Hash 95 | functions are commonly used with digital signatures and for data integrity. 96 | The acceptable values for this parameter are: 97 | 98 | SHA1 | SHA256 | SHA384 | SHA512 | MACTripleDES | MD5 | RIPEMD160 99 | 100 | If no value is specified, or if the parameter is omitted, the default value is SHA256. 101 | 102 | ```yaml 103 | Type: String 104 | Parameter Sets: (All) 105 | Aliases: 106 | 107 | Required: False 108 | Position: Named 109 | Default value: SHA256 110 | Accept pipeline input: False 111 | Accept wildcard characters: False 112 | ``` 113 | 114 | ### -Exclude 115 | Exclude one or more files from being compared. 116 | 117 | ```yaml 118 | Type: String[] 119 | Parameter Sets: (All) 120 | Aliases: 121 | 122 | Required: False 123 | Position: Named 124 | Default value: None 125 | Accept pipeline input: False 126 | Accept wildcard characters: False 127 | ``` 128 | 129 | ### -Recurse 130 | Indicates that this cmdlet performs a recursive comparison. 131 | 132 | ```yaml 133 | Type: SwitchParameter 134 | Parameter Sets: (All) 135 | Aliases: 136 | 137 | Required: False 138 | Position: Named 139 | Default value: False 140 | Accept pipeline input: False 141 | Accept wildcard characters: False 142 | ``` 143 | 144 | ### CommonParameters 145 | This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. 146 | For more information, see about_CommonParameters (http://go.microsoft.com/fwlink/?LinkID=113216). 147 | 148 | ## INPUTS 149 | 150 | ## OUTPUTS 151 | 152 | ## NOTES 153 | 154 | ## RELATED LINKS 155 | -------------------------------------------------------------------------------- /Documentation/Copy-FileHash.md: -------------------------------------------------------------------------------- 1 | # Copy-FileHash 2 | 3 | ## SYNOPSIS 4 | Copies files from one location to another based on determining change via computed hash value. 5 | 6 | ## SYNTAX 7 | 8 | ### Path 9 | ``` 10 | Copy-FileHash -Path -Destination [-Algorithm ] [-Exclude ] [-PassThru] 11 | [-Recurse] [-Mirror] [-Force] [-WhatIf] [-Confirm] [] 12 | ``` 13 | 14 | ### LiteralPath 15 | ``` 16 | Copy-FileHash -LiteralPath -Destination [-Algorithm ] [-Exclude ] 17 | [-PassThru] [-Recurse] [-Mirror] [-Force] [-WhatIf] [-Confirm] [] 18 | ``` 19 | 20 | ## DESCRIPTION 21 | The Copy-FileHash cmdlet uses the Get-FileHash cmdlet to compute the hash value of one or more files and then copies any changed 22 | and new files to the specified destination path. 23 | If you use the -Recurse parameter the cmdlet will synchronise a full directory 24 | tree, preserving the structure and creating any missing directories in the destination path as required. 25 | 26 | The purpose of this cmdlet is to copy specific file changes between two paths in situations where you cannot rely on the modified 27 | date of the files to determine if a file has changed. 28 | This can occur in situations where file modified dates have been changed, such 29 | as when cloning a set of files from a source control system. 30 | 31 | ## EXAMPLES 32 | 33 | ### EXAMPLE 1 34 | ``` 35 | Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse 36 | ``` 37 | 38 | Compares the files between the two trees and replaces in the destination any where they have different contents as determined 39 | via hash value comparison. 40 | 41 | ## PARAMETERS 42 | 43 | ### -Path 44 | The path to the source file/s or folder/s to copy any new or changed files from. 45 | 46 | ```yaml 47 | Type: String[] 48 | Parameter Sets: Path 49 | Aliases: 50 | 51 | Required: True 52 | Position: Named 53 | Default value: None 54 | Accept pipeline input: True (ByPropertyName, ByValue) 55 | Accept wildcard characters: False 56 | ``` 57 | 58 | ### -LiteralPath 59 | The literal path to the source file/s or folder/s to copy any new or changed files from. 60 | Unlike the Path parameter, the value of 61 | LiteralPath is used exactly as it is typed. 62 | No characters are interpreted as wildcards. 63 | 64 | ```yaml 65 | Type: String[] 66 | Parameter Sets: LiteralPath 67 | Aliases: 68 | 69 | Required: True 70 | Position: Named 71 | Default value: None 72 | Accept pipeline input: True (ByPropertyName) 73 | Accept wildcard characters: False 74 | ``` 75 | 76 | ### -Destination 77 | The Destination folder to compare to -Path and overwrite with any changed or new files from -Path. 78 | If the folder does not exist 79 | It will be created. 80 | 81 | ```yaml 82 | Type: String 83 | Parameter Sets: (All) 84 | Aliases: 85 | 86 | Required: True 87 | Position: Named 88 | Default value: None 89 | Accept pipeline input: False 90 | Accept wildcard characters: False 91 | ``` 92 | 93 | ### -Algorithm 94 | Specifies the cryptographic hash function to use for computing the hash value of the contents of the specified file. 95 | A cryptographic 96 | hash function includes the property that it is not possible to find two distinct inputs that generate the same hash values. 97 | Hash 98 | functions are commonly used with digital signatures and for data integrity. 99 | The acceptable values for this parameter are: 100 | 101 | SHA1 | SHA256 | SHA384 | SHA512 | MACTripleDES | MD5 | RIPEMD160 102 | 103 | If no value is specified, or if the parameter is omitted, the default value is SHA256. 104 | 105 | ```yaml 106 | Type: String 107 | Parameter Sets: (All) 108 | Aliases: 109 | 110 | Required: False 111 | Position: Named 112 | Default value: SHA256 113 | Accept pipeline input: False 114 | Accept wildcard characters: False 115 | ``` 116 | 117 | ### -Exclude 118 | Exclude one or more files from being copied. 119 | 120 | ```yaml 121 | Type: String[] 122 | Parameter Sets: (All) 123 | Aliases: 124 | 125 | Required: False 126 | Position: Named 127 | Default value: None 128 | Accept pipeline input: False 129 | Accept wildcard characters: False 130 | ``` 131 | 132 | ### -PassThru 133 | Returns the output of the file copy as an object. 134 | By default, this cmdlet does not generate any output. 135 | 136 | ```yaml 137 | Type: SwitchParameter 138 | Parameter Sets: (All) 139 | Aliases: 140 | 141 | Required: False 142 | Position: Named 143 | Default value: False 144 | Accept pipeline input: False 145 | Accept wildcard characters: False 146 | ``` 147 | 148 | ### -Recurse 149 | Indicates that this cmdlet performs a recursive copy. 150 | 151 | ```yaml 152 | Type: SwitchParameter 153 | Parameter Sets: (All) 154 | Aliases: 155 | 156 | Required: False 157 | Position: Named 158 | Default value: False 159 | Accept pipeline input: False 160 | Accept wildcard characters: False 161 | ``` 162 | 163 | ### -Mirror 164 | Use to remove files from the Destination path that are no longer in any of the Source paths. 165 | 166 | ```yaml 167 | Type: SwitchParameter 168 | Parameter Sets: (All) 169 | Aliases: 170 | 171 | Required: False 172 | Position: Named 173 | Default value: False 174 | Accept pipeline input: False 175 | Accept wildcard characters: False 176 | ``` 177 | 178 | ### -Force 179 | Indicates that this cmdlet will copy items that cannot otherwise be changed, such as copying over a read-only file or alias. 180 | 181 | ```yaml 182 | Type: SwitchParameter 183 | Parameter Sets: (All) 184 | Aliases: 185 | 186 | Required: False 187 | Position: Named 188 | Default value: False 189 | Accept pipeline input: False 190 | Accept wildcard characters: False 191 | ``` 192 | 193 | ### -WhatIf 194 | Shows what would happen if the cmdlet runs. 195 | The cmdlet is not run. 196 | 197 | ```yaml 198 | Type: SwitchParameter 199 | Parameter Sets: (All) 200 | Aliases: wi 201 | 202 | Required: False 203 | Position: Named 204 | Default value: None 205 | Accept pipeline input: False 206 | Accept wildcard characters: False 207 | ``` 208 | 209 | ### -Confirm 210 | Prompts you for confirmation before running the cmdlet. 211 | 212 | ```yaml 213 | Type: SwitchParameter 214 | Parameter Sets: (All) 215 | Aliases: cf 216 | 217 | Required: False 218 | Position: Named 219 | Default value: None 220 | Accept pipeline input: False 221 | Accept wildcard characters: False 222 | ``` 223 | 224 | ### CommonParameters 225 | This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. 226 | For more information, see about_CommonParameters (http://go.microsoft.com/fwlink/?LinkID=113216). 227 | 228 | ## INPUTS 229 | 230 | ## OUTPUTS 231 | 232 | ## NOTES 233 | 234 | ## RELATED LINKS 235 | -------------------------------------------------------------------------------- /HashCopy/HashCopy.psd1: -------------------------------------------------------------------------------- 1 | # 2 | # Module manifest for module 'PSGet_HashCopy' 3 | # 4 | # Generated by: Mark Wragg 5 | # 6 | # Generated on: 9/9/2019 7 | # 8 | 9 | @{ 10 | 11 | # Script module or binary module file associated with this manifest. 12 | RootModule = 'HashCopy.psm1' 13 | 14 | # Version number of this module. 15 | ModuleVersion = '1.0.57' 16 | 17 | # Supported PSEditions 18 | # CompatiblePSEditions = @() 19 | 20 | # ID used to uniquely identify this module 21 | GUID = '0a6e0c6e-1eb3-4c5e-aa2f-d88339b8a7bc' 22 | 23 | # Author of this module 24 | Author = 'Mark Wragg' 25 | 26 | # Company or vendor of this module 27 | CompanyName = 'wragg.io' 28 | 29 | # Copyright statement for this module 30 | Copyright = '(c) 2018 Mark.Wragg. All rights reserved.' 31 | 32 | # Description of the functionality provided by this module 33 | Description = 'A module for cmdlets related to performing copy operations based on computed hash values.' 34 | 35 | # Minimum version of the Windows PowerShell engine required by this module 36 | # PowerShellVersion = '' 37 | 38 | # Name of the Windows PowerShell host required by this module 39 | # PowerShellHostName = '' 40 | 41 | # Minimum version of the Windows PowerShell host required by this module 42 | # PowerShellHostVersion = '' 43 | 44 | # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 45 | # DotNetFrameworkVersion = '' 46 | 47 | # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 48 | # CLRVersion = '' 49 | 50 | # Processor architecture (None, X86, Amd64) required by this module 51 | # ProcessorArchitecture = '' 52 | 53 | # Modules that must be imported into the global environment prior to importing this module 54 | # RequiredModules = @() 55 | 56 | # Assemblies that must be loaded prior to importing this module 57 | # RequiredAssemblies = @() 58 | 59 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 60 | # ScriptsToProcess = @() 61 | 62 | # Type files (.ps1xml) to be loaded when importing this module 63 | # TypesToProcess = @() 64 | 65 | # Format files (.ps1xml) to be loaded when importing this module 66 | # FormatsToProcess = @() 67 | 68 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 69 | # NestedModules = @() 70 | 71 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 72 | FunctionsToExport = 'Compare-FileHash', 'Copy-FileHash' 73 | 74 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 75 | CmdletsToExport = @() 76 | 77 | # Variables to export from this module 78 | # VariablesToExport = @() 79 | 80 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 81 | AliasesToExport = @() 82 | 83 | # DSC resources to export from this module 84 | # DscResourcesToExport = @() 85 | 86 | # List of all modules packaged with this module 87 | # ModuleList = @() 88 | 89 | # List of all files packaged with this module 90 | # FileList = @() 91 | 92 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 93 | PrivateData = @{ 94 | 95 | PSData = @{ 96 | 97 | # Tags applied to this module. These help with module discovery in online galleries. 98 | Tags = 'File','Copy','Hash','Sync','Compare' 99 | 100 | # A URL to the license for this module. 101 | # LicenseUri = '' 102 | 103 | # A URL to the main website for this project. 104 | ProjectUri = 'https://github.com/markwragg/Powershell-HashCopy' 105 | 106 | # A URL to an icon representing this module. 107 | # IconUri = '' 108 | 109 | # ReleaseNotes of this module 110 | ReleaseNotes = 'https://github.com/markwragg/Powershell-HashCopy/blob/master/README.md' 111 | 112 | # Prerelease string of this module 113 | # Prerelease = '' 114 | 115 | # Flag to indicate whether the module requires explicit user acceptance for install/update 116 | # RequireLicenseAcceptance = $false 117 | 118 | # External dependent modules of this module 119 | # ExternalModuleDependencies = @() 120 | 121 | } # End of PSData hashtable 122 | 123 | } # End of PrivateData hashtable 124 | 125 | # HelpInfo URI of this module 126 | # HelpInfoURI = '' 127 | 128 | # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. 129 | # DefaultCommandPrefix = '' 130 | 131 | } 132 | 133 | -------------------------------------------------------------------------------- /HashCopy/HashCopy.psm1: -------------------------------------------------------------------------------- 1 | $Public = @( Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -Recurse ) 2 | $Private = @( Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -Recurse ) 3 | 4 | @($Private + $Public) | ForEach-Object { 5 | try { 6 | . $_.FullName 7 | } 8 | catch { 9 | Write-Error -Message "Failed to import function $($_.FullName): $_" 10 | } 11 | } 12 | 13 | Export-ModuleMember -Function $Public.BaseName -------------------------------------------------------------------------------- /HashCopy/Private/Get-DestinationFilePath.ps1: -------------------------------------------------------------------------------- 1 | function Get-DestinationFilePath { 2 | <# 3 | .SYNOPSIS 4 | Accepts a source and destination file paths and a file (that from the source path) and returns the equivalent destination path (regardless of whether it exists). 5 | 6 | .PARAMETER File 7 | The file to modify. 8 | 9 | .PARAMETER Source 10 | The source directory or file path. 11 | 12 | .PARAMETER Destination 13 | The destination path. 14 | 15 | .EXAMPLE 16 | Get-DestinationFilePath -File (Get-ChildItem c:\temp\somefile.txt) -Source c:\temp -Destination d:\example 17 | #> 18 | [cmdletbinding()] 19 | param( 20 | [Parameter(Mandatory)] 21 | [System.IO.FileInfo] 22 | $File, 23 | 24 | [Parameter(Mandatory)] 25 | [String] 26 | $Source, 27 | 28 | [Parameter(Mandatory)] 29 | [String] 30 | $Destination 31 | ) 32 | 33 | if (Test-Path -Path $Source -PathType leaf) { 34 | $Source = Join-Path (Split-Path -Parent $Source) -ChildPath '/' 35 | } 36 | 37 | $DestFile = Join-Path (Split-Path -Parent $File) -ChildPath '/' 38 | $DestFile = $DestFile -Replace "^$([Regex]::Escape((Convert-Path $Source)))", $Destination 39 | $DestFile = Join-Path -Path $DestFile -ChildPath (Split-Path -Leaf $File) 40 | 41 | Return $DestFile 42 | } 43 | -------------------------------------------------------------------------------- /HashCopy/Public/Compare-FileHash.ps1: -------------------------------------------------------------------------------- 1 | function Compare-FileHash { 2 | <# 3 | .SYNOPSIS 4 | Compares files from one location to another based on determining change via computed hash value. 5 | 6 | .DESCRIPTION 7 | The Compare-FileHash cmdlet uses the Get-FileHash cmdlet to compute the hash value of one or more files and then returns any changed 8 | and new files from the specified source path. If you use the -Recurse parameter the cmdlet will synchronise a full directory 9 | tree, preserving the structure and creating any missing directories in the destination path as required. 10 | 11 | The purpose of this cmdlet is to compare specific file changes between two paths in situations where you cannot rely on the modified 12 | date of the files to determine if a file has changed. This can occur in situations where file modified dates have been changed, such 13 | as when cloning a set of files from a source control system. 14 | 15 | .PARAMETER Path 16 | The path to the source file/s or folder/s to copy any new or changed files from. 17 | 18 | .PARAMETER LiteralPath 19 | The literal path to the source file/s or folder/s to copy any new or changed files from. Unlike the Path parameter, the value of 20 | LiteralPath is used exactly as it is typed. No characters are interpreted as wildcards. 21 | 22 | .PARAMETER Destination 23 | The Destination folder to compare to -Path or -LiteralPath and return any changed or new files. 24 | 25 | .PARAMETER Algorithm 26 | Specifies the cryptographic hash function to use for computing the hash value of the contents of the specified file. A cryptographic 27 | hash function includes the property that it is not possible to find two distinct inputs that generate the same hash values. Hash 28 | functions are commonly used with digital signatures and for data integrity. The acceptable values for this parameter are: 29 | 30 | SHA1 | SHA256 | SHA384 | SHA512 | MACTripleDES | MD5 | RIPEMD160 31 | 32 | If no value is specified, or if the parameter is omitted, the default value is SHA256. 33 | 34 | .PARAMETER Exclude 35 | Exclude one or more files from being compared. 36 | 37 | .PARAMETER Recurse 38 | Indicates that this cmdlet performs a recursive comparison. 39 | 40 | .EXAMPLE 41 | Compare-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse 42 | 43 | Compares the files between the two trees and returns any where they have different contents as determined via hash value comparison. 44 | #> 45 | [cmdletbinding()] 46 | param( 47 | [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')] 48 | [ValidateScript( {if (Test-Path $_) {$True} Else { Throw '-Path must be a valid path.'} })] 49 | [String[]] 50 | $Path, 51 | 52 | [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'LiteralPath')] 53 | [ValidateScript( {if (Test-Path $_) {$True} Else { Throw '-LiteralPath must be a valid path.'} })] 54 | [String[]] 55 | $LiteralPath, 56 | 57 | [Parameter(Mandatory)] 58 | [ValidateScript( {if (Test-Path $_ -PathType Container -IsValid) {$True} Else { Throw '-Destination must be a valid path.' } })] 59 | [String] 60 | $Destination, 61 | 62 | [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MACTripleDES', 'MD5', 'RIPEMD160')] 63 | [String] 64 | $Algorithm = 'SHA256', 65 | 66 | [string[]] 67 | $Exclude, 68 | 69 | [switch] 70 | $Recurse 71 | ) 72 | begin { 73 | try { 74 | $SourcePath = if ($PSBoundParameters.ContainsKey('LiteralPath')) { 75 | (Resolve-Path -LiteralPath $LiteralPath).Path 76 | } 77 | else { 78 | (Resolve-Path -Path $Path).Path 79 | } 80 | 81 | if (-Not (Test-Path $Destination)) { 82 | throw "$Destination does not exist" 83 | } 84 | else { 85 | $Destination = Join-Path ((Resolve-Path -Path $Destination).Path) -ChildPath '/' 86 | } 87 | } 88 | catch { 89 | throw $_ 90 | } 91 | } 92 | process { 93 | foreach ($Source in $SourcePath) { 94 | $SourceFiles = (Get-ChildItem -Path $Source -Recurse:$Recurse -File -Exclude $Exclude).FullName 95 | 96 | foreach ($SourceFile in $SourceFiles) { 97 | $DestFile = Get-DestinationFilePath -File $SourceFile -Source $Source -Destination $Destination 98 | $SourceHash = (Get-FileHash $SourceFile -Algorithm $Algorithm).hash 99 | 100 | if (Test-Path $DestFile) { 101 | $DestHash = (Get-FileHash $DestFile -Algorithm $Algorithm).hash 102 | } 103 | else { 104 | Write-Verbose "New file: $SourceFile" 105 | $DestHash = $null 106 | } 107 | 108 | if ($SourceHash -ne $DestHash) { 109 | Get-ChildItem -Path $SourceFile 110 | } 111 | } 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /HashCopy/Public/Copy-FileHash.ps1: -------------------------------------------------------------------------------- 1 | function Copy-FileHash { 2 | <# 3 | .SYNOPSIS 4 | Copies files from one location to another based on determining change via computed hash value. 5 | 6 | .DESCRIPTION 7 | The Copy-FileHash cmdlet uses the Get-FileHash cmdlet to compute the hash value of one or more files and then copies any changed 8 | and new files to the specified destination path. If you use the -Recurse parameter the cmdlet will synchronise a full directory 9 | tree, preserving the structure and creating any missing directories in the destination path as required. 10 | 11 | The purpose of this cmdlet is to copy specific file changes between two paths in situations where you cannot rely on the modified 12 | date of the files to determine if a file has changed. This can occur in situations where file modified dates have been changed, such 13 | as when cloning a set of files from a source control system. 14 | 15 | .PARAMETER Path 16 | The path to the source file/s or folder/s to copy any new or changed files from. 17 | 18 | .PARAMETER LiteralPath 19 | The literal path to the source file/s or folder/s to copy any new or changed files from. Unlike the Path parameter, the value of 20 | LiteralPath is used exactly as it is typed. No characters are interpreted as wildcards. 21 | 22 | .PARAMETER Destination 23 | The Destination folder to compare to -Path and overwrite with any changed or new files from -Path. If the folder does not exist 24 | It will be created. 25 | 26 | .PARAMETER Algorithm 27 | Specifies the cryptographic hash function to use for computing the hash value of the contents of the specified file. A cryptographic 28 | hash function includes the property that it is not possible to find two distinct inputs that generate the same hash values. Hash 29 | functions are commonly used with digital signatures and for data integrity. The acceptable values for this parameter are: 30 | 31 | SHA1 | SHA256 | SHA384 | SHA512 | MACTripleDES | MD5 | RIPEMD160 32 | 33 | If no value is specified, or if the parameter is omitted, the default value is SHA256. 34 | 35 | .PARAMETER Exclude 36 | Exclude one or more files from being copied. 37 | 38 | .PARAMETER PassThru 39 | Returns the output of the file copy as an object. By default, this cmdlet does not generate any output. 40 | 41 | .PARAMETER Recurse 42 | Indicates that this cmdlet performs a recursive copy. 43 | 44 | .PARAMETER Mirror 45 | Use to remove files from the Destination path that are no longer in any of the Source paths. 46 | 47 | .PARAMETER Force 48 | Indicates that this cmdlet will copy items that cannot otherwise be changed, such as copying over a read-only file or alias. 49 | 50 | .EXAMPLE 51 | Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse 52 | 53 | Compares the files between the two trees and replaces in the destination any where they have different contents as determined 54 | via hash value comparison. 55 | #> 56 | [cmdletbinding(SupportsShouldProcess)] 57 | param( 58 | [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')] 59 | [ValidateScript( {if (Test-Path $_) {$True} Else { Throw '-Path must be a valid path.'} })] 60 | [string[]] 61 | $Path, 62 | 63 | [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'LiteralPath')] 64 | [ValidateScript( {if (Test-Path $_) {$True} Else { Throw '-LiteralPath must be a valid path.'} })] 65 | [string[]] 66 | $LiteralPath, 67 | 68 | [Parameter(Mandatory)] 69 | [ValidateScript( {if (Test-Path $_ -PathType Container -IsValid) {$True} Else { Throw '-Destination must be a valid path.' } })] 70 | [string] 71 | $Destination, 72 | 73 | [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MACTripleDES', 'MD5', 'RIPEMD160')] 74 | [string] 75 | $Algorithm = 'SHA256', 76 | 77 | [string[]] 78 | $Exclude, 79 | 80 | [switch] 81 | $PassThru, 82 | 83 | [switch] 84 | $Recurse, 85 | 86 | [switch] 87 | $Mirror, 88 | 89 | [switch] 90 | $Force 91 | ) 92 | begin { 93 | try { 94 | $SourcePath = if ($PSBoundParameters.ContainsKey('LiteralPath')) { 95 | (Resolve-Path -LiteralPath $LiteralPath).Path 96 | } 97 | else { 98 | (Resolve-Path -Path $Path).Path 99 | } 100 | 101 | if (-Not (Test-Path $Destination)) { 102 | New-Item -Path $Destination -ItemType Container | Out-Null 103 | Write-Warning "$Destination did not exist and has been created as a folder path." 104 | } 105 | 106 | $Destination = Join-Path ((Resolve-Path -Path $Destination).Path) -ChildPath '/' 107 | } 108 | catch { 109 | throw $_ 110 | } 111 | 112 | if ($Mirror -and ($SourcePath -is [array])) { 113 | throw 'Cannot use -Mirror with an array of Paths. Specify a single Source path only.' 114 | } 115 | 116 | } 117 | process { 118 | foreach ($Source in $SourcePath) { 119 | $SourceFiles = (Get-ChildItem -Path $Source -Recurse:$Recurse -File -Exclude $Exclude).FullName 120 | 121 | foreach ($SourceFile in $SourceFiles) { 122 | $DestFile = Get-DestinationFilePath -File $SourceFile -Source $Source -Destination $Destination 123 | $SourceHash = (Get-FileHash $SourceFile -Algorithm $Algorithm).hash 124 | 125 | if (Test-Path $DestFile) { 126 | $DestHash = (Get-FileHash $DestFile -Algorithm $Algorithm).hash 127 | } 128 | else { 129 | #Using New-Item -Force creates an initial destination file along with any folders missing from its path. 130 | #We use (Get-Date).Ticks to give the file a random value so that it is copied even if the source file is 131 | #empty, so that if -PassThru has been used it is returned. 132 | if ($PSCmdlet.ShouldProcess($DestFile, 'New-Item')) { 133 | New-Item -Path $DestFile -Value (Get-Date).Ticks -Force -ItemType 'file' | Out-Null 134 | } 135 | $DestHash = $null 136 | } 137 | 138 | if (($SourceHash -ne $DestHash) -and $PSCmdlet.ShouldProcess($SourceFile, 'Copy-Item')) { 139 | Copy-Item -Path $SourceFile -Destination $DestFile -Force:$Force -PassThru:$PassThru 140 | } 141 | } 142 | 143 | if ($Mirror) { 144 | $DestFiles = (Get-ChildItem $Destination -Recurse:$Recurse -File).FullName 145 | 146 | foreach ($DestFile in $DestFiles) { 147 | $SourceFile = Get-DestinationFilePath -File $DestFile -Source $Destination -Destination $Source 148 | 149 | if (-not (Test-Path $SourceFile)) { 150 | if ($PSCmdlet.ShouldProcess($DestFile, 'Remove-Item')) { 151 | Remove-Item $DestFile 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /PSScriptAnalyzerSettings.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | ExcludeRules = @( 3 | ) 4 | 5 | Severity = @( 6 | "Warning", 7 | "Error" 8 | ) 9 | 10 | Rules = @{} 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PowerShell-HashCopy 2 | 3 | [![Build Status](https://dev.azure.com/markwragg/GitHub/_apis/build/status/markwragg.PowerShell-HashCopy?branchName=master)](https://dev.azure.com/markwragg/GitHub/_build/latest?definitionId=2&branchName=master) ![Test Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen.svg?maxAge=60) 4 | 5 | This PowerShell module contains cmdlets for copying and comparing specific files between two paths, where those files have been determined to have changed via a computed hash value. This is useful if you need to sync specific file changes from one directory to another but cannot trust the modified date of the files to determine which files have been modified (for example, if the source files has been cloned from a source control system and as a result the modified dates had changed). 6 | 7 | You should of course be confident that if there is a difference between two files, it is the copy you have specified as being in the source `-Path` that you want to use to overwrite the copy in the `-Destination` path. New files (files that exist in the source path but not in the destination) will also be copied across, including any directories in their paths that may be missing in the destination folder. 8 | 9 | You can synchronise an entire directory tree by using the `-Recurse` parameter. 10 | 11 | # Installation 12 | 13 | The module is published in the PSGallery, so if you have PowerShell 5 or newer can be installed by running: 14 | 15 | ``` 16 | Install-Module HashCopy -Scope CurrentUser 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### Copy-FileHash 22 | 23 | You can use the `Copy-FileHash` cmdlet to sync a single path by providing it with `-Path` and `-Destination` parameters: 24 | ``` 25 | Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files 26 | ``` 27 | This will compute the hash for all files in each directory (and all sub-directories, due to `-Recurse`) via the `Get-FileHash` cmdlet and then will copy any changed and new files from the source path to the destination path. 28 | 29 | You can include all the sub-folders of the source `-Path` by adding `-Recurse`: 30 | ``` 31 | Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse 32 | ``` 33 | 34 | You can specify a `-LiteralPath` instead of a Path if you want to avoid wildcard characters from being interpreted as such: 35 | ``` 36 | Copy-FileHash -LiteralPath C:\Some\Files -Destination D:\Some\Other\Files -Recurse 37 | ``` 38 | 39 | You can remove files from the Destination path that are not in the Source path by adding `-Mirror`: 40 | ``` 41 | Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Mirror 42 | ``` 43 | 44 | You can have the destination file objects returned by adding `-PassThru`: 45 | ``` 46 | Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse -PassThru 47 | ``` 48 | 49 | You can Force the overwrite of read-only files in the Destination path by adding `-Force`: 50 | ``` 51 | Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Force 52 | ``` 53 | 54 | You can specify the algorithm that `Get-FileHash` uses to create the Hash by using `-Algorithm`: 55 | ``` 56 | Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Algorithm MD5 57 | ``` 58 | Valid `-Algorithm` values are: SHA1 | SHA256 | SHA384 | SHA512 | MACTripleDES | MD5 | RIPEMD160. 59 | 60 | ### Compare-FileHash 61 | 62 | If you'd like to check which files will be copied from a source path before actually using `Copy-FileHash`, you can use `Compare-FileHash`. This cmdlet outputs file objects for any new or modified file having performed the same comparison as the `Copy-` cmdlet (e.g via using Get-FileHash of the source and destination file to determine if they are different). 63 | 64 | Check which files would be copied from one single directory to another: 65 | ``` 66 | Compare-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files 67 | ``` 68 | Check which files would be copied between one directory tree and another (including all sub-directories): 69 | ``` 70 | Compare-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse 71 | ``` 72 | 73 | As with `Copy-FileHash` you can use `-LiteralPath` instead of `-Path` to have paths interpreted literally. 74 | 75 | ## Cmdlets 76 | 77 | A full list of cmdlets in this module is provided below for reference. Use `Get-Help ` with these to learn more about their usage. 78 | 79 | Cmdlet | Description 80 | -----------------| ------------------------------------------------------------------------------------------------------- 81 | Copy-FileHash | Copies any files between two directory paths that are new or have changed based on computed hash value. 82 | Compare-FileHash | Compares files from one location to another based on determining change via computed hash value. 83 | -------------------------------------------------------------------------------- /Tests/Common/Help.Tests.ps1: -------------------------------------------------------------------------------- 1 | # Taken with love from @juneb_get_help (https://raw.githubusercontent.com/juneb/PesterTDD/master/Module.Help.Tests.ps1) 2 | 3 | BeforeDiscovery { 4 | function global:FilterOutCommonParams { 5 | param ($Params) 6 | $commonParams = @( 7 | 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 8 | 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 9 | 'WarningVariable', 'Confirm', 'Whatif', 'ProgressAction' 10 | ) 11 | $params | Where-Object { $_.Name -notin $commonParams } | Sort-Object -Property Name -Unique 12 | } 13 | 14 | $env:BHProjectPath = Resolve-Path (Join-Path $PSScriptRoot "../../") 15 | $env:BHProjectName = (Get-ChildItem $env:BHProjectPath -Filter '*.psm1' -Recurse | Select-Object -First 1).BaseName 16 | 17 | # Get module commands 18 | # Remove all versions of the module from the session. Pester can't handle multiple versions. 19 | Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore 20 | Import-Module -Name (Resolve-Path $PSScriptRoot/../../$env:BHProjectName) -Verbose:$false -ErrorAction Stop 21 | $params = @{ 22 | Module = (Get-Module $env:BHProjectName) 23 | CommandType = [System.Management.Automation.CommandTypes[]]'Cmdlet, Function' # Not alias 24 | } 25 | if ($PSVersionTable.PSVersion.Major -lt 6) { 26 | $params.CommandType[0] += 'Workflow' 27 | } 28 | $commands = Get-Command @params 29 | 30 | ## When testing help, remember that help is cached at the beginning of each session. 31 | ## To test, restart session. 32 | } 33 | 34 | AfterAll { 35 | Remove-Item Function:/FilterOutCommonParams 36 | Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore 37 | } 38 | 39 | Describe "Test help for <_.Name>" -ForEach $commands { 40 | 41 | BeforeDiscovery { 42 | # Get command help, parameters, and links 43 | $command = $_ 44 | $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue 45 | $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters 46 | $commandParameterNames = $commandParameters.Name 47 | $helpLinks = $commandHelp.relatedLinks.navigationLink.uri 48 | } 49 | 50 | BeforeAll { 51 | # These vars are needed in both discovery and test phases so we need to duplicate them here 52 | $command = $_ 53 | $commandName = $_.Name 54 | $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue 55 | $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters 56 | $commandParameterNames = $commandParameters.Name 57 | $helpParameters = global:FilterOutCommonParams -Params $commandHelp.Parameters.Parameter 58 | $helpParameterNames = $helpParameters.Name 59 | } 60 | 61 | # If help is not found, synopsis in auto-generated help is the syntax diagram 62 | It 'Help is not auto-generated' { 63 | $commandHelp.Synopsis | Should -Not -BeLike '*`[``]*' 64 | } 65 | 66 | # Should be a description for every function 67 | It "Has description" { 68 | $commandHelp.Description | Should -Not -BeNullOrEmpty 69 | } 70 | 71 | # Should be at least one example 72 | It "Has example code" { 73 | ($commandHelp.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty 74 | } 75 | 76 | # Should be at least one example description 77 | It "Has example help" { 78 | ($commandHelp.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty 79 | } 80 | 81 | It "Help link <_> is valid" -ForEach $helpLinks { 82 | (Invoke-WebRequest -Uri $_ -UseBasicParsing).StatusCode | Should -Be '200' 83 | } 84 | 85 | Context "Parameter <_.Name>" -Foreach $commandParameters { 86 | 87 | BeforeAll { 88 | $parameter = $_ 89 | $parameterName = $parameter.Name 90 | $parameterHelp = $commandHelp.parameters.parameter | Where-Object Name -eq $parameterName 91 | $parameterHelpType = if ($parameterHelp.ParameterValue) { $parameterHelp.ParameterValue.Trim() } 92 | } 93 | 94 | # Should be a description for every parameter 95 | It "Has description" { 96 | $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty 97 | } 98 | 99 | # Required value in Help should match IsMandatory property of parameter 100 | It "Has correct [mandatory] value" { 101 | $codeMandatory = $_.IsMandatory.toString() 102 | $parameterHelp.Required | Should -Be $codeMandatory 103 | } 104 | 105 | # Parameter type in help should match code 106 | It "Has correct parameter type" { 107 | $parameterHelpType | Should -Be $parameter.ParameterType.Name 108 | } 109 | } 110 | 111 | Context "Test <_> help parameter help for " -Foreach $helpParameterNames { 112 | 113 | # Shouldn't find extra parameters in help. 114 | It "finds help parameter in code: <_>" { 115 | $_ -in $parameterNames | Should -Be $true 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /Tests/Common/Manifest.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | $env:BHProjectPath = Resolve-Path (Join-Path $PSScriptRoot "../../") 3 | $env:BHProjectName = (Get-ChildItem $env:BHProjectPath -Filter '*.psm1' -Recurse | Select-Object -First 1).BaseName 4 | $env:BHPSModuleManifest = (Get-ChildItem (Join-Path $env:BHProjectPath $env:BHProjectName) -Filter "${env:BHProjectName}.psd1").FullName 5 | 6 | $moduleName = $env:BHProjectName 7 | $manifest = Import-PowerShellDataFile -Path $env:BHPSModuleManifest 8 | $outputManifestPath = Join-Path -Path (Join-Path $env:BHProjectPath $env:BHProjectName) "$($moduleName).psd1" 9 | $manifestData = Test-ModuleManifest -Path $outputManifestPath -Verbose:$false -ErrorAction Stop -WarningAction SilentlyContinue 10 | 11 | $changelogPath = Join-Path -Path $env:BHProjectPath -Child 'CHANGELOG.md' 12 | $changelogVersion = Get-Content $changelogPath | ForEach-Object { 13 | if ($_ -match "^##\s\[(?(\d+\.){1,3}\d+)\]") { 14 | $changelogVersion = $matches.Version 15 | break 16 | } 17 | } 18 | 19 | $script:manifest = $null 20 | } 21 | 22 | Describe 'Module manifest' { 23 | 24 | Context 'Validation' { 25 | 26 | It 'Has a valid manifest' { 27 | $manifestData | Should -Not -BeNullOrEmpty 28 | } 29 | 30 | It 'Has a valid name in the manifest' { 31 | $manifestData.Name | Should -Be $moduleName 32 | } 33 | 34 | It 'Has a valid root module' { 35 | $manifestData.RootModule | Should -Be "$($moduleName).psm1" 36 | } 37 | 38 | It 'Has a valid version in the manifest' { 39 | $manifestData.Version -as [Version] | Should -Not -BeNullOrEmpty 40 | } 41 | 42 | It 'Has a valid description' { 43 | $manifestData.Description | Should -Not -BeNullOrEmpty 44 | } 45 | 46 | It 'Has a valid author' { 47 | $manifestData.Author | Should -Not -BeNullOrEmpty 48 | } 49 | 50 | It 'Has a valid guid' { 51 | { [guid]::Parse($manifestData.Guid) } | Should -Not -Throw 52 | } 53 | 54 | It 'Has a valid copyright' { 55 | $manifestData.CopyRight | Should -Not -BeNullOrEmpty 56 | } 57 | 58 | It 'Has a valid version in the changelog' { 59 | $changelogVersion | Should -Not -BeNullOrEmpty 60 | $changelogVersion -as [Version] | Should -Not -BeNullOrEmpty 61 | } 62 | 63 | It 'Changelog and manifest versions are the same' { 64 | $changelogVersion -as [Version] | Should -Be ( $manifestData.Version -as [Version] ) 65 | } 66 | } 67 | } 68 | 69 | Describe 'Git tagging' -Skip { 70 | 71 | BeforeAll { 72 | $gitTagVersion = $null 73 | 74 | if ($git = Get-Command git -CommandType Application -ErrorAction SilentlyContinue) { 75 | $thisCommit = & $git log --decorate --oneline HEAD~1..HEAD 76 | if ($thisCommit -match 'tag:\s*(\d+(?:\.\d+)*)') { $gitTagVersion = $matches[1] } 77 | } 78 | } 79 | 80 | It 'Is tagged with a valid version' { 81 | $gitTagVersion | Should -Not -BeNullOrEmpty 82 | $gitTagVersion -as [Version] | Should -Not -BeNullOrEmpty 83 | } 84 | 85 | It 'Matches manifest version' { 86 | $manifestData.Version -as [Version] | Should -Be ( $gitTagVersion -as [Version]) 87 | } 88 | } -------------------------------------------------------------------------------- /Tests/Common/PSSA.Tests.ps1: -------------------------------------------------------------------------------- 1 | # This runs all PSScriptAnalyzer rules as Pester tests to enable visibility when publishing test results 2 | 3 | Describe 'Testing against PSSA rules' { 4 | 5 | Context 'PSSA Standard Rules' { 6 | 7 | BeforeAll { 8 | $env:BHProjectPath = Resolve-Path (Join-Path $PSScriptRoot "../../") 9 | $env:BHModulePath = (Get-ChildItem $env:BHProjectPath -Filter '*.psm1' -Recurse | Select-Object -First 1).Directory 10 | 11 | $ScriptAnalyzerSettingsPath = Join-Path -Path $env:BHProjectPath -ChildPath 'PSScriptAnalyzerSettings.psd1' 12 | $analysis = Invoke-ScriptAnalyzer -Path $env:BHModulePath -Recurse -Settings $ScriptAnalyzerSettingsPath 13 | } 14 | 15 | $scriptAnalyzerRules = Get-ScriptAnalyzerRule 16 | 17 | It "Should pass <_>" -TestCases $scriptAnalyzerRules { 18 | $rule = $_ 19 | If ($analysis.RuleName -contains $rule) { 20 | $analysis | Where-Object RuleName -EQ $rule -OutVariable 'failures' | Out-Default 21 | $failures.Count | Should -Be 0 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/Compare-FileHash.tests.ps1: -------------------------------------------------------------------------------- 1 | $PSVersion = $PSVersionTable.PSVersion.Major 2 | 3 | Describe "Compare-FileHash PS$PSVersion" { 4 | 5 | BeforeAll { 6 | . $PSScriptRoot/../HashCopy/Public/Compare-FileHash.ps1 7 | . $PSScriptRoot/../HashCopy/Private/Get-DestinationFilePath.ps1 8 | } 9 | 10 | $CopyParams1 = @{ 11 | Path = '/TempSource' 12 | Destination = '/TempDest/' 13 | Recurse = $true 14 | } 15 | $CopyParams2 = @{ 16 | Path = '/TempSource/Temp2/Temp3/' 17 | Destination = '/TempDest' 18 | Recurse = $true 19 | } 20 | $CopyParams3 = @{ 21 | Path = '/TempSource/' 22 | Destination = '/TempDest/Temp2/Temp3' 23 | Recurse = $true 24 | } 25 | 26 | Context "Compare-FileHash -Path -Destination -Recurse:" -ForEach ($CopyParams1, $CopyParams2, $CopyParams3) { 27 | 28 | BeforeAll { 29 | $Path = Join-Path $TestDrive $Path 30 | $Destination = Join-Path $TestDrive $Destination 31 | 32 | New-Item -ItemType Directory $Path 33 | New-Item -ItemType Directory $Destination 34 | } 35 | 36 | Context 'New file to copy and existing file to modify' { 37 | 38 | BeforeAll { 39 | New-Item (Join-Path $Path '/somenewfile.txt') 40 | 'newcontent' | Out-File (Join-Path $Path '/someoriginalfile.txt') 41 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 42 | } 43 | 44 | It 'Compare-FileHash should return two files' { 45 | Compare-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be @((Join-Path $Path '/somenewfile.txt'), (Join-Path $Path '/someoriginalfile.txt')) 46 | } 47 | It 'Should not copy somenewfile.txt to destination' { 48 | (Join-Path $Destination '/somenewfile.txt') | Should -Not -Exist 49 | } 50 | It 'Should not update someoriginalfile.txt with newcontent' { 51 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 52 | } 53 | } 54 | 55 | Context 'New file in subdirectory with existing file in root' { 56 | 57 | BeforeAll { 58 | New-Item -ItemType Directory (Join-Path $Path '/Somesubdir') 59 | New-Item (Join-Path $Path '/Somesubdir/someoriginalfile.txt') 60 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 61 | } 62 | 63 | It 'Compare-FileHash should return one file' { 64 | Compare-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be (Join-Path $Path '/Somesubdir/someoriginalfile.txt') 65 | } 66 | It 'Should not copy new someorginalfile.txt to subfolder destination' { 67 | (Join-Path $Destination '/Somesubdir/someoriginalfile.txt') | Should -Not -Exist 68 | } 69 | It 'Should not change existing someoriginalfile.txt in root' { 70 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 71 | } 72 | } 73 | 74 | Context 'New file in subdirectory two levels deep' { 75 | 76 | BeforeAll { 77 | New-Item -ItemType Directory (Join-Path $Path '/Somedir') 78 | New-Item -ItemType Directory (Join-Path $Path '/Somedir/Someotherdir') 79 | New-Item (Join-Path $Path '/Somedir/Someotherdir/someoriginalfile.txt') 80 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 81 | } 82 | 83 | It 'Compare-FileHash should return one file' { 84 | Compare-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be (Join-Path $Path '/Somedir/Someotherdir/someoriginalfile.txt') 85 | } 86 | It 'Should not copy new someoriginalfile.txt to sub-subfolder destination' { 87 | (Join-Path $Destination '/Somedir/Someotherdir/someoriginalfile.txt') | Should -Not -Exist 88 | } 89 | It 'Should not change existing someoriginalfile.txt in root' { 90 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 91 | } 92 | } 93 | 94 | Context 'No file changes needed with a single file' { 95 | 96 | BeforeAll { 97 | 'onecontent' | Out-File (Join-Path $Path '/someoriginalfile.txt') 98 | 'onecontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 99 | } 100 | 101 | It 'Compare-FileHash should return null"' { 102 | Compare-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 103 | } 104 | It 'The pre-existing destination file "someoriginalfile.txt" should still contain "onecontent"' { 105 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'onecontent' 106 | } 107 | } 108 | 109 | Context 'No file changes needed with multiple files' { 110 | 111 | BeforeAll { 112 | 'onecontent' | Out-File (Join-Path $Path '/someoriginalfile.txt') 113 | 'onecontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 114 | 'twocontent' | Out-File (Join-Path $Path '/someotherfile.txt') 115 | 'twocontent' | Out-File (Join-Path $Destination '/someotherfile.txt') 116 | } 117 | 118 | It 'Compare-FileHash should return null"' { 119 | Compare-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 120 | } 121 | It 'The pre-existing destination file "someoriginalfile.txt" should still contain "onecontent"' { 122 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'onecontent' 123 | } 124 | It 'The pre-existing destination file "someotherfile.txt" should still contain "twocontent"' { 125 | (Join-Path $Destination '/someotherfile.txt') | Should -FileContentMatchExactly 'twocontent' 126 | } 127 | } 128 | 129 | Context 'Destination folder empty' { 130 | 131 | BeforeAll { 132 | 'oldcontent' | Out-File (Join-Path $Path '/someoriginalfile.txt') 133 | } 134 | 135 | It 'The destination folder should be empty before performing a copy' { 136 | Get-ChildItem (Join-Path $Destination '/') | Should -Be $null 137 | } 138 | It 'Compare-FileHash should return one file' { 139 | Compare-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be (Join-Path $Path '/someoriginalfile.txt') 140 | } 141 | It 'The destination folder should not contain someoriginalfile.txt' { 142 | (Join-Path $Destination '/someoriginalfile.txt') | Should -Not -Exist 143 | } 144 | } 145 | 146 | Context 'Source folder empty' { 147 | 148 | BeforeAll { 149 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 150 | } 151 | 152 | It 'The source folder should be empty before performing a copy' { 153 | Get-ChildItem (Join-Path $Path '/') | Should -Be $null 154 | } 155 | It 'Compare-FileHash should return null' { 156 | Compare-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 157 | } 158 | It 'The destination folder should now contain someoriginalfile.txt containing "oldcontent"' { 159 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 160 | } 161 | } 162 | } 163 | 164 | $CopyLiteralParams = @{ 165 | LiteralPath = '/LiteralTempSource' 166 | Destination = '/LiteralTempDest' 167 | Recurse = $true 168 | } 169 | 170 | Context "Compare-FileHash -LiteralPath $LiteralPath -Destination $Destination -Recurse:$Recurse" -ForEach $CopyLiteralParams { 171 | 172 | BeforeAll { 173 | $LiteralPath = Join-Path $TestDrive $LiteralPath 174 | $Destination = Join-Path $TestDrive $Destination 175 | 176 | New-Item -ItemType Directory $LiteralPath 177 | New-Item -ItemType Directory $Destination 178 | } 179 | 180 | Context 'New file to copy and existing file to modify' { 181 | 182 | BeforeAll { 183 | New-Item (Join-Path $LiteralPath '/somenewfile.txt') 184 | 'newcontent' | Out-File (Join-Path $LiteralPath '/someoriginalfile.txt') 185 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 186 | } 187 | 188 | It 'Compare-FileHash should return two files' { 189 | Compare-FileHash -LiteralPath $LiteralPath -Destination $Destination | Should -Be @((Join-Path $LiteralPath '/somenewfile.txt'), (Join-Path $LiteralPath '/someoriginalfile.txt')) 190 | } 191 | It 'Should not copy somenewfile.txt to destination' { 192 | (Join-Path $Destination '/somenewfile.txt') | Should -Not -Exist 193 | } 194 | It 'Should not update someoriginalfile.txt with newcontent' { 195 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 196 | } 197 | } 198 | } 199 | 200 | Context 'Compare-FileHash with Invalid -Path input' { 201 | 202 | It 'Compare-FileHash should throw "-Path must be a valid path." for a missing path' { 203 | { Compare-FileHash -Path 'TestDrive:/fake/path/not/exist' -Destination 'TestDrive:/' } | Should -Throw 204 | } 205 | It 'Compare-FileHash should throw "-Path must be a valid path." for an invalid path' { 206 | { Compare-FileHash -Path 'z:|invalid -Destination -Recurse:" -ForEach ($CopyParams1, $CopyParams2, $CopyParams3) { 27 | 28 | BeforeAll { 29 | $Path = Join-Path $TestDrive $Path 30 | $Destination = Join-Path $TestDrive $Destination 31 | 32 | New-Item -ItemType Directory $Path -Force 33 | New-Item -ItemType Directory $Destination -Force 34 | } 35 | 36 | Context 'New file to copy and existing file to modify' { 37 | 38 | BeforeAll { 39 | New-Item (Join-Path $Path 'somenewfile.txt') 40 | 'newcontent' | Out-File (Join-Path $Path 'someoriginalfile.txt') 41 | 'oldcontent' | Out-File (Join-Path $Destination 'someoriginalfile.txt') 42 | 43 | $Result = Copy-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse 44 | } 45 | 46 | It 'Copy-FileHash should return null' { 47 | $Result | Should -Be $Null 48 | } 49 | It 'Should copy somenewfile.txt to destination' { 50 | (Join-Path $Destination 'somenewfile.txt') | Should -Exist 51 | } 52 | It 'Should update someoriginalfile.txt with newcontent' { 53 | Get-Content (Join-Path $Destination 'someoriginalfile.txt') | Should -Be 'newcontent' 54 | } 55 | } 56 | 57 | Context 'New file in subdirectory with existing file in root' { 58 | 59 | BeforeAll { 60 | New-Item -ItemType Directory (Join-Path $Path '/Somesubdir') 61 | New-Item (Join-Path $Path '/Somesubdir/someoriginalfile.txt') 62 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 63 | } 64 | 65 | It 'Copy-FileHash should return null' { 66 | Copy-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 67 | } 68 | It 'Should copy new someorginalfile.txt to subfolder destination' { 69 | (Join-Path $Destination '/Somesubdir/someoriginalfile.txt') | Should -Exist 70 | } 71 | It 'Should not change existing someoriginalfile.txt in root' { 72 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 73 | } 74 | } 75 | 76 | Context 'New file in subdirectory two levels deep' { 77 | 78 | BeforeAll { 79 | New-Item -ItemType Directory (Join-Path $Path '/Somedir') 80 | New-Item -ItemType Directory (Join-Path $Path '/Somedir/Someotherdir') 81 | New-Item (Join-Path $Path '/Somedir/Someotherdir/someoriginalfile.txt') 82 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 83 | } 84 | 85 | It 'Copy-FileHash should return null' { 86 | Copy-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 87 | } 88 | It 'Should copy new someoriginalfile.txt to sub-subfolder destination' { 89 | (Join-Path $Destination '/Somedir/Someotherdir/someoriginalfile.txt') | Should -Exist 90 | } 91 | It 'Should not change existing someoriginalfile.txt in root' { 92 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 93 | } 94 | } 95 | 96 | Context 'No file changes needed with a single file' { 97 | 98 | BeforeAll { 99 | 'onecontent' | Out-File (Join-Path $Path '/someoriginalfile.txt') 100 | 'onecontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 101 | } 102 | 103 | It 'Copy-FileHash should return null"' { 104 | Copy-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 105 | } 106 | It 'The pre-existing destination file "someoriginalfile.txt" should still contain "onecontent"' { 107 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'onecontent' 108 | } 109 | } 110 | 111 | Context 'No file changes needed with multiple files' { 112 | 113 | BeforeAll { 114 | 'onecontent' | Out-File (Join-Path $Path '/someoriginalfile.txt') 115 | 'onecontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 116 | 'twocontent' | Out-File (Join-Path $Path '/someotherfile.txt') 117 | 'twocontent' | Out-File (Join-Path $Destination '/someotherfile.txt') 118 | } 119 | 120 | It 'Copy-FileHash should return null"' { 121 | Copy-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 122 | } 123 | It 'The pre-existing destination file "someoriginalfile.txt" should still contain "onecontent"' { 124 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'onecontent' 125 | } 126 | It 'The pre-existing destination file "someotherfile.txt" should still contain "twocontent"' { 127 | (Join-Path $Destination '/someotherfile.txt') | Should -FileContentMatchExactly 'twocontent' 128 | } 129 | } 130 | 131 | Context 'Destination folder empty' { 132 | 133 | BeforeAll { 134 | 'oldcontent' | Out-File (Join-Path $Path '/someoriginalfile.txt') 135 | } 136 | 137 | It 'The destination folder should be empty before performing a copy' { 138 | Get-ChildItem (Join-Path $Destination '/') | Should -Be $null 139 | } 140 | It 'Copy-FileHash should return null' { 141 | Copy-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 142 | } 143 | It 'The destination folder should now contain someoriginalfile.txt containing "oldcontent"' { 144 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 145 | } 146 | } 147 | 148 | Context 'Source folder empty' { 149 | 150 | BeforeAll { 151 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 152 | } 153 | 154 | It 'The source folder should be empty before performing a copy' { 155 | Get-ChildItem (Join-Path $Path '/') | Should -Be $null 156 | } 157 | It 'Copy-FileHash should return null' { 158 | Copy-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse | Should -Be $Null 159 | } 160 | It 'The destination folder should now contain someoriginalfile.txt containing "oldcontent"' { 161 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'oldcontent' 162 | } 163 | } 164 | 165 | Context 'Using -Mirror removes files from destination that are not in source' { 166 | 167 | BeforeAll { 168 | New-Item (Join-Path $Path '/somenewfile.txt') 169 | 'newcontent' | Out-File (Join-Path $Path '/someoriginalfile.txt') 170 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 171 | 'existingfi' | Out-File (Join-Path $Destination '/someexistingfile.txt') 172 | } 173 | 174 | It 'Copy-FileHash should return null' { 175 | Copy-FileHash -Path $Path -Destination $Destination -Recurse:$Recurse -Mirror | Should -Be $Null 176 | } 177 | It 'Should copy somenewfile.txt to destination' { 178 | (Join-Path $Destination '/somenewfile.txt') | Should -Exist 179 | } 180 | It 'Should update someoriginalfile.txt with newcontent' { 181 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'newcontent' 182 | } 183 | It 'Should remove someexistingfile.txt from the destination' { 184 | (Join-Path $Destination '/someexistingfile.txt') | Should -Not -Exist 185 | } 186 | } 187 | } 188 | 189 | $CopyLiteralParams = @{ 190 | LiteralPath = '/LiteralTempSource' 191 | Destination = '/LiteralTempDest' 192 | Recurse = $true 193 | } 194 | 195 | Context "Copy-FileHash -LiteralPath $LiteralPath -Destination $Destination -Recurse:$Recurse" -ForEach $CopyLiteralParams { 196 | 197 | BeforeAll { 198 | $LiteralPath = Join-Path $TestDrive $LiteralPath 199 | $Destination = Join-Path $TestDrive $Destination 200 | 201 | New-Item -ItemType Directory $LiteralPath 202 | New-Item -ItemType Directory $Destination 203 | } 204 | 205 | Context 'New file to copy and existing file to modify' { 206 | 207 | BeforeAll { 208 | New-Item (Join-Path $LiteralPath '/somenewfile.txt') 209 | 'newcontent' | Out-File (Join-Path $LiteralPath '/someoriginalfile.txt') 210 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 211 | } 212 | 213 | It 'Copy-FileHash should return null' { 214 | Copy-FileHash -LiteralPath $LiteralPath -Destination $Destination -Recurse:$Recurse | Should -Be $Null 215 | } 216 | It 'Should copy somenewfile.txt to destination' { 217 | (Join-Path $Destination '/somenewfile.txt') | Should -Exist 218 | } 219 | It 'Should update someoriginalfile.txt with newcontent' { 220 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'newcontent' 221 | } 222 | } 223 | 224 | Context 'Using -Mirror removes files from destination that are not in source' { 225 | 226 | BeforeAll { 227 | New-Item (Join-Path $LiteralPath '/somenewfile.txt') 228 | 'newcontent' | Out-File (Join-Path $LiteralPath '/someoriginalfile.txt') 229 | 'oldcontent' | Out-File (Join-Path $Destination '/someoriginalfile.txt') 230 | 'existingfi' | Out-File (Join-Path $Destination '/someexistingfile.txt') 231 | } 232 | 233 | It 'Copy-FileHash should return null' { 234 | Copy-FileHash -LiteralPath $LiteralPath -Destination $Destination -Recurse:$Recurse -Mirror | Should -Be $Null 235 | } 236 | It 'Should copy somenewfile.txt to destination' { 237 | (Join-Path $Destination '/somenewfile.txt') | Should -Exist 238 | } 239 | It 'Should update someoriginalfile.txt with newcontent' { 240 | (Join-Path $Destination '/someoriginalfile.txt') | Should -FileContentMatchExactly 'newcontent' 241 | } 242 | It 'Should remove someexistingfile.txt from the destination' { 243 | (Join-Path $Destination '/someexistingfile.txt') | Should -Not -Exist 244 | } 245 | } 246 | } 247 | 248 | Context 'Copy-FileHash with Invalid -Path input' { 249 | 250 | It 'Copy-FileHash should throw "-Path must be a valid path." for a missing path' { 251 | { Copy-FileHash -Path (Join-Path $TestDrive '/temp/fake/path/not/exist') -Destination $TestDrive } | Should -Throw 252 | } 253 | It 'Copy-FileHash should throw "-Path must be a valid path." for an invalid path' { 254 | { Copy-FileHash -Path 'z:|invalid 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | --------------------------------------------------------------------------------