Eigenverft.Manifested.Drydock.GitAwareCopy.ps1
|
function Copy-DirectoryTreeGitAware { <# .SYNOPSIS Copies a directory tree while respecting Git repositories found below the source path. .DESCRIPTION Copies a directory tree like a normal recursive copy, but treats Git repositories specially. The command is intended for fast backup or staging copies of large directory trees. Files and folders outside Git repositories are copied normally. When a Git repository is found, its .git metadata is not copied and files ignored by that repository's .gitignore files are left out. Git is used only to determine which repository files belong in the copy. Global Git excludes and .git/info/exclude are not used. Existing tracked files are copied even when they also match an ignore rule. The destination uses the same transit.destination.lock lease name as Sync-DirectoryTreeLockSafe. This prevents two compatible Drydock copy operations from writing to the same destination at the same time. The source is not locked or modified. Existing destination files can be skipped when they already match the selected comparison policy. No files or directories are removed from the destination. Long-running copies write a compact progress line after about 15 seconds and then about every 15 seconds. Large files also write a status line before the copy starts. This uses normal host output so progress remains readable in CI logs; no Write-Progress UI is used. .PARAMETER SourceDirectory The directory tree to copy. .PARAMETER DestinationDirectory The directory that receives the copied tree. .PARAMETER CopyRetryCount Number of retry attempts after the first failed file copy. Default is 3. .PARAMETER CopyRetryDelaySeconds Delay between file copy retries. Default is 5 seconds. .PARAMETER CopyComparisonPolicy Controls when an existing destination file can be skipped. Default is LengthAndLastWriteTimeUtc. .PARAMETER LockRetryCount Number of retries while the destination is leased by another compatible Drydock operation. .PARAMETER LockRetryDelaySeconds Delay between destination lease retries. .PARAMETER LockTokenExpiryHours Age after which a stale destination lease may be removed. Default is 12 hours. Local leases created by this command may be removed sooner when their recorded owner process is no longer running. .PARAMETER LogDetailLevel Summary writes start, progress, status, warning, error, and final summary messages. Detailed also writes repository, lease, retry, and skip details. .EXAMPLE Copy-DirectoryTreeGitAware -SourceDirectory 'D:\Programs\Eigenverft.App.McpServer' -DestinationDirectory 'E:\Backup\Eigenverft.App.McpServer' Creates a fast backup-style copy. Normal directories are copied recursively; Git metadata and files ignored by repository .gitignore files are left out. .EXAMPLE Copy-DirectoryTreeGitAware -SourceDirectory 'D:\Workspace' -DestinationDirectory 'E:\WorkspaceBackup' -CopyComparisonPolicy Length Uses file length to skip destination files that already match the source length. .NOTES This is the first implementation draft. Directory reparse points are skipped and reported instead of being followed, to avoid recursively leaving the requested source tree through junctions or links. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$SourceDirectory, [Parameter(Mandatory = $true)] [string]$DestinationDirectory, [Parameter()] [ValidateRange(0, 100)] [int]$CopyRetryCount = 3, [Parameter()] [ValidateRange(0, 3600)] [int]$CopyRetryDelaySeconds = 5, [Parameter()] [ValidateSet('None', 'Length', 'LengthAndLastWriteTimeUtc', 'Sha256Hash', 'Md5FastHash')] [string]$CopyComparisonPolicy = 'LengthAndLastWriteTimeUtc', [Parameter()] [ValidateRange(0, 100)] [int]$LockRetryCount = 10, [Parameter()] [ValidateRange(0, 3600)] [int]$LockRetryDelaySeconds = 30, [Parameter()] [ValidateRange(1, 87600)] [int]$LockTokenExpiryHours = 12, [Parameter()] [ValidateSet('Detailed', 'Summary')] [string]$LogDetailLevel = 'Summary' ) $gitCommand = Get-Command git -ErrorAction SilentlyContinue if ($null -eq $gitCommand) { throw "Git is required for Copy-DirectoryTreeGitAware but was not found in PATH." } if (-not (Test-Path -LiteralPath $SourceDirectory -PathType Container)) { throw "Source directory '$SourceDirectory' does not exist or is not a directory." } $sourceRootResolved = (Resolve-Path -LiteralPath $SourceDirectory).ProviderPath $destinationRootFull = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DestinationDirectory) $sourceCompare = $sourceRootResolved.TrimEnd('\', '/') $destinationCompare = $destinationRootFull.TrimEnd('\', '/') $sourcePrefix = $sourceCompare + [System.IO.Path]::DirectorySeparatorChar if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { $pathComparison = [System.StringComparison]::OrdinalIgnoreCase $pathComparer = [System.StringComparer]::OrdinalIgnoreCase } else { $pathComparison = [System.StringComparison]::Ordinal $pathComparer = [System.StringComparer]::Ordinal } if ([string]::Equals($sourceCompare, $destinationCompare, $pathComparison)) { throw 'Source and destination paths must be different.' } if ($destinationCompare.StartsWith($sourcePrefix, $pathComparison)) { throw "Destination '$destinationRootFull' must not be inside source '$sourceRootResolved'." } [System.IO.Directory]::CreateDirectory($destinationRootFull) | Out-Null $destinationRootResolved = (Resolve-Path -LiteralPath $destinationRootFull).ProviderPath $stats = [PSCustomObject]@{ DirectoriesVisited = 0L DirectoriesCreated = 0L GitRepositories = 0L GitMetadataSkipped = 0L FilesProcessed = 0L FilesCopied = 0L FilesUnchanged = 0L MissingSourceFiles = 0L CopyRetries = 0L ReparsePointsSkipped = 0L } $processedRepositories = New-Object 'System.Collections.Generic.HashSet[string]' ($pathComparer) $destinationLockFileName = 'transit.destination.lock' $sourceLockBaseName = 'transit.source' $destinationLockPath = Join-Path -Path $destinationRootResolved -ChildPath $destinationLockFileName $destinationLockToken = [Guid]::NewGuid().ToString('N') $currentProcess = [System.Diagnostics.Process]::GetCurrentProcess() try { $currentProcessStartUtc = $currentProcess.StartTime.ToUniversalTime() } finally { $currentProcess.Dispose() } $destinationLeaseState = [PSCustomObject]@{ Acquired = $false } $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $progressState = [PSCustomObject]@{ IntervalMilliseconds = 15000L NextAtMilliseconds = 15000L } $largeFileSizeThresholdBytes = [long](200 * 1024 * 1024) function local:_Write-StandardMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] # This function is globally exempt from the GENERAL POWERSHELL REQUIREMENTS unless explicitly stated otherwise. [CmdletBinding()] param( [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Message, [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$Level='INF', [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$MinLevel, [Parameter()][switch]$NoThrow ) if ($null -eq $Message) { $Message = [string]::Empty } $sevMap=@{TRC=0;DBG=1;INF=2;WRN=3;ERR=4;FTL=5} if(-not $PSBoundParameters.ContainsKey('MinLevel')){ $gv=Get-Variable ConsoleLogMinLevel -Scope Global -ErrorAction SilentlyContinue $MinLevel=if($gv -and $gv.Value -and -not [string]::IsNullOrEmpty([string]$gv.Value)){[string]$gv.Value}else{'INF'} } $lvl=$Level.ToUpperInvariant() $min=$MinLevel.ToUpperInvariant() $sev=$sevMap[$lvl];if($null -eq $sev){$lvl='INF';$sev=$sevMap['INF']} $gate=$sevMap[$min];if($null -eq $gate){$min='INF';$gate=$sevMap['INF']} if($sev -ge 4 -and $sev -lt $gate -and $gate -ge 4){$lvl=$min;$sev=$gate} if($sev -lt $gate){return} $ts=[DateTime]::UtcNow.ToString('yy-MM-dd HH:mm:ss.ff') $stack=Get-PSCallStack ; $helperName=$MyInvocation.MyCommand.Name ; $helperScript=$MyInvocation.MyCommand.ScriptBlock.File ; $caller=$null if($stack){ # 1: prefer first non-underscore function not defined in the helper's own file for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_') -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break} } # 2: fallback to first non-underscore function (any file) if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_')){$caller=$f;break} } } # 3: fallback to first non-helper frame not from helper's own file if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName if($fn -and $fn -ne $helperName -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break} } } # 4: final fallback to first non-helper frame if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName if($fn -and $fn -ne $helperName){$caller=$f;break} } } } if(-not $caller){$caller=[pscustomobject]@{ScriptName=$PSCommandPath;FunctionName=$null}} $lineNumber=$null ; $p=$caller.PSObject.Properties['ScriptLineNumber'];if($p -and $p.Value){$lineNumber=[string]$p.Value} if(-not $lineNumber){ $p=$caller.PSObject.Properties['Position'] if($p -and $p.Value){ $sp=$p.Value.PSObject.Properties['StartLineNumber'];if($sp -and $sp.Value){$lineNumber=[string]$sp.Value} } } if(-not $lineNumber){ $p=$caller.PSObject.Properties['Location'] if($p -and $p.Value){ $m=[regex]::Match([string]$p.Value,':(\d+)\s+char:','IgnoreCase');if($m.Success -and $m.Groups.Count -gt 1){$lineNumber=$m.Groups[1].Value} } } $file=if($caller.ScriptName){Split-Path -Leaf $caller.ScriptName}else{'cmd'} if($file -ne 'console' -and $lineNumber){$file="{0}:{1}" -f $file,$lineNumber} $prefix="[$ts " $suffix="] [$file] $Message" $cfg=@{TRC=@{Fore='DarkGray';Back=$null};DBG=@{Fore='Cyan';Back=$null};INF=@{Fore='Green';Back=$null};WRN=@{Fore='Yellow';Back=$null};ERR=@{Fore='Red';Back=$null};FTL=@{Fore='Red';Back='DarkRed'}}[$lvl] $fore=$cfg.Fore $back=$cfg.Back $isInteractive = [System.Environment]::UserInteractive if($isInteractive -and ($fore -or $back)){ Write-Host -NoNewline $prefix if($fore -and $back){Write-Host -NoNewline $lvl -ForegroundColor $fore -BackgroundColor $back} elseif($fore){Write-Host -NoNewline $lvl -ForegroundColor $fore} elseif($back){Write-Host -NoNewline $lvl -BackgroundColor $back} Write-Host $suffix } else { Write-Host "$prefix$lvl$suffix" } if($sev -ge 4 -and -not $NoThrow -and $ErrorActionPreference -eq 'Stop'){throw ("ConsoleLog.{0}: {1}" -f $lvl,$Message)} } function local:_Write-ProgressMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([string]$CurrentPath) $elapsedMilliseconds = $stopwatch.ElapsedMilliseconds if ($elapsedMilliseconds -lt $progressState.NextAtMilliseconds) { return } $currentSuffix = '' if (-not [string]::IsNullOrWhiteSpace($CurrentPath)) { $currentSuffix = " Current: '$CurrentPath'." } _Write-StandardMessage -Message ( "[PROGRESS] Running {0}. Directories visited: {1}; Git repos: {2}; files processed/copied/unchanged: {3}/{4}/{5}; retries: {6}.{7}" -f $stopwatch.Elapsed.ToString('hh\:mm\:ss'), $stats.DirectoriesVisited, $stats.GitRepositories, $stats.FilesProcessed, $stats.FilesCopied, $stats.FilesUnchanged, $stats.CopyRetries, $currentSuffix ) while ($progressState.NextAtMilliseconds -le $elapsedMilliseconds) { $progressState.NextAtMilliseconds = $progressState.NextAtMilliseconds + $progressState.IntervalMilliseconds } } function local:_Write-DetailMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([Parameter(Mandatory = $true)][string]$Message) if ($LogDetailLevel -eq 'Detailed') { _Write-StandardMessage -Message ("[DETAIL] {0}" -f $Message) -Level 'DBG' } } function local:_Ensure-Directory { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Container)) { [System.IO.Directory]::CreateDirectory($Path) | Out-Null $stats.DirectoriesCreated = $stats.DirectoriesCreated + 1 _Write-DetailMessage "Created directory '$Path'." } } function local:_Remove-StaleTransitLockIfExpired { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([Parameter(Mandatory = $true)][string]$LockFilePath) if (-not (Test-Path -LiteralPath $LockFilePath -PathType Leaf -ErrorAction SilentlyContinue)) { return $false } $rawContent = $null $expiresUtc = $null $ownerMachineName = $null $ownerProcessId = 0 $ownerProcessStartUtc = $null $staleOwnerReason = $null try { $rawContent = Get-Content -LiteralPath $LockFilePath -Raw -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace($rawContent)) { try { $jsonObject = ConvertFrom-Json -InputObject $rawContent -ErrorAction Stop $expiresProperty = $jsonObject.PSObject.Properties['ExpiresUtc'] if ($null -ne $expiresProperty -and $null -ne $expiresProperty.Value) { $expiresUtc = [DateTime]::Parse( [string]$expiresProperty.Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal ) } } catch { _Write-DetailMessage "Transit lease '$LockFilePath' has no usable ExpiresUtc value; falling back to file timestamps." } } if ($null -eq $expiresUtc) { $lockInfo = Get-Item -LiteralPath $LockFilePath -Force -ErrorAction Stop $timestamp = $lockInfo.LastWriteTimeUtc if ($lockInfo.CreationTimeUtc -gt $timestamp) { $timestamp = $lockInfo.CreationTimeUtc } $expiresUtc = $timestamp.AddHours($LockTokenExpiryHours) } } catch { $expiresUtc = [DateTime]::UtcNow.AddSeconds(-1) } # New local leases carry process identity; legacy and remote leases continue to use expiry only. if (-not [string]::IsNullOrWhiteSpace($rawContent)) { try { $ownerJson = ConvertFrom-Json -InputObject $rawContent -ErrorAction Stop $machineProperty = $ownerJson.PSObject.Properties['MachineName'] $processIdProperty = $ownerJson.PSObject.Properties['ProcessId'] $processStartProperty = $ownerJson.PSObject.Properties['ProcessStartUtc'] if ($null -ne $machineProperty -and $null -ne $machineProperty.Value -and $null -ne $processIdProperty -and $null -ne $processIdProperty.Value -and $null -ne $processStartProperty -and $null -ne $processStartProperty.Value) { $ownerMachineName = [string]$machineProperty.Value $ownerProcessId = [int]$processIdProperty.Value $ownerProcessStartUtc = [DateTime]::Parse( [string]$processStartProperty.Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal ) } } catch { _Write-DetailMessage "Could not read optional process identity from transit lease '$LockFilePath'; expiry remains authoritative. $($_.Exception.Message)" } } if (-not [string]::IsNullOrWhiteSpace($ownerMachineName) -and [string]::Equals($ownerMachineName, [System.Environment]::MachineName, [System.StringComparison]::OrdinalIgnoreCase) -and $ownerProcessId -gt 0 -and $null -ne $ownerProcessStartUtc) { $ownerProcess = $null try { $ownerProcess = [System.Diagnostics.Process]::GetProcessById($ownerProcessId) $observedProcessStartUtc = $ownerProcess.StartTime.ToUniversalTime() if ([Math]::Abs(($observedProcessStartUtc - $ownerProcessStartUtc).TotalSeconds) -gt 1) { $staleOwnerReason = "local owner PID $ownerProcessId was reused" } } catch [System.ArgumentException] { $staleOwnerReason = "local owner PID $ownerProcessId is no longer running" } catch [System.InvalidOperationException] { $staleOwnerReason = "local owner PID $ownerProcessId is no longer running" } catch { _Write-DetailMessage "Could not verify local owner PID $ownerProcessId for transit lease '$LockFilePath'; expiry remains authoritative. $($_.Exception.Message)" } finally { if ($null -ne $ownerProcess) { $ownerProcess.Dispose() } } } if ($null -eq $staleOwnerReason -and [DateTime]::UtcNow -le $expiresUtc) { return $false } if ($null -eq $staleOwnerReason) { $staleOwnerReason = "lease expiry passed at $($expiresUtc.ToString('o'))" } Remove-Item -LiteralPath $LockFilePath -Force -ErrorAction Stop _Write-StandardMessage -Message "[WRN] Removed stale transit lease '$LockFilePath': $staleOwnerReason." -Level 'WRN' return $true } function local:_Acquire-DestinationLease { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param() $attempt = 0 while ($true) { $writerExists = Test-Path -LiteralPath $destinationLockPath -PathType Leaf -ErrorAction SilentlyContinue if ($writerExists) { if (_Remove-StaleTransitLockIfExpired -LockFilePath $destinationLockPath) { $writerExists = $false } } $readerExists = $false $readerItems = @(Get-ChildItem -LiteralPath $destinationRootResolved -File -Force -ErrorAction SilentlyContinue) foreach ($readerItem in $readerItems) { if ([string]::Equals($readerItem.Name, $sourceLockBaseName, $pathComparison) -or $readerItem.Name.StartsWith($sourceLockBaseName + '.', $pathComparison)) { if (-not (_Remove-StaleTransitLockIfExpired -LockFilePath $readerItem.FullName)) { $readerExists = $true } } } if (-not $writerExists -and -not $readerExists) { $stream = $null try { $stream = New-Object System.IO.FileStream( $destinationLockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None ) $nowUtc = [DateTime]::UtcNow $payload = [PSCustomObject]@{ CreatedUtc = $nowUtc.ToString('o') ExpiresUtc = $nowUtc.AddHours($LockTokenExpiryHours).ToString('o') MachineName = [System.Environment]::MachineName ProcessId = $PID ProcessStartUtc = $currentProcessStartUtc.ToString('o') Token = $destinationLockToken } $json = $payload | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush() $destinationLeaseState.Acquired = $true _Write-DetailMessage "Acquired destination lease '$destinationLockPath'." return } catch [System.IO.IOException] { _Write-DetailMessage 'Destination lease CreateNew race detected; retrying through the normal lease checks.' } finally { if ($null -ne $stream) { $stream.Dispose() } } } if ($attempt -ge $LockRetryCount) { $leaseFailureMessage = "Destination '$destinationRootResolved' is still leased after $LockRetryCount retries; copy was not started." _Write-StandardMessage -Message "[ERR] $leaseFailureMessage" -Level 'ERR' -NoThrow throw $leaseFailureMessage } $attempt = $attempt + 1 _Write-StandardMessage -Message "[ERR] Destination '$destinationRootResolved' is leased; copy is blocked. Retrying in $LockRetryDelaySeconds seconds (attempt $attempt of $LockRetryCount)." -Level 'ERR' -NoThrow if ($LockRetryDelaySeconds -gt 0) { Start-Sleep -Seconds $LockRetryDelaySeconds } _Write-ProgressMessage -CurrentPath $destinationRootResolved } } function local:_Release-DestinationLease { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param() if (-not $destinationLeaseState.Acquired) { return } if (-not (Test-Path -LiteralPath $destinationLockPath -PathType Leaf -ErrorAction SilentlyContinue)) { return } try { $rawContent = Get-Content -LiteralPath $destinationLockPath -Raw -ErrorAction Stop $jsonObject = ConvertFrom-Json -InputObject $rawContent -ErrorAction Stop $tokenProperty = $jsonObject.PSObject.Properties['Token'] if ($null -eq $tokenProperty -or [string]$tokenProperty.Value -ne $destinationLockToken) { _Write-StandardMessage -Message "[WRN] Destination lease '$destinationLockPath' changed owner; it will not be removed by this process." -Level 'WRN' return } Remove-Item -LiteralPath $destinationLockPath -Force -ErrorAction Stop _Write-DetailMessage "Released destination lease '$destinationLockPath'." } catch { _Write-StandardMessage -Message "[WRN] Failed to release destination lease '$destinationLockPath': $($_.Exception.Message)" -Level 'WRN' } } function local:_Test-DestinationFileMatch { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] [Diagnostics.CodeAnalysis.SuppressMessage("PSAvoidUsingBrokenHashAlgorithms","")] param( [Parameter(Mandatory = $true)][string]$SourceFullPath, [Parameter(Mandatory = $true)][string]$DestinationFullPath ) if ($CopyComparisonPolicy -eq 'None' -or -not (Test-Path -LiteralPath $DestinationFullPath -PathType Leaf)) { return $false } $sourceInfo = Get-Item -LiteralPath $SourceFullPath -Force -ErrorAction Stop $destinationInfo = Get-Item -LiteralPath $DestinationFullPath -Force -ErrorAction Stop switch ($CopyComparisonPolicy) { 'Length' { return ([long]$sourceInfo.Length -eq [long]$destinationInfo.Length) } 'LengthAndLastWriteTimeUtc' { return (([long]$sourceInfo.Length -eq [long]$destinationInfo.Length) -and ($sourceInfo.LastWriteTimeUtc -eq $destinationInfo.LastWriteTimeUtc)) } 'Sha256Hash' { $sourceHash = (Get-FileHash -LiteralPath $SourceFullPath -Algorithm SHA256 -ErrorAction Stop).Hash $destinationHash = (Get-FileHash -LiteralPath $DestinationFullPath -Algorithm SHA256 -ErrorAction Stop).Hash return ($sourceHash -eq $destinationHash) } 'Md5FastHash' { $sourceHash = (Get-FileHash -LiteralPath $SourceFullPath -Algorithm MD5 -ErrorAction Stop).Hash $destinationHash = (Get-FileHash -LiteralPath $DestinationFullPath -Algorithm MD5 -ErrorAction Stop).Hash return ($sourceHash -eq $destinationHash) } } return $false } function local:_Copy-OneFile { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [Parameter(Mandatory = $true)][string]$SourceFullPath, [Parameter(Mandatory = $true)][string]$DestinationFullPath ) $stats.FilesProcessed = $stats.FilesProcessed + 1 _Write-ProgressMessage -CurrentPath $SourceFullPath try { if (_Test-DestinationFileMatch -SourceFullPath $SourceFullPath -DestinationFullPath $DestinationFullPath) { $stats.FilesUnchanged = $stats.FilesUnchanged + 1 _Write-DetailMessage "Skipped unchanged file '$SourceFullPath'." return } } catch { _Write-DetailMessage "Comparison failed for '$SourceFullPath'; proceeding with copy. $($_.Exception.Message)" } $destinationParent = Split-Path -Path $DestinationFullPath -Parent if (-not [string]::IsNullOrEmpty($destinationParent)) { _Ensure-Directory -Path $destinationParent } try { $sourceInfoBeforeCopy = Get-Item -LiteralPath $SourceFullPath -Force -ErrorAction Stop if ([long]$sourceInfoBeforeCopy.Length -ge $largeFileSizeThresholdBytes) { $sizeMB = [Math]::Round(([double]$sourceInfoBeforeCopy.Length / 1MB), 1) _Write-StandardMessage -Message "[STATUS] Copying large file (about $sizeMB MB), this may take a while: '$SourceFullPath'." } } catch { _Write-DetailMessage "Could not read file size before copy for '$SourceFullPath'. $($_.Exception.Message)" } $maxAttempts = $CopyRetryCount + 1 $attempt = 0 while ($attempt -lt $maxAttempts) { try { Copy-Item -LiteralPath $SourceFullPath -Destination $DestinationFullPath -Force -ErrorAction Stop try { $sourceInfoAfterCopy = Get-Item -LiteralPath $SourceFullPath -Force -ErrorAction Stop $destinationInfoAfterCopy = Get-Item -LiteralPath $DestinationFullPath -Force -ErrorAction Stop $destinationAttributes = $destinationInfoAfterCopy.Attributes $readOnlyFlag = [System.IO.FileAttributes]::ReadOnly $readOnlyWasCleared = (($destinationAttributes -band $readOnlyFlag) -ne 0) # Copy-Item preserves ReadOnly; temporarily clear it so Windows permits timestamp updates. try { if ($readOnlyWasCleared) { $writableAttributes = [System.IO.FileAttributes]([int]$destinationAttributes -bxor [int]$readOnlyFlag) [System.IO.File]::SetAttributes($DestinationFullPath, $writableAttributes) } [System.IO.File]::SetLastWriteTimeUtc($DestinationFullPath, $sourceInfoAfterCopy.LastWriteTimeUtc) [System.IO.File]::SetCreationTimeUtc($DestinationFullPath, $sourceInfoAfterCopy.CreationTimeUtc) } finally { if ($readOnlyWasCleared) { [System.IO.File]::SetAttributes($DestinationFullPath, $destinationAttributes) } } } catch { _Write-StandardMessage -Message "[WRN] Copied '$SourceFullPath' but failed to preserve timestamps or file attributes: $($_.Exception.Message)" -Level 'WRN' } $stats.FilesCopied = $stats.FilesCopied + 1 _Write-ProgressMessage -CurrentPath $SourceFullPath return } catch { $attempt = $attempt + 1 if ($attempt -ge $maxAttempts) { throw "Failed to copy '$SourceFullPath' to '$DestinationFullPath' after $maxAttempts attempts. $($_.Exception.Message)" } $stats.CopyRetries = $stats.CopyRetries + 1 _Write-DetailMessage "Copy attempt $attempt of $maxAttempts failed for '$SourceFullPath'; waiting $CopyRetryDelaySeconds seconds." if ($CopyRetryDelaySeconds -gt 0) { Start-Sleep -Seconds $CopyRetryDelaySeconds } } } } function local:_Get-GitRepositoryFileList { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([Parameter(Mandatory = $true)][string]$RepositoryPath) $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $gitCommand.Source $startInfo.WorkingDirectory = $RepositoryPath $startInfo.Arguments = 'ls-files --cached --others --exclude-per-directory=.gitignore -z' $startInfo.UseShellExecute = $false $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.CreateNoWindow = $true try { $startInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $startInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8 } catch { _Write-DetailMessage 'Redirected Git stream encoding could not be set explicitly on this runtime.' } $process = New-Object System.Diagnostics.Process $process.StartInfo = $startInfo try { if (-not $process.Start()) { throw "Failed to start Git for repository '$RepositoryPath'." } $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() if ($process.ExitCode -ne 0) { throw "Git failed for repository '$RepositoryPath' with exit code $($process.ExitCode). $stderr" } return @($stdout -split "`0" | Where-Object { -not [string]::IsNullOrEmpty($_) }) } finally { $process.Dispose() } } function local:_Copy-GitRepository { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [Parameter(Mandatory = $true)][string]$RepositorySourcePath, [Parameter(Mandatory = $true)][string]$RepositoryDestinationPath ) $repositoryResolved = (Resolve-Path -LiteralPath $RepositorySourcePath).ProviderPath if (-not $processedRepositories.Add($repositoryResolved)) { return } $stats.GitRepositories = $stats.GitRepositories + 1 $stats.GitMetadataSkipped = $stats.GitMetadataSkipped + 1 _Write-DetailMessage "Git repository found at '$repositoryResolved'." _Ensure-Directory -Path $RepositoryDestinationPath $repositoryFiles = @(_Get-GitRepositoryFileList -RepositoryPath $repositoryResolved) _Write-ProgressMessage -CurrentPath $repositoryResolved foreach ($relativeGitPath in $repositoryFiles) { $relativeFileSystemPath = $relativeGitPath.Replace('/', [System.IO.Path]::DirectorySeparatorChar) $sourcePath = Join-Path -Path $repositoryResolved -ChildPath $relativeFileSystemPath $destinationPath = Join-Path -Path $RepositoryDestinationPath -ChildPath $relativeFileSystemPath if (Test-Path -LiteralPath $sourcePath -PathType Leaf) { _Copy-OneFile -SourceFullPath $sourcePath -DestinationFullPath $destinationPath continue } if (Test-Path -LiteralPath $sourcePath -PathType Container) { $nestedGitMarker = Join-Path -Path $sourcePath -ChildPath '.git' if (Test-Path -LiteralPath $nestedGitMarker) { _Copy-GitRepository -RepositorySourcePath $sourcePath -RepositoryDestinationPath $destinationPath } continue } $stats.MissingSourceFiles = $stats.MissingSourceFiles + 1 _Write-DetailMessage "Git listed '$sourcePath', but the file no longer exists; skipping it." } } function local:_Walk-Directory { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [Parameter(Mandatory = $true)][string]$CurrentSourcePath, [Parameter(Mandatory = $true)][string]$CurrentDestinationPath ) $stats.DirectoriesVisited = $stats.DirectoriesVisited + 1 _Write-ProgressMessage -CurrentPath $CurrentSourcePath $gitMarker = Join-Path -Path $CurrentSourcePath -ChildPath '.git' if (Test-Path -LiteralPath $gitMarker) { _Copy-GitRepository -RepositorySourcePath $CurrentSourcePath -RepositoryDestinationPath $CurrentDestinationPath return } _Ensure-Directory -Path $CurrentDestinationPath $items = @(Get-ChildItem -LiteralPath $CurrentSourcePath -Force -ErrorAction Stop) foreach ($item in $items) { if ([string]::Equals($item.Name, '.git', $pathComparison)) { continue } $destinationPath = Join-Path -Path $CurrentDestinationPath -ChildPath $item.Name if ($item.PSIsContainer) { if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { $stats.ReparsePointsSkipped = $stats.ReparsePointsSkipped + 1 _Write-StandardMessage -Message "[WRN] Skipping directory reparse point '$($item.FullName)' in first implementation draft." -Level 'WRN' continue } _Walk-Directory -CurrentSourcePath $item.FullName -CurrentDestinationPath $destinationPath } else { _Copy-OneFile -SourceFullPath $item.FullName -DestinationFullPath $destinationPath } } } _Write-StandardMessage -Message '--- Copy-DirectoryTreeGitAware: Git-aware backup or staging copy ---' -Level 'INF' _Write-StandardMessage -Message "[STATUS] From: $sourceRootResolved" -Level 'INF' _Write-StandardMessage -Message "[STATUS] To: $destinationRootResolved" -Level 'INF' _Write-StandardMessage -Message "[STATUS] Comparison policy: $CopyComparisonPolicy" -Level 'INF' try { _Acquire-DestinationLease _Walk-Directory -CurrentSourcePath $sourceRootResolved -CurrentDestinationPath $destinationRootResolved $stopwatch.Stop() _Write-StandardMessage -Message ( "[SUMMARY] Completed in {0}. Directories visited/created: {1}/{2}; Git repos: {3}; files processed/copied/unchanged/missing: {4}/{5}/{6}/{7}; retries: {8}; reparse points skipped: {9}. From: {10} To: {11}" -f $stopwatch.Elapsed, $stats.DirectoriesVisited, $stats.DirectoriesCreated, $stats.GitRepositories, $stats.FilesProcessed, $stats.FilesCopied, $stats.FilesUnchanged, $stats.MissingSourceFiles, $stats.CopyRetries, $stats.ReparsePointsSkipped, $SourceDirectory, $DestinationDirectory ) } finally { if ($stopwatch.IsRunning) { $stopwatch.Stop() } _Release-DestinationLease } } |