This PowerShell script can be used to compare two directories (and sub-directories within them) to determine differences.
param(
[Parameter(Mandatory = $true)]
[string]$SourcePath,
[Parameter(Mandatory = $true)]
[string[]]$TargetPaths,
[string]$CsvOutput = "",
[switch]$CompareContent
)
function Normalize-Path {
param(
[string]$Path
)
if ([string]::IsNullOrWhiteSpace($Path)) {
return $null
}
return $Path.Trim('"', "'").TrimEnd('\')
}
function Get-RelativePath {
param(
[string]$BasePath,
[string]$FullPath
)
$base = [System.IO.Path]::GetFullPath($BasePath).TrimEnd('\') + '\'
$full = [System.IO.Path]::GetFullPath($FullPath)
if ($full.StartsWith($base, [System.StringComparison]::OrdinalIgnoreCase)) {
return $full.Substring($base.Length)
}
return $full
}
function Get-DirectoryInventory {
param(
[string]$RootPath,
[switch]$CompareContent
)
$items = New-Object System.Collections.Generic.List[object]
Get-ChildItem -LiteralPath $RootPath -Recurse -Directory -Force -ErrorAction Stop | ForEach-Object {
$items.Add([PSCustomObject]@{
RelativePath = Get-RelativePath -BasePath $RootPath -FullPath $_.FullName
ItemType = "Directory"
FullPath = $_.FullName
Length = $null
LastWriteTime = $_.LastWriteTime
Hash = $null
})
}
Get-ChildItem -LiteralPath $RootPath -Recurse -File -Force -ErrorAction Stop | ForEach-Object {
$hashValue = $null
if ($CompareContent) {
try {
$hashValue = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 -ErrorAction Stop).Hash
}
catch {
$hashValue = "HASH_ERROR"
}
}
$items.Add([PSCustomObject]@{
RelativePath = Get-RelativePath -BasePath $RootPath -FullPath $_.FullName
ItemType = "File"
FullPath = $_.FullName
Length = $_.Length
LastWriteTime = $_.LastWriteTime
Hash = $hashValue
})
}
return $items
}
function Compare-Inventory {
param(
[array]$SourceItems,
[array]$TargetItems,
[string]$TargetRoot,
[switch]$CompareContent
)
$sourceLookup = @{}
$targetLookup = @{}
$results = New-Object System.Collections.Generic.List[object]
foreach ($item in $SourceItems) {
$key = "{0}|{1}" -f $item.ItemType, $item.RelativePath.ToLower()
$sourceLookup[$key] = $item
}
foreach ($item in $TargetItems) {
$key = "{0}|{1}" -f $item.ItemType, $item.RelativePath.ToLower()
$targetLookup[$key] = $item
}
foreach ($key in $sourceLookup.Keys) {
$src = $sourceLookup[$key]
if (-not $targetLookup.ContainsKey($key)) {
$results.Add([PSCustomObject]@{
ComparedTarget = $TargetRoot
Status = "MissingInTarget"
ItemType = $src.ItemType
RelativePath = $src.RelativePath
SourcePath = $src.FullPath
TargetPath = $null
SourceSize = $src.Length
TargetSize = $null
SourceHash = $src.Hash
TargetHash = $null
})
}
else {
$tgt = $targetLookup[$key]
if ($src.ItemType -eq "File") {
if ($CompareContent) {
if ($src.Hash -ne $tgt.Hash) {
$results.Add([PSCustomObject]@{
ComparedTarget = $TargetRoot
Status = "DifferentContent"
ItemType = $src.ItemType
RelativePath = $src.RelativePath
SourcePath = $src.FullPath
TargetPath = $tgt.FullPath
SourceSize = $src.Length
TargetSize = $tgt.Length
SourceHash = $src.Hash
TargetHash = $tgt.Hash
})
}
}
else {
if (($src.Length -ne $tgt.Length) -or ($src.LastWriteTime -ne $tgt.LastWriteTime)) {
$results.Add([PSCustomObject]@{
ComparedTarget = $TargetRoot
Status = "DifferentMetadata"
ItemType = $src.ItemType
RelativePath = $src.RelativePath
SourcePath = $src.FullPath
TargetPath = $tgt.FullPath
SourceSize = $src.Length
TargetSize = $tgt.Length
SourceHash = $null
TargetHash = $null
})
}
}
}
}
}
foreach ($key in $targetLookup.Keys) {
$tgt = $targetLookup[$key]
if (-not $sourceLookup.ContainsKey($key)) {
$results.Add([PSCustomObject]@{
ComparedTarget = $TargetRoot
Status = "MissingInSource"
ItemType = $tgt.ItemType
RelativePath = $tgt.RelativePath
SourcePath = $null
TargetPath = $tgt.FullPath
SourceSize = $null
TargetSize = $tgt.Length
SourceHash = $null
TargetHash = $tgt.Hash
})
}
}
return $results
}
function Show-ComparisonResults {
param(
[array]$Results,
[string]$TargetPath
)
$targetName = Split-Path $TargetPath -Leaf
Write-Host $targetName -ForegroundColor Cyan
if (-not $Results -or $Results.Count -eq 0) {
Write-Host " No differences" -ForegroundColor Green
Write-Host ""
return
}
$grouped = $Results | Group-Object Status | Sort-Object Name
foreach ($group in $grouped) {
switch ($group.Name) {
"MissingInTarget" { $label = "Missing in target" }
"MissingInSource" { $label = "Extra in target" }
"DifferentContent" { $label = "Different file content" }
"DifferentMetadata" { $label = "Different file metadata" }
"TargetPathNotFound" { $label = "Target path not found" }
default { $label = $group.Name }
}
Write-Host " $label" -ForegroundColor Yellow
foreach ($item in ($group.Group | Sort-Object ItemType, RelativePath)) {
if ($item.RelativePath) {
Write-Host (" - {0}: {1}" -f $item.ItemType, $item.RelativePath)
}
elseif ($item.TargetPath) {
Write-Host (" - {0}" -f $item.TargetPath)
}
}
}
Write-Host ""
}
# Main
$SourcePath = Normalize-Path -Path $SourcePath
if ([string]::IsNullOrWhiteSpace($SourcePath)) {
throw "SourcePath is empty."
}
if (-not (Test-Path -LiteralPath $SourcePath)) {
throw "Source path does not exist: $SourcePath"
}
$cleanTargets = @()
foreach ($target in $TargetPaths) {
$clean = Normalize-Path -Path $target
if (-not [string]::IsNullOrWhiteSpace($clean)) {
$cleanTargets += $clean
}
}
if ($cleanTargets.Count -eq 0) {
throw "No valid target paths were provided."
}
$sourceItems = Get-DirectoryInventory -RootPath $SourcePath -CompareContent:$CompareContent
$allResults = New-Object System.Collections.Generic.List[object]
foreach ($target in $cleanTargets) {
if (-not (Test-Path -LiteralPath $target)) {
$missingTargetResult = [PSCustomObject]@{
ComparedTarget = $target
Status = "TargetPathNotFound"
ItemType = $null
RelativePath = $null
SourcePath = $SourcePath
TargetPath = $target
SourceSize = $null
TargetSize = $null
SourceHash = $null
TargetHash = $null
}
$allResults.Add($missingTargetResult)
Show-ComparisonResults -Results @($missingTargetResult) -TargetPath $target
continue
}
$targetItems = Get-DirectoryInventory -RootPath $target -CompareContent:$CompareContent
$results = Compare-Inventory `
-SourceItems $sourceItems `
-TargetItems $targetItems `
-TargetRoot $target `
-CompareContent:$CompareContent
Show-ComparisonResults -Results $results -TargetPath $target
foreach ($result in $results) {
$allResults.Add($result)
}
}
$differentTargets = ($allResults | Select-Object -ExpandProperty ComparedTarget -Unique).Count
$matchingTargets = $cleanTargets.Count - $differentTargets
Write-Host "Summary" -ForegroundColor Cyan
Write-Host (" Matching targets : {0}" -f $matchingTargets)
Write-Host (" Different targets: {0}" -f $differentTargets)
if (-not [string]::IsNullOrWhiteSpace($CsvOutput)) {
$CsvOutput = Normalize-Path -Path $CsvOutput
$csvFolder = Split-Path -Path $CsvOutput -Parent
if (-not [string]::IsNullOrWhiteSpace($csvFolder) -and -not (Test-Path -LiteralPath $csvFolder)) {
New-Item -Path $csvFolder -ItemType Directory -Force | Out-Null
}
$allResults | Export-Csv -Path $CsvOutput -NoTypeInformation -Encoding UTF8
Write-Host (" CSV report : {0}" -f $CsvOutput)
}
This snippet of PowerShell can be used to generate some sample data to test the script and simulate what it’ll kick out.
param(
[string]$RootPath = "C:\Temp_or_Wherever",
[switch]$Overwrite
)
# ------------------------------------------------------------
# Build-TwoDirectoryTestSet.ps1
# Creates two directory trees with mostly matching content,
# but with a few intentional differences for testing.
# ------------------------------------------------------------
$dirA = Join-Path $RootPath "DirA"
$dirB = Join-Path $RootPath "DirB"
function Remove-IfExists {
param([string]$Path)
if (Test-Path $Path) {
Remove-Item -Path $Path -Recurse -Force
}
}
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
}
function Write-TextFile {
param(
[string]$Path,
[string]$Content
)
$parent = Split-Path $Path -Parent
Ensure-Directory -Path $parent
Set-Content -Path $Path -Value $Content -Encoding UTF8
}
function New-MatchingStructure {
param([string]$BasePath)
# Create nested folder structure
$folders = @(
"Apps",
"Apps\Config",
"Apps\Config\Profiles",
"Apps\Logs",
"Data",
"Data\Inbound",
"Data\Outbound",
"Data\Archive\2024",
"Data\Archive\2025",
"Scripts",
"Scripts\Modules",
"Docs",
"Docs\Design",
"Docs\Operations"
)
foreach ($folder in $folders) {
Ensure-Directory -Path (Join-Path $BasePath $folder)
}
# Create files that should match in both trees
$files = @{
"README.txt" = @"
Test directory tree for comparison validation.
This file should match in both directories.
"@
"Apps\Config\appsettings.txt" = @"
ApplicationName=TestPlatform
Environment=Lab
LogLevel=Info
"@
"Apps\Config\Profiles\default.txt" = @"
Profile=Default
Retries=3
TimeoutSeconds=30
"@
"Apps\Logs\startup-log.txt" = @"
2026-04-14 08:00:00 Application startup successful
2026-04-14 08:00:02 Configuration loaded
"@
"Data\Inbound\customers.txt" = @"
1001,Acme Corp,Houston
1002,Globex,Denver
1003,Initech,Dallas
"@
"Data\Outbound\manifest.txt" = @"
Batch=2026-04-14-A
Records=3
Status=Complete
"@
"Data\Archive\2024\summary.txt" = @"
ArchiveYear=2024
FileCount=18
ChecksumMode=SHA256
"@
"Data\Archive\2025\summary.txt" = @"
ArchiveYear=2025
FileCount=22
ChecksumMode=SHA256
"@
"Scripts\Deploy.ps1" = @"
Write-Host 'Deploy started'
Write-Host 'Deploy completed'
"@
"Scripts\Modules\Common.psm1" = @"
function Get-TestValue {
return 'SharedValue'
}
"@
"Docs\Design\architecture.txt" = @"
System Architecture
- Web Tier
- App Tier
- Data Tier
"@
"Docs\Operations\runbook.txt" = @"
Operations Runbook
1. Start services
2. Validate health
3. Review logs
"@
}
foreach ($relativePath in $files.Keys) {
$fullPath = Join-Path $BasePath $relativePath
Write-TextFile -Path $fullPath -Content $files[$relativePath]
}
}
# Handle overwrite behavior
if ((Test-Path $dirA) -or (Test-Path $dirB)) {
if ($Overwrite) {
Remove-IfExists -Path $dirA
Remove-IfExists -Path $dirB
}
else {
Write-Error "Target directories already exist. Re-run with -Overwrite to recreate them.`nDirA: $dirA`nDirB: $dirB"
exit 1
}
}
# Ensure root path exists
Ensure-Directory -Path $RootPath
# Create baseline matching structures
New-MatchingStructure -BasePath $dirA
New-MatchingStructure -BasePath $dirB
# ------------------------------------------------------------
# Introduce intentional differences in DirB
# ------------------------------------------------------------
# 1. Same file path, different content
Write-TextFile -Path (Join-Path $dirB "Apps\Config\appsettings.txt") -Content @"
ApplicationName=TestPlatform
Environment=Production
LogLevel=Warning
"@
# 2. File exists in DirA but removed from DirB
$missingInB = Join-Path $dirB "Docs\Operations\runbook.txt"
if (Test-Path $missingInB) {
Remove-Item -Path $missingInB -Force
}
# 3. Extra file exists only in DirB
Write-TextFile -Path (Join-Path $dirB "Docs\Operations\extra-notes.txt") -Content @"
This file exists only in DirB.
Your comparison script should report it as extra.
"@
# 4. Different content in deeply nested file
Write-TextFile -Path (Join-Path $dirB "Data\Archive\2025\summary.txt") -Content @"
ArchiveYear=2025
FileCount=23
ChecksumMode=SHA256
"@
# 5. Extra nested folder and file only in DirB
Write-TextFile -Path (Join-Path $dirB "Apps\Config\Profiles\Advanced\override.txt") -Content @"
AdvancedProfile=True
FeatureFlag=Enabled
"@
# 6. File exists in both, but content differs
Write-TextFile -Path (Join-Path $dirB "Scripts\Deploy.ps1") -Content @"
Write-Host 'Deploy started'
Write-Host 'Running validation'
Write-Host 'Deploy completed'
"@
# 7. File removed and replacement added in DirB
$customersInB = Join-Path $dirB "Data\Inbound\customers.txt"
if (Test-Path $customersInB) {
Remove-Item -Path $customersInB -Force
}
Write-TextFile -Path (Join-Path $dirB "Data\Inbound\clients.txt") -Content @"
1001,Acme Corp,Houston
1002,Globex,Denver
1003,Initech,Dallas
"@
Write-Host ""
Write-Host "Test directory structures created successfully." -ForegroundColor Green
Write-Host "DirA: $dirA"
Write-Host "DirB: $dirB"
Write-Host ""
Write-Host "Intentional differences introduced in DirB:" -ForegroundColor Yellow
Write-Host " - Apps\Config\appsettings.txt has different content"
Write-Host " - Docs\Operations\runbook.txt is missing"
Write-Host " - Docs\Operations\extra-notes.txt exists only in DirB"
Write-Host " - Data\Archive\2025\summary.txt has different content"
Write-Host " - Apps\Config\Profiles\Advanced\override.txt exists only in DirB"
Write-Host " - Scripts\Deploy.ps1 has different content"
Write-Host " - Data\Inbound\customers.txt removed and clients.txt added"
Write-Host ""
Write-Host "You can now point your comparison script at:"
Write-Host " Source: $dirA"
Write-Host " Target: $dirB"
