2つのフォルダから差分をパワーシェルだけで炙り出す

2つのフォルダから差分をパワーシェルだけで炙り出す
Winmargeでも確かに十分だが好き勝手にcsvでもやれるしカスタムをしたいためだけ
笑笑

.ps1 という拡張子で以下を保存すればそのままダブルクリックすれば、
ダイアログ表示されるので使えます。
比較するフォルダを入力して、ENTER keyで実行可能
宜しくお願い致します。


param(
    [string]$Folder1,

    [string]$Folder2,

    [string]$OutputCsv,

    [int]$TimestampToleranceSeconds = 2,

    [switch]$DiffOnly,

    [switch]$ExcludeDirectories,

    [int]$FlushInterval = 200,

    [switch]$UseDialogs
)

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
[System.Windows.Forms.Application]::EnableVisualStyles()

function Select-FolderPath {
    param(
        [string]$Description,
        [string]$SelectedPath
    )

    $dialog = New-Object System.Windows.Forms.FolderBrowserDialog
    $dialog.Description = $Description
    $dialog.ShowNewFolderButton = $false

    if (-not [string]::IsNullOrWhiteSpace($SelectedPath) -and (Test-Path -LiteralPath $SelectedPath)) {
        $dialog.SelectedPath = $SelectedPath
    }

    $result = $dialog.ShowDialog()
    if ($result -ne [System.Windows.Forms.DialogResult]::OK) {
        return $null
    }

    return $dialog.SelectedPath
}

function Select-OutputCsvPath {
    param(
        [string]$InitialPath
    )

    $dialog = New-Object System.Windows.Forms.SaveFileDialog
    $dialog.Title = '比較結果CSVの保存先を指定'
    $dialog.Filter = 'CSVファイル (*.csv)|*.csv|すべてのファイル (*.*)|*.*'
    $dialog.DefaultExt = 'csv'
    $dialog.AddExtension = $true
    $dialog.OverwritePrompt = $true
    $dialog.FileName = 'FolderCompare_{0}.csv' -f (Get-Date -Format 'yyyyMMdd_HHmmss')

    if (-not [string]::IsNullOrWhiteSpace($InitialPath)) {
        try {
            $dir = Split-Path -Path $InitialPath -Parent
            $leaf = Split-Path -Path $InitialPath -Leaf
            if (-not [string]::IsNullOrWhiteSpace($dir) -and (Test-Path -LiteralPath $dir)) {
                $dialog.InitialDirectory = $dir
            }
            if (-not [string]::IsNullOrWhiteSpace($leaf)) {
                $dialog.FileName = $leaf
            }
        }
        catch {
        }
    }
    else {
        try {
            $dialog.InitialDirectory = (Get-Location).Path
        }
        catch {
        }
    }

    $result = $dialog.ShowDialog()
    if ($result -ne [System.Windows.Forms.DialogResult]::OK) {
        return $null
    }

    return $dialog.FileName
}

function Show-CompareSetupForm {
    param(
        [string]$Folder1,
        [string]$Folder2,
        [string]$OutputCsv
    )

    $form = New-Object System.Windows.Forms.Form
    $form.Text = 'フォルダ比較設定'
    $form.StartPosition = 'CenterScreen'
    $form.Size = New-Object System.Drawing.Size(860, 250)
    $form.MinimumSize = New-Object System.Drawing.Size(860, 250)
    $form.MaximizeBox = $false
    $form.MinimizeBox = $false
    $form.TopMost = $true

    $labelInfo = New-Object System.Windows.Forms.Label
    $labelInfo.Location = New-Object System.Drawing.Point(20, 15)
    $labelInfo.Size = New-Object System.Drawing.Size(800, 20)
    $labelInfo.Text = '直接入力または参照ボタンで指定して、OKで次へ進みます。'
    $form.Controls.Add($labelInfo)

    $label1 = New-Object System.Windows.Forms.Label
    $label1.Location = New-Object System.Drawing.Point(20, 50)
    $label1.Size = New-Object System.Drawing.Size(140, 20)
    $label1.Text = 'Folder1'
    $form.Controls.Add($label1)

    $textFolder1 = New-Object System.Windows.Forms.TextBox
    $textFolder1.Location = New-Object System.Drawing.Point(20, 72)
    $textFolder1.Size = New-Object System.Drawing.Size(700, 25)
    $textFolder1.Text = $Folder1
    $form.Controls.Add($textFolder1)

    $buttonBrowse1 = New-Object System.Windows.Forms.Button
    $buttonBrowse1.Location = New-Object System.Drawing.Point(730, 70)
    $buttonBrowse1.Size = New-Object System.Drawing.Size(90, 28)
    $buttonBrowse1.Text = '参照...'
    $buttonBrowse1.Add_Click({
        $selected = Select-FolderPath -Description '比較元のフォルダ (Folder1) を選択' -SelectedPath $textFolder1.Text
        if ($null -ne $selected) {
            $textFolder1.Text = $selected
        }
    })
    $form.Controls.Add($buttonBrowse1)

    $label2 = New-Object System.Windows.Forms.Label
    $label2.Location = New-Object System.Drawing.Point(20, 105)
    $label2.Size = New-Object System.Drawing.Size(140, 20)
    $label2.Text = 'Folder2'
    $form.Controls.Add($label2)

    $textFolder2 = New-Object System.Windows.Forms.TextBox
    $textFolder2.Location = New-Object System.Drawing.Point(20, 127)
    $textFolder2.Size = New-Object System.Drawing.Size(700, 25)
    $textFolder2.Text = $Folder2
    $form.Controls.Add($textFolder2)

    $buttonBrowse2 = New-Object System.Windows.Forms.Button
    $buttonBrowse2.Location = New-Object System.Drawing.Point(730, 125)
    $buttonBrowse2.Size = New-Object System.Drawing.Size(90, 28)
    $buttonBrowse2.Text = '参照...'
    $buttonBrowse2.Add_Click({
        $selected = Select-FolderPath -Description '比較先のフォルダ (Folder2) を選択' -SelectedPath $textFolder2.Text
        if ($null -ne $selected) {
            $textFolder2.Text = $selected
        }
    })
    $form.Controls.Add($buttonBrowse2)

    $label3 = New-Object System.Windows.Forms.Label
    $label3.Location = New-Object System.Drawing.Point(20, 160)
    $label3.Size = New-Object System.Drawing.Size(160, 20)
    $label3.Text = '出力CSV'
    $form.Controls.Add($label3)

    $textOutput = New-Object System.Windows.Forms.TextBox
    $textOutput.Location = New-Object System.Drawing.Point(20, 182)
    $textOutput.Size = New-Object System.Drawing.Size(700, 25)
    $textOutput.Text = $OutputCsv
    $form.Controls.Add($textOutput)

    $buttonBrowseOutput = New-Object System.Windows.Forms.Button
    $buttonBrowseOutput.Location = New-Object System.Drawing.Point(730, 180)
    $buttonBrowseOutput.Size = New-Object System.Drawing.Size(90, 28)
    $buttonBrowseOutput.Text = '参照...'
    $buttonBrowseOutput.Add_Click({
        $selected = Select-OutputCsvPath -InitialPath $textOutput.Text
        if ($null -ne $selected) {
            $textOutput.Text = $selected
        }
    })
    $form.Controls.Add($buttonBrowseOutput)

    $buttonOk = New-Object System.Windows.Forms.Button
    $buttonOk.Location = New-Object System.Drawing.Point(620, 215)
    $buttonOk.Size = New-Object System.Drawing.Size(95, 30)
    $buttonOk.Text = 'OK'
    $buttonOk.Add_Click({
        $folder1Value = $textFolder1.Text.Trim()
        $folder2Value = $textFolder2.Text.Trim()
        $outputValue = $textOutput.Text.Trim()

        if ([string]::IsNullOrWhiteSpace($folder1Value)) {
            [System.Windows.Forms.MessageBox]::Show('Folder1を入力または選択してください。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
            $textFolder1.Focus()
            return
        }
        if (-not (Test-Path -LiteralPath $folder1Value)) {
            [System.Windows.Forms.MessageBox]::Show('Folder1が存在しません。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
            $textFolder1.Focus()
            return
        }
        if (-not (Get-Item -LiteralPath $folder1Value -Force).PSIsContainer) {
            [System.Windows.Forms.MessageBox]::Show('Folder1にはフォルダを指定してください。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
            $textFolder1.Focus()
            return
        }

        if ([string]::IsNullOrWhiteSpace($folder2Value)) {
            [System.Windows.Forms.MessageBox]::Show('Folder2を入力または選択してください。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
            $textFolder2.Focus()
            return
        }
        if (-not (Test-Path -LiteralPath $folder2Value)) {
            [System.Windows.Forms.MessageBox]::Show('Folder2が存在しません。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
            $textFolder2.Focus()
            return
        }
        if (-not (Get-Item -LiteralPath $folder2Value -Force).PSIsContainer) {
            [System.Windows.Forms.MessageBox]::Show('Folder2にはフォルダを指定してください。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
            $textFolder2.Focus()
            return
        }

        if ([string]::IsNullOrWhiteSpace($outputValue)) {
            [System.Windows.Forms.MessageBox]::Show('出力CSVを入力または選択してください。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
            $textOutput.Focus()
            return
        }

        try {
            $parentDir = Split-Path -Path $outputValue -Parent
            if (-not [string]::IsNullOrWhiteSpace($parentDir) -and -not (Test-Path -LiteralPath $parentDir)) {
                [System.Windows.Forms.MessageBox]::Show('出力先フォルダが存在しません。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
                $textOutput.Focus()
                return
            }
        }
        catch {
            [System.Windows.Forms.MessageBox]::Show('出力CSVのパス形式が不正です。', '入力確認', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) | Out-Null
            $textOutput.Focus()
            return
        }

        $form.Tag = [pscustomobject]@{
            Folder1   = $folder1Value
            Folder2   = $folder2Value
            OutputCsv = $outputValue
        }
        $form.DialogResult = [System.Windows.Forms.DialogResult]::OK
        $form.Close()
    })
    $form.Controls.Add($buttonOk)

    $buttonCancel = New-Object System.Windows.Forms.Button
    $buttonCancel.Location = New-Object System.Drawing.Point(725, 215)
    $buttonCancel.Size = New-Object System.Drawing.Size(95, 30)
    $buttonCancel.Text = 'キャンセル'
    $buttonCancel.Add_Click({
        $form.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
        $form.Close()
    })
    $form.Controls.Add($buttonCancel)

    $form.AcceptButton = $buttonOk
    $form.CancelButton = $buttonCancel

    $result = $form.ShowDialog()
    if ($result -ne [System.Windows.Forms.DialogResult]::OK) {
        return $null
    }

    return $form.Tag
}

function Confirm-CompareSettings {
    param(
        [string]$Folder1,
        [string]$Folder2,
        [string]$OutputCsv,
        [int]$TimestampToleranceSeconds,
        [bool]$DiffOnly,
        [bool]$ExcludeDirectories,
        [int]$FlushInterval
    )

    $diffOnlyText = 'いいえ'
    if ($DiffOnly) {
        $diffOnlyText = 'はい'
    }

    $excludeDirectoriesText = 'いいえ'
    if ($ExcludeDirectories) {
        $excludeDirectoriesText = 'はい'
    }

    $message = @"
以下の設定で比較を開始します。

Folder1:
$Folder1

Folder2:
$Folder2

出力CSV:
$OutputCsv

更新日時許容差(秒): $TimestampToleranceSeconds
差分のみ出力: $diffOnlyText
フォルダ行を除外: $excludeDirectoriesText
途中保存間隔(件): $FlushInterval

はい       : この設定で実行
いいえ     : 入力フォームへ戻る
キャンセル : 実行せず終了
"@

    return [System.Windows.Forms.MessageBox]::Show(
        $message,
        '比較開始の確認',
        [System.Windows.Forms.MessageBoxButtons]::YesNoCancel,
        [System.Windows.Forms.MessageBoxIcon]::Question,
        [System.Windows.Forms.MessageBoxDefaultButton]::Button1
    )
}

if ([string]::IsNullOrWhiteSpace($OutputCsv)) {
    $OutputCsv = Join-Path -Path (Get-Location) -ChildPath ('FolderCompare_{0}.csv' -f (Get-Date -Format 'yyyyMMdd_HHmmss'))
}

if ($UseDialogs -or [string]::IsNullOrWhiteSpace($Folder1) -or [string]::IsNullOrWhiteSpace($Folder2) -or [string]::IsNullOrWhiteSpace($OutputCsv)) {
    while ($true) {
        $selection = Show-CompareSetupForm -Folder1 $Folder1 -Folder2 $Folder2 -OutputCsv $OutputCsv
        if ($null -eq $selection) {
            Write-Warning '入力フォームがキャンセルされました。処理を終了します。'
            return
        }

        $Folder1 = $selection.Folder1
        $Folder2 = $selection.Folder2
        $OutputCsv = $selection.OutputCsv

        $confirmResult = Confirm-CompareSettings `
            -Folder1 $Folder1 `
            -Folder2 $Folder2 `
            -OutputCsv $OutputCsv `
            -TimestampToleranceSeconds $TimestampToleranceSeconds `
            -DiffOnly ([bool]$DiffOnly) `
            -ExcludeDirectories ([bool]$ExcludeDirectories) `
            -FlushInterval $FlushInterval

        if ($confirmResult -eq [System.Windows.Forms.DialogResult]::Yes) {
            break
        }

        if ($confirmResult -eq [System.Windows.Forms.DialogResult]::Cancel) {
            Write-Warning '実行はキャンセルされました。'
            return
        }
    }
}

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$script:CsvBuffer = New-Object System.Collections.Generic.List[object]
$script:Summary = @{}
$script:TotalRowsWritten = 0
$script:CompletedSuccessfully = $false

$script:CsvColumns = @(
    '判定区分',
    '相対パス',
    'Folder1フルパス',
    'Folder2フルパス',
    '種別1',
    '種別2',
    'サイズ1',
    'サイズ2',
    'サイズ差',
    '作成日時1',
    '作成日時2',
    '更新日時1',
    '更新日時2',
    '更新日時差秒',
    '差分理由',
    'エラー内容'
)

$script:PartialOutputCsv = $OutputCsv + '.partial'

function Resolve-NormalizedPath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "パスが存在しません: $Path"
    }

    $item = Get-Item -LiteralPath $Path -Force
    return $item.FullName.TrimEnd('\\')
}

function Get-RelativePathSafe {
    param(
        [Parameter(Mandatory = $true)]
        [string]$RootPath,

        [Parameter(Mandatory = $true)]
        [string]$FullPath
    )

    if ($FullPath -eq $RootPath) {
        return ''
    }

    $rootUri = New-Object System.Uri(($RootPath.TrimEnd('\\') + '\\'))
    $fullUri = New-Object System.Uri($FullPath)
    $relativeUri = $rootUri.MakeRelativeUri($fullUri)
    $relativePath = [System.Uri]::UnescapeDataString($relativeUri.ToString())

    return ($relativePath -replace '/', '\\')
}

function Format-DateTimeValue {
    param(
        [object]$DateValue
    )

    if ($null -eq $DateValue) {
        return ''
    }

    return ([datetime]$DateValue).ToString('yyyy-MM-dd HH:mm:ss')
}

function Initialize-OutputCsv {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    $dir = Split-Path -Path $Path -Parent
    if (-not [string]::IsNullOrWhiteSpace($dir) -and -not (Test-Path -LiteralPath $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }

    $headerLine = ($script:CsvColumns -join ',')
    Set-Content -LiteralPath $Path -Value $headerLine -Encoding UTF8
}

function Flush-CsvBuffer {
    if ($script:CsvBuffer.Count -eq 0) {
        return
    }

    $script:CsvBuffer |
        Export-Csv -LiteralPath $script:PartialOutputCsv -NoTypeInformation -Encoding UTF8 -Append

    $script:TotalRowsWritten += $script:CsvBuffer.Count
    $script:CsvBuffer.Clear()
}

function Add-OutputRow {
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Row
    )

    $script:CsvBuffer.Add($Row)

    $category = [string]$Row.判定区分
    if ($script:Summary.ContainsKey($category)) {
        $script:Summary[$category]++
    }
    else {
        $script:Summary[$category] = 1
    }

    if ($script:CsvBuffer.Count -ge $FlushInterval) {
        Flush-CsvBuffer
    }
}

function Get-FileSystemIndex {
    param(
        [Parameter(Mandatory = $true)]
        [string]$RootPath,

        [Parameter(Mandatory = $true)]
        [string]$SideLabel,

        [bool]$IncludeDirectories = $true,

        [int]$ProgressId = 1
    )

    $index = @{}
    $errors = New-Object System.Collections.Generic.List[object]
    $stack = New-Object System.Collections.Stack
    $stack.Push($RootPath)

    $discoveredFileCount = 0
    $processedEntryCount = 0
    $lastProgressTick = [Environment]::TickCount

    while ($stack.Count -gt 0) {
        $currentDir = [string]$stack.Pop()

        try {
            $children = [System.IO.Directory]::GetFileSystemEntries($currentDir)
        }
        catch {
            $relativeForError = ''
            if ($currentDir -ne $RootPath) {
                $relativeForError = Get-RelativePathSafe -RootPath $RootPath -FullPath $currentDir
            }

            $errors.Add([pscustomobject]@{
                SideLabel    = $SideLabel
                RelativePath = $relativeForError
                FullPath     = $currentDir
                ErrorMessage = $_.Exception.Message
            })
            continue
        }

        foreach ($child in $children) {
            $relativePath = ''

            try {
                $processedEntryCount++
                $relativePath = Get-RelativePathSafe -RootPath $RootPath -FullPath $child

                $attributes = [System.IO.File]::GetAttributes($child)
                $isDirectory = (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0)

                if ($isDirectory) {
                    $dirInfo = New-Object System.IO.DirectoryInfo($child)

                    if ($IncludeDirectories) {
                        $index[$relativePath] = [pscustomobject]@{
                            RelativePath  = $relativePath
                            FullPath      = $dirInfo.FullName
                            ItemType      = 'Directory'
                            Size          = $null
                            CreationTime  = $dirInfo.CreationTime
                            LastWriteTime = $dirInfo.LastWriteTime
                            Attributes    = [string]$dirInfo.Attributes
                        }
                    }

                    $stack.Push($child)
                }
                else {
                    $discoveredFileCount++
                    $fileInfo = New-Object System.IO.FileInfo($child)

                    $index[$relativePath] = [pscustomobject]@{
                        RelativePath  = $relativePath
                        FullPath      = $fileInfo.FullName
                        ItemType      = 'File'
                        Size          = [int64]$fileInfo.Length
                        CreationTime  = $fileInfo.CreationTime
                        LastWriteTime = $fileInfo.LastWriteTime
                        Attributes    = [string]$fileInfo.Attributes
                    }
                }

                $nowTick = [Environment]::TickCount
                if ((($processedEntryCount % 200) -eq 0) -or (($nowTick - $lastProgressTick) -ge 300)) {
                    Write-Progress `
                        -Id $ProgressId `
                        -Activity "$SideLabel をスキャン中" `
                        -Status ("発見ファイル数: {0:N0}" -f $discoveredFileCount) `
                        -CurrentOperation $relativePath

                    $lastProgressTick = $nowTick
                }
            }
            catch {
                $errors.Add([pscustomobject]@{
                    SideLabel    = $SideLabel
                    RelativePath = $relativePath
                    FullPath     = $child
                    ErrorMessage = $_.Exception.Message
                })
            }
        }
    }

    Write-Progress `
        -Id $ProgressId `
        -Activity "$SideLabel をスキャン中" `
        -Status ("完了  発見ファイル数: {0:N0}" -f $discoveredFileCount) `
        -Completed

    return [pscustomobject]@{
        RootPath = $RootPath
        Index    = $index
        Errors   = $errors
    }
}

function New-CompareRow {
    param(
        [string]$Category,
        [string]$RelativePath,
        [string]$Folder1FullPath,
        [string]$Folder2FullPath,
        [string]$Type1,
        [string]$Type2,
        [object]$Size1,
        [object]$Size2,
        [object]$CreationTime1,
        [object]$CreationTime2,
        [object]$LastWriteTime1,
        [object]$LastWriteTime2,
        [string]$Reason,
        [string]$ErrorMessage
    )

    $sizeDiff = $null
    if ($null -ne $Size1 -and $null -ne $Size2) {
        $sizeDiff = [int64]$Size2 - [int64]$Size1
    }

    $timeDiffSeconds = $null
    if ($null -ne $LastWriteTime1 -and $null -ne $LastWriteTime2) {
        $timeDiffSeconds = [math]::Round((New-TimeSpan -Start ([datetime]$LastWriteTime1) -End ([datetime]$LastWriteTime2)).TotalSeconds, 0)
    }

    return [pscustomobject][ordered]@{
        判定区分        = $Category
        相対パス        = $RelativePath
        Folder1フルパス = $Folder1FullPath
        Folder2フルパス = $Folder2FullPath
        種別1           = $Type1
        種別2           = $Type2
        サイズ1         = $Size1
        サイズ2         = $Size2
        サイズ差        = $sizeDiff
        作成日時1       = Format-DateTimeValue -DateValue $CreationTime1
        作成日時2       = Format-DateTimeValue -DateValue $CreationTime2
        更新日時1       = Format-DateTimeValue -DateValue $LastWriteTime1
        更新日時2       = Format-DateTimeValue -DateValue $LastWriteTime2
        更新日時差秒    = $timeDiffSeconds
        差分理由        = $Reason
        エラー内容      = $ErrorMessage
    }
}

function Finalize-SortedOutput {
    if (-not (Test-Path -LiteralPath $script:PartialOutputCsv)) {
        return
    }

    if ($script:TotalRowsWritten -le 0) {
        $headerLine = ($script:CsvColumns -join ',')
        Set-Content -LiteralPath $OutputCsv -Value $headerLine -Encoding UTF8
        return
    }

    Import-Csv -LiteralPath $script:PartialOutputCsv |
        Sort-Object 判定区分, 相対パス |
        Export-Csv -LiteralPath $OutputCsv -NoTypeInformation -Encoding UTF8
}

try {
    $root1 = Resolve-NormalizedPath -Path $Folder1
    $root2 = Resolve-NormalizedPath -Path $Folder2
    $includeDirectories = -not $ExcludeDirectories

    Initialize-OutputCsv -Path $script:PartialOutputCsv

    $scan1 = Get-FileSystemIndex -RootPath $root1 -SideLabel 'Folder1' -IncludeDirectories $includeDirectories -ProgressId 1
    $scan2 = Get-FileSystemIndex -RootPath $root2 -SideLabel 'Folder2' -IncludeDirectories $includeDirectories -ProgressId 2

    $allKeys = @($scan1.Index.Keys) + @($scan2.Index.Keys) | Sort-Object -Unique

    $totalCompareCount = $allKeys.Count
    $compareCounter = 0
    $lastCompareTick = [Environment]::TickCount

    foreach ($key in $allKeys) {
        $compareCounter++

        $nowTick = [Environment]::TickCount
        if ((($compareCounter % 500) -eq 0) -or (($nowTick - $lastCompareTick) -ge 300)) {
            $percent = 0
            if ($totalCompareCount -gt 0) {
                $percent = [int](($compareCounter / $totalCompareCount) * 100)
            }

            Write-Progress `
                -Id 3 `
                -Activity "差分比較中" `
                -Status ("比較済み: {0:N0} / {1:N0}" -f $compareCounter, $totalCompareCount) `
                -CurrentOperation $key `
                -PercentComplete $percent

            $lastCompareTick = $nowTick
        }

        $item1 = $null
        $item2 = $null

        if ($scan1.Index.ContainsKey($key)) {
            $item1 = $scan1.Index[$key]
        }

        if ($scan2.Index.ContainsKey($key)) {
            $item2 = $scan2.Index[$key]
        }

        $category = ''
        $reason = ''

        if ($null -ne $item1 -and $null -eq $item2) {
            $category = 'Folder1のみ存在'
            $reason = 'Folder1にのみ存在'
        }
        elseif ($null -eq $item1 -and $null -ne $item2) {
            $category = 'Folder2のみ存在'
            $reason = 'Folder2にのみ存在'
        }
        else {
            if ($item1.ItemType -ne $item2.ItemType) {
                $category = '種別不一致'
                $reason = '同一相対パスでファイル/フォルダが不一致'
            }
            elseif ($item1.ItemType -eq 'Directory') {
                $category = '両方あり同一候補'
                $reason = '両方に同名フォルダあり'
            }
            else {
                $size1 = [int64]$item1.Size
                $size2 = [int64]$item2.Size
                $sizeDiff = $size2 - $size1

                $timeDiffSeconds = [math]::Round((New-TimeSpan -Start $item1.LastWriteTime -End $item2.LastWriteTime).TotalSeconds, 0)
                $sameSize = ($size1 -eq $size2)
                $sameTime = ([math]::Abs($timeDiffSeconds) -le $TimestampToleranceSeconds)

                if ($sameSize -and $sameTime) {
                    $category = '両方あり同一候補'
                    $reason = 'サイズ一致かつ更新日時差が許容範囲内'
                }
                else {
                    if (-not $sameTime) {
                        if ($timeDiffSeconds -gt 0) {
                            $category = 'Folder2が新しい候補'
                            if ($sizeDiff -gt 0) {
                                $reason = '更新日時・サイズともにFolder2側が新しい/大きい'
                            }
                            elseif ($sizeDiff -lt 0) {
                                $reason = '更新日時はFolder2が新しいが、サイズはFolder1の方が大きい'
                            }
                            else {
                                $reason = '更新日時はFolder2が新しい'
                            }
                        }
                        else {
                            $category = 'Folder1が新しい候補'
                            if ($sizeDiff -lt 0) {
                                $reason = '更新日時・サイズともにFolder1側が新しい/大きい'
                            }
                            elseif ($sizeDiff -gt 0) {
                                $reason = '更新日時はFolder1が新しいが、サイズはFolder2の方が大きい'
                            }
                            else {
                                $reason = '更新日時はFolder1が新しい'
                            }
                        }
                    }
                    else {
                        if ($sizeDiff -gt 0) {
                            $category = 'Folder2が新しい候補'
                            $reason = '更新日時差は許容範囲内だが、サイズはFolder2の方が大きい'
                        }
                        elseif ($sizeDiff -lt 0) {
                            $category = 'Folder1が新しい候補'
                            $reason = '更新日時差は許容範囲内だが、サイズはFolder1の方が大きい'
                        }
                        else {
                            $category = '両方あり同一候補'
                            $reason = 'サイズ一致・更新日時差は許容範囲内'
                        }
                    }
                }
            }
        }

        if ($DiffOnly -and $category -eq '両方あり同一候補') {
            continue
        }

        $folder1FullPath = ''
        $folder2FullPath = ''
        $type1 = 'Missing'
        $type2 = 'Missing'
        $size1Val = $null
        $size2Val = $null
        $creation1 = $null
        $creation2 = $null
        $write1 = $null
        $write2 = $null

        if ($null -ne $item1) {
            $folder1FullPath = $item1.FullPath
            $type1 = $item1.ItemType
            $size1Val = $item1.Size
            $creation1 = $item1.CreationTime
            $write1 = $item1.LastWriteTime
        }

        if ($null -ne $item2) {
            $folder2FullPath = $item2.FullPath
            $type2 = $item2.ItemType
            $size2Val = $item2.Size
            $creation2 = $item2.CreationTime
            $write2 = $item2.LastWriteTime
        }

        $row = New-CompareRow `
            -Category $category `
            -RelativePath $key `
            -Folder1FullPath $folder1FullPath `
            -Folder2FullPath $folder2FullPath `
            -Type1 $type1 `
            -Type2 $type2 `
            -Size1 $size1Val `
            -Size2 $size2Val `
            -CreationTime1 $creation1 `
            -CreationTime2 $creation2 `
            -LastWriteTime1 $write1 `
            -LastWriteTime2 $write2 `
            -Reason $reason `
            -ErrorMessage ''

        Add-OutputRow -Row $row
    }

    Write-Progress -Id 3 -Activity "差分比較中" -Completed

    foreach ($err in $scan1.Errors) {
        $row = New-CompareRow `
            -Category '比較不能' `
            -RelativePath $err.RelativePath `
            -Folder1FullPath $err.FullPath `
            -Folder2FullPath '' `
            -Type1 'Error' `
            -Type2 '' `
            -Size1 $null `
            -Size2 $null `
            -CreationTime1 $null `
            -CreationTime2 $null `
            -LastWriteTime1 $null `
            -LastWriteTime2 $null `
            -Reason 'Folder1側の読み取りに失敗' `
            -ErrorMessage $err.ErrorMessage

        Add-OutputRow -Row $row
    }

    foreach ($err in $scan2.Errors) {
        $row = New-CompareRow `
            -Category '比較不能' `
            -RelativePath $err.RelativePath `
            -Folder1FullPath '' `
            -Folder2FullPath $err.FullPath `
            -Type1 '' `
            -Type2 'Error' `
            -Size1 $null `
            -Size2 $null `
            -CreationTime1 $null `
            -CreationTime2 $null `
            -LastWriteTime1 $null `
            -LastWriteTime2 $null `
            -Reason 'Folder2側の読み取りに失敗' `
            -ErrorMessage $err.ErrorMessage

        Add-OutputRow -Row $row
    }

    Flush-CsvBuffer
    Finalize-SortedOutput

    $script:CompletedSuccessfully = $true

    Write-Host ""
    Write-Host "並び替え済みCSV出力完了: $OutputCsv" -ForegroundColor Green
    Write-Host ("CSV行数: {0:N0}" -f $script:TotalRowsWritten) -ForegroundColor Green
    Write-Host ("途中保存CSV: {0}" -f $script:PartialOutputCsv) -ForegroundColor DarkGray
    Write-Host ""
    Write-Host "件数サマリ:" -ForegroundColor Cyan

    $script:Summary.GetEnumerator() |
        Sort-Object Name |
        ForEach-Object {
            Write-Host ("  {0} : {1:N0}" -f $_.Name, $_.Value)
        }
}
catch {
    try {
        Flush-CsvBuffer
    }
    catch {
    }

    Write-Error $_.Exception.Message
    throw
}
finally {
    try {
        Flush-CsvBuffer
    }
    catch {
    }

    Write-Progress -Id 1 -Activity "Folder1 をスキャン中" -Completed
    Write-Progress -Id 2 -Activity "Folder2 をスキャン中" -Completed
    Write-Progress -Id 3 -Activity "差分比較中" -Completed

    if (-not $script:CompletedSuccessfully) {
        Write-Warning "処理は完走していません。途中までの結果は partial CSV に残っている可能性があります: $script:PartialOutputCsv"
    }
}