Public/Invoke-TfvcReplay.ps1
|
function Invoke-TfvcReplay { <# .SYNOPSIS Replays exported TFVC changesets as Git commits. .DESCRIPTION Reads changesets.json produced by Export-TfvcChangeset, downloads file content from TFVC at each changeset version, and creates a corresponding Git commit preserving author, date, comment, and work-item links. Supports checkpoint/resume and optional push to a remote. .PARAMETER ConfigPath Path to the migration config.json file. Defaults to ./config.json. .PARAMETER Resume Resume replay from the last replay-checkpoint.json. .PARAMETER Push Push to the configured remote after replay completes. .EXAMPLE Invoke-TfvcReplay -ConfigPath ./config.json -Push #> [CmdletBinding()] param( [string]$ConfigPath = "./config.json", [switch]$Resume, [switch]$Push ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # --- Bootstrap --- $config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json $outputDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($config.outputDir) $logFile = Join-Path $outputDir 'migration-log.txt' $checkpointFile = Join-Path $outputDir 'replay-checkpoint.json' $changesetsFile = Join-Path $outputDir 'changesets.json' if (-not (Test-Path $changesetsFile)) { throw "changesets.json not found at $changesetsFile. Run Export-TfvcChangeset first." } $export = Get-Content -Path $changesetsFile -Raw | ConvertFrom-Json $changesets = $export.changesets Write-MigrationLog -Message "=== Git Replay started ===" -LogFile $logFile Write-MigrationLog -Message "Total changesets in export: $($changesets.Count)" -LogFile $logFile # --- TFVC connection (for downloading files) --- $conn = New-TfvcConnection ` -ServerUrl $config.adoServerUrl ` -Collection $config.collection ` -Project $config.project ` -Pat $config.pat ` -ApiVersion $(if ($config.apiVersion) { $config.apiVersion } else { '7.0' }) # --- Git repo setup --- $repoPath = Join-Path $outputDir 'git-repo' if (-not (Test-Path (Join-Path $repoPath '.git'))) { Write-MigrationLog -Message "Initialising Git repo at $repoPath" -LogFile $logFile git init $repoPath git -C $repoPath config core.autocrlf false git -C $repoPath config core.safecrlf false if ($config.gitRemoteUrl) { git -C $repoPath remote add origin $config.gitRemoteUrl Write-MigrationLog -Message "Remote 'origin' set to $($config.gitRemoteUrl)" -LogFile $logFile } } # --- LFS availability check --- $lfsAvailable = $false try { $null = git lfs version 2>&1 if ($LASTEXITCODE -eq 0) { $lfsAvailable = $true git -C $repoPath lfs install --local 2>&1 | Out-Null Write-MigrationLog -Message "Git LFS is available and initialised" -LogFile $logFile } } catch { Write-MigrationLog -Message "Git LFS not available - large files will be committed directly" -Level WARN -LogFile $logFile } # --- Resume --- $resumeAfterId = 0 $totalReplayed = 0 if ($Resume -and (Test-Path $checkpointFile)) { $checkpoint = Get-Content $checkpointFile -Raw | ConvertFrom-Json $resumeAfterId = $checkpoint.lastChangesetId $totalReplayed = $(if ($checkpoint.totalReplayed) { $checkpoint.totalReplayed } else { 0 }) Write-MigrationLog -Message "Resuming after changeset $resumeAfterId ($totalReplayed already replayed)" -LogFile $logFile } # --- LFS helpers --- $lfsThreshold = $(if ($config.lfsThresholdBytes) { $config.lfsThresholdBytes } else { 0 }) $lfsPatterns = @($(if ($config.lfsPatterns) { $config.lfsPatterns } else { @() })) # Tracking set for patterns already in .gitattributes $trackedLfsPatterns = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $gitattributes = Join-Path $repoPath '.gitattributes' if (Test-Path $gitattributes) { Get-Content $gitattributes | ForEach-Object { if ($_ -match '^\s*(\S+)\s+filter=lfs') { $trackedLfsPatterns.Add($Matches[1]) | Out-Null } } } function Test-NeedsLfs { param( [string]$FilePath, [long]$SizeBytes ) # Size check if ($lfsThreshold -gt 0 -and $SizeBytes -ge $lfsThreshold) { return $true } # Extension pattern check $ext = [System.IO.Path]::GetExtension($FilePath) foreach ($pattern in $lfsPatterns) { # Pattern like "*.dll" - compare extension $patExt = $pattern.TrimStart('*') if ($ext -eq $patExt) { return $true } } return $false } function Add-LfsTracking { param([string]$Pattern) if ($trackedLfsPatterns.Contains($Pattern)) { return } $trackedLfsPatterns.Add($Pattern) | Out-Null if ($lfsAvailable) { Push-Location $repoPath try { git lfs track $Pattern 2>&1 | Out-Null } finally { Pop-Location } Write-MigrationLog -Message "LFS tracking added for: $Pattern" -LogFile $logFile } else { # Manually append to .gitattributes "$Pattern filter=lfs diff=lfs merge=lfs -text" | Add-Content -Path $gitattributes -Encoding UTF8 Write-MigrationLog -Message "LFS pattern added to .gitattributes (git lfs not available): $Pattern" -Level WARN -LogFile $logFile } } # --- Helper: remove file and empty parent dirs --- function Remove-FileAndEmptyParents { param([string]$FilePath) if (Test-Path $FilePath) { Remove-Item -Path $FilePath -Force } $dir = Split-Path $FilePath -Parent while ($dir -and $dir -ne $repoPath -and (Test-Path $dir)) { $children = @(Get-ChildItem -Path $dir -Force) if ($children.Count -eq 0) { Remove-Item -Path $dir -Force $dir = Split-Path $dir -Parent } else { break } } } # --- Replay loop --- $index = 0 $total = @($changesets).Count foreach ($cs in $changesets) { $index++ # Skip if already replayed if ($resumeAfterId -gt 0 -and $cs.changesetId -le $resumeAfterId) { continue } $changeCount = @($cs.changes).Count # Progress if ($totalReplayed % 50 -eq 0 -or $totalReplayed -eq 0 -or $index -eq $total) { Write-MigrationLog -Message "Replaying changeset $($cs.changesetId) ($index / $total)" -LogFile $logFile } if ($changeCount -eq 0) { Write-MigrationLog -Message "Changeset $($cs.changesetId) has 0 in-scope changes - creating empty commit" -LogFile $logFile } # --- Process each change --- foreach ($change in $cs.changes) { $destFile = Join-Path $repoPath $change.destinationPath switch ($change.changeType) { { $_ -in 'add', 'edit', 'branch', 'merge', 'undelete' } { Save-TfvcItemContent ` -Connection $conn ` -ServerPath $change.serverPath ` -OutputPath $destFile ` -ChangesetVersion $cs.changesetId } 'delete' { Remove-FileAndEmptyParents -FilePath $destFile } 'rename' { # Delete old location (compute from sourceServerPath if available) if ($change.sourceServerPath) { # Find mapping for the source path to get the old dest foreach ($m in $config.sourceMappings) { $oldDest = ConvertTo-RelativePath ` -ServerPath $change.sourceServerPath ` -TfvcBase $m.tfvcPath ` -DestinationPrefix $(if ($m.destinationPath) { $m.destinationPath } else { '' }) if ($oldDest) { $oldFile = Join-Path $repoPath $oldDest Remove-FileAndEmptyParents -FilePath $oldFile break } } } # Download at new location Save-TfvcItemContent ` -Connection $conn ` -ServerPath $change.serverPath ` -OutputPath $destFile ` -ChangesetVersion $cs.changesetId } } # LFS check for files that were downloaded if ($change.changeType -ne 'delete' -and (Test-Path $destFile)) { $fileSize = (Get-Item $destFile).Length if (Test-NeedsLfs -FilePath $destFile -SizeBytes $fileSize) { $ext = [System.IO.Path]::GetExtension($destFile) if ($ext) { Add-LfsTracking -Pattern "*$ext" } } } } # --- Stage --- git -C $repoPath add -A 2>&1 | Out-Null # --- Build commit message --- $body = $(if ($cs.comment) { $cs.comment } else { '' }) $trailer = "`n---" $trailer += "`nTFVC-Changeset: $($cs.changesetId)" $trailer += "`nTFVC-Author: $($cs.author)" $trailer += "`nTFVC-Date: $($cs.createdDate)" if ($cs.workItems -and @($cs.workItems).Count -gt 0) { $wiRefs = ($cs.workItems | ForEach-Object { "#$($_.id)" }) -join ', ' $trailer += "`nTFVC-WorkItems: $wiRefs" } $commitMsg = "$body$trailer" # Write to temp file to avoid shell escaping issues $tempMsgFile = Join-Path $outputDir "commit-msg-$($cs.changesetId).tmp" [System.IO.File]::WriteAllText($tempMsgFile, $commitMsg, [System.Text.Encoding]::UTF8) # --- Commit --- try { $env:GIT_AUTHOR_NAME = $cs.author $env:GIT_AUTHOR_EMAIL = "$($cs.author)@tfvc.local" $env:GIT_AUTHOR_DATE = $cs.createdDate $env:GIT_COMMITTER_DATE = $cs.createdDate git -C $repoPath commit -F $tempMsgFile --allow-empty 2>&1 | Out-Null } finally { Remove-Item $env:GIT_AUTHOR_NAME -ErrorAction SilentlyContinue Remove-Item $env:GIT_AUTHOR_EMAIL -ErrorAction SilentlyContinue Remove-Item $env:GIT_AUTHOR_DATE -ErrorAction SilentlyContinue Remove-Item $env:GIT_COMMITTER_DATE -ErrorAction SilentlyContinue $env:GIT_AUTHOR_NAME = $null $env:GIT_AUTHOR_EMAIL = $null $env:GIT_AUTHOR_DATE = $null $env:GIT_COMMITTER_DATE = $null } # Clean up temp file Remove-Item $tempMsgFile -ErrorAction SilentlyContinue $totalReplayed++ # --- Checkpoint every 50 --- if ($totalReplayed % 50 -eq 0) { $lastHash = (git -C $repoPath rev-parse HEAD 2>&1).Trim() @{ lastChangesetId = $cs.changesetId lastCommitHash = $lastHash totalReplayed = $totalReplayed } | ConvertTo-Json | Set-Content -Path $checkpointFile -Encoding UTF8 Write-MigrationLog -Message "Checkpoint: $totalReplayed commits replayed (changeset $($cs.changesetId))" -LogFile $logFile } } # --- Final checkpoint --- if ($totalReplayed -gt 0) { $lastHash = (git -C $repoPath rev-parse HEAD 2>&1).Trim() @{ lastChangesetId = ($changesets | Select-Object -Last 1).changesetId lastCommitHash = $lastHash totalReplayed = $totalReplayed } | ConvertTo-Json | Set-Content -Path $checkpointFile -Encoding UTF8 } Write-MigrationLog -Message "Replay complete. $totalReplayed commits created." -LogFile $logFile # --- Push --- if ($Push) { Write-MigrationLog -Message "Pushing to remote..." -LogFile $logFile $branch = (git -C $repoPath branch --show-current 2>&1).Trim() if (-not $branch) { $branch = 'main' } git -C $repoPath push -u origin $branch 2>&1 Write-MigrationLog -Message "Push complete (branch: $branch)" -LogFile $logFile } Write-MigrationLog -Message "=== Git Replay finished ===" -LogFile $logFile } |