Script to Compare Multiple Directories for Discrepancies

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"

Leave a Reply

Your email address will not be published. Required fields are marked *