src/Public/Backup-Project.ps1
|
<#
.SYNOPSIS Cleans and archives a RenderKit project. .DESCRIPTION Resolves a project, removes configured artifacts, optionally removes empty folders, creates a ZIP backup, verifies archive content integrity, injects log files into the archive, writes a backup manifest, and optionally removes the source project folder. Supports `-WhatIf` / `-Confirm` via `SupportsShouldProcess`. .PARAMETER ProjectName Name of the project folder to back up. .PARAMETER Path Project root directory that contains the project folder. If omitted, the default path from RenderKit config is used. .PARAMETER Profile Cleanup profile names used to decide which artifacts are removed before archiving. .PARAMETER DestinationRoot Destination directory for the created backup archive. If omitted, the parent directory of the project root is used. .PARAMETER KeepEmptyFolders Keeps empty folders after cleanup when set. .PARAMETER KeepSourceProject Keeps the source project folder after backup. If omitted, source project folder is removed after successful backup. .PARAMETER DryRun Simulates cleanup and archive operations without changing files. .EXAMPLE Backup-Project -ProjectName "ClientA_2026" Backs up project `ClientA_2026` from the configured default project root. .EXAMPLE Backup-Project -ProjectName "ClientA_2026" -Path "D:\Projects" -Profile DaVinci -DryRun Simulates a DaVinci-focused backup for the given path. .EXAMPLE Backup-Project -ProjectName "ClientA_2026" -Path "D:\Projects" -DestinationRoot "E:\Backups" -KeepEmptyFolders -Confirm Runs backup and asks for confirmation because of `SupportsShouldProcess`. .EXAMPLE Backup-Project -ProjectName "ClientA_2026" -KeepSourceProject Runs backup but keeps the source project folder. .INPUTS None. You cannot pipe input to this command. .OUTPUTS System.Management.Automation.PSCustomObject Returns project and backup result data (project id, source path, backup path, source removal flag, dry-run flag). .LINK Set-ProjectRoot .LINK New-Project .LINK https://github.com/djtroi/RenderKit #> Register-RenderKitFunction "Backup-Project" function Backup-Project{ [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)] [string]$ProjectName, [string]$Path, [Alias("Software")] [string[]]$Profile = @("General"), [string]$DestinationRoot, [switch]$KeepEmptyFolders, [switch]$KeepSourceProject, [switch]$DryRun ) Write-RenderKitLog -Level Info -Message "Starting backup for project '$ProjectName'." Write-RenderKitLog -Level Debug -Message ( "Parameters: Path='{0}' Profile='{1}' DestinationRoot='{2}' KeepEmptyFolders='{3}' KeepSourceProject='{4}' DryRun='{5}'." -f $Path, ($Profile -join ","), $DestinationRoot, $KeepEmptyFolders, $KeepSourceProject, $DryRun ) $config = Get-RenderKitConfig if ([string]::IsNullOrWhiteSpace($Path)) { if ([string]::IsNullOrWhiteSpace([string]$config.DefaultProjectPath)) { Write-RenderKitLog -Level Error -Message "No project path was provided and no default project path is configured." throw "No project path was provided and no default project path is configured." } $Path = [string]$config.DefaultProjectPath Write-RenderKitLog -Level Info -Message "Using default project path '$Path'." } $project = Get-RenderKitProject -ProjectName $ProjectName -Path $Path $projectRoot = [string]$project.RootPath $rules = Get-CleanupRule -Profile $Profile $startedAt = Get-Date $archiveDescriptor = Resolve-BackupArchivePath ` -Project $project ` -DestinationRoot $DestinationRoot ` -Timestamp $startedAt $actionDescription = if ($KeepSourceProject) { "Clean project artifacts, create backup archive '$($archiveDescriptor.ArchivePath)', verify integrity, inject logs into archive, and keep source project folder" } else { "Clean project artifacts, create backup archive '$($archiveDescriptor.ArchivePath)', verify integrity, inject logs into archive, and remove source project folder" } if (-not $PSCmdlet.ShouldProcess($projectRoot, $actionDescription)) { return $null } $lockHandle = $null $manifestPath = $null $manifestArchiveEntryPath = $null $sourceRemoved = $false $integrityCheck = $null $sourceIntegrityIndex = $null $archiveLogInjection = [PSCustomObject]@{ AddedCount = 0 AddedEntries = @() } try { if (-not $DryRun) { if (-not (Test-Path -Path $archiveDescriptor.DestinationRoot -PathType Container)) { New-Item -ItemType Directory -Path $archiveDescriptor.DestinationRoot -Force | Out-Null } $lockHandle = Get-BackupLock -ProjectRoot $projectRoot Initialize-RenderKitLogging -ProjectRoot $projectRoot } else { Write-RenderKitLog -Level Info -Message "DryRun mode: no files will be modified, created, or deleted." } $statsBefore = Get-BackupProjectStatistic -ProjectPath $projectRoot Write-RenderKitLog -Level Info -Message "Cleaning project artifacts..." $artifactCleanup = Remove-ProjectArtifact ` -ProjectPath $projectRoot ` -Rules $rules ` -DryRun:$DryRun $emptyFolderCleanup = [PSCustomObject]@{ CandidateCount = 0 RemovedCount = 0 FailedCount = 0 Mode = if ($DryRun) { "DryRun" } else { "Execute" } Skipped = $true } if (-not $KeepEmptyFolders) { $emptyFolderCleanup = Remove-EmptyFolder -Path $projectRoot -DryRun:$DryRun Add-Member -InputObject $emptyFolderCleanup -NotePropertyName Skipped -NotePropertyValue $false -Force } if (-not $DryRun) { $sourceIntegrityIndex = Get-BackupFileHashIndex ` -RootPath $projectRoot ` -BasePath $projectRoot ` -Algorithm "SHA256" } $archiveInfo = @{ destinationRoot = $archiveDescriptor.DestinationRoot fileName = $archiveDescriptor.ArchiveFileName path = $archiveDescriptor.ArchivePath created = $false sourceRemoved = $false exists = $false sizeBytes = [int64]0 hashAlgorithm = $null hash = $null } if (-not $DryRun) { $archiveResult = Compress-Project ` -ProjectPath $projectRoot ` -DestinationPath $archiveDescriptor.ArchivePath $archiveInfo.created = $true $archiveInfo.exists = Test-Path -Path $archiveDescriptor.ArchivePath -PathType Leaf $archiveInfo.sizeBytes = [int64]$archiveResult.SizeBytes $archiveInfo.hashAlgorithm = [string]$archiveResult.HashAlgorithm $archiveInfo.hash = [string]$archiveResult.Hash Write-RenderKitLog -Level Info -Message "Backup archive created: $($archiveDescriptor.ArchivePath)" $integrityCheck = Test-BackupArchiveContentIntegrity ` -ProjectPath $projectRoot ` -ArchivePath $archiveDescriptor.ArchivePath ` -SourceIndex $sourceIntegrityIndex ` -Algorithm "SHA256" if (-not $integrityCheck.IsMatch) { Write-RenderKitLog -Level Error -Message ( "Archive integrity check failed. MissingInArchive={0}, ExtraInArchive={1}, HashMismatches={2}." -f $integrityCheck.MissingInArchiveCount, $integrityCheck.ExtraInArchiveCount, $integrityCheck.HashMismatchCount ) throw ( "Archive integrity check failed. MissingInArchive={0}, ExtraInArchive={1}, HashMismatches={2}." -f $integrityCheck.MissingInArchiveCount, $integrityCheck.ExtraInArchiveCount, $integrityCheck.HashMismatchCount ) } Write-RenderKitLog -Level Info -Message ( "Archive integrity check passed (Algorithm={0}, Files={1})." -f $integrityCheck.Algorithm, $integrityCheck.SourceFileCount ) $archiveLogInjection = Add-BackupLogsToArchive ` -ArchivePath $archiveDescriptor.ArchivePath ` -ProjectRoot $projectRoot if ($archiveLogInjection.AddedCount -gt 0) { Write-RenderKitLog -Level Info -Message "Added $($archiveLogInjection.AddedCount) log file(s) to archive." } else { Write-RenderKitLog -Level Info -Message "No log files found to inject into archive." } $finalArchiveItem = Get-Item -Path $archiveDescriptor.ArchivePath -ErrorAction Stop $finalArchiveHash = Get-FileHash -Path $archiveDescriptor.ArchivePath -Algorithm SHA256 -ErrorAction Stop $archiveInfo.sizeBytes = [int64]$finalArchiveItem.Length $archiveInfo.hashAlgorithm = "SHA256" $archiveInfo.hash = [string]$finalArchiveHash.Hash } $statsAfterCleanup = if ($DryRun) { $statsBefore } else { Get-BackupProjectStatistic -ProjectPath $projectRoot } if (-not $DryRun -and -not $KeepSourceProject) { Write-RenderKitLog -Level Info -Message "Removing source project folder '$projectRoot'." if ($lockHandle) { [void](Unlock-BackupLock -ProjectRoot $projectRoot -OwnerToken $lockHandle.OwnerToken) $lockHandle = $null } if (Test-Path -Path $projectRoot -PathType Container) { Remove-Item -Path $projectRoot -Recurse -Force -ErrorAction Stop } $sourceRemoved = -not (Test-Path -Path $projectRoot -PathType Container) if (-not $sourceRemoved) { Write-RenderKitLog -Level Error -Message "Source project folder '$projectRoot' could not be removed." throw "Source project folder '$projectRoot' could not be removed." } # Reset file logging context because source .renderkit was deleted. $script:RenderKitLoggingInitialized = $false $script:RenderKitLogFile = $null $script:RenderKitDebugLogFile = $null Write-Information "Source project folder removed: '$projectRoot'." -InformationAction Continue } elseif (-not $DryRun -and $KeepSourceProject) { Write-RenderKitLog -Level Info -Message "Keeping source project folder: '$projectRoot'." } $archiveInfo.sourceRemoved = [bool]$sourceRemoved $archiveInfo.logInjection = @{ addedCount = [int]$archiveLogInjection.AddedCount addedEntries = @($archiveLogInjection.AddedEntries) } $archiveInfo.contentIntegrity = @{ checked = [bool](-not $DryRun) isMatch = if ($integrityCheck) { [bool]$integrityCheck.IsMatch } else { $null } algorithm = if ($integrityCheck) { [string]$integrityCheck.Algorithm } else { $null } sourceFileCount = if ($integrityCheck) { [int]$integrityCheck.SourceFileCount } else { $null } archiveFileCount = if ($integrityCheck) { [int]$integrityCheck.ArchiveFileCount } else { $null } missingInArchiveCount = if ($integrityCheck) { [int]$integrityCheck.MissingInArchiveCount } else { $null } extraInArchiveCount = if ($integrityCheck) { [int]$integrityCheck.ExtraInArchiveCount } else { $null } hashMismatchCount = if ($integrityCheck) { [int]$integrityCheck.HashMismatchCount } else { $null } } $endedAt = Get-Date $statistics = @{ startedAt = $startedAt.ToString("o") endedAt = $endedAt.ToString("o") durationSeconds = [Math]::Round(($endedAt - $startedAt).TotalSeconds, 3) before = @{ fileCount = [int]$statsBefore.FileCount directoryCount = [int]$statsBefore.DirectoryCount totalBytes = [int64]$statsBefore.TotalBytes } after = @{ fileCount = [int]$statsAfterCleanup.FileCount directoryCount = [int]$statsAfterCleanup.DirectoryCount totalBytes = [int64]$statsAfterCleanup.TotalBytes } cleanup = @{ artifactCandidates = @{ files = [int]$artifactCleanup.CandidateFileCount folders = [int]$artifactCleanup.CandidateFolderCount } artifactRemoved = @{ files = [int]$artifactCleanup.RemovedFileCount folders = [int]$artifactCleanup.RemovedFolderCount bytes = [int64]$artifactCleanup.RemovedFileBytes failedCount = [int]$artifactCleanup.FailedCount } emptyFolders = @{ candidates = [int]$emptyFolderCleanup.CandidateCount removed = [int]$emptyFolderCleanup.RemovedCount failedCount = [int]$emptyFolderCleanup.FailedCount skipped = [bool]$emptyFolderCleanup.Skipped } } source = @{ path = $projectRoot removed = [bool]$sourceRemoved existsAfterRun = [bool](Test-Path -Path $projectRoot -PathType Container) } archiveIntegrity = if ($integrityCheck) { @{ checked = $true isMatch = [bool]$integrityCheck.IsMatch algorithm = [string]$integrityCheck.Algorithm sourceFileCount = [int]$integrityCheck.SourceFileCount archiveFileCount = [int]$integrityCheck.ArchiveFileCount missingInArchiveCount = [int]$integrityCheck.MissingInArchiveCount extraInArchiveCount = [int]$integrityCheck.ExtraInArchiveCount hashMismatchCount = [int]$integrityCheck.HashMismatchCount } } else { @{ checked = $false } } } $cleanupSummary = @( [PSCustomObject]@{ Step = "RemoveProjectArtifacts" Mode = [string]$artifactCleanup.Mode CandidateFiles = [int]$artifactCleanup.CandidateFileCount CandidateDirs = [int]$artifactCleanup.CandidateFolderCount RemovedFiles = [int]$artifactCleanup.RemovedFileCount RemovedDirs = [int]$artifactCleanup.RemovedFolderCount RemovedBytes = [int64]$artifactCleanup.RemovedFileBytes FailedCount = [int]$artifactCleanup.FailedCount } [PSCustomObject]@{ Step = "RemoveEmptyFolders" Mode = [string]$emptyFolderCleanup.Mode CandidateDirs = [int]$emptyFolderCleanup.CandidateCount RemovedDirs = [int]$emptyFolderCleanup.RemovedCount FailedCount = [int]$emptyFolderCleanup.FailedCount Skipped = [bool]$emptyFolderCleanup.Skipped } ) $manifest = New-BackupManifest ` -Project $project ` -Options @{ profiles = @($rules.Profiles) keepEmptyFolders = [bool]$KeepEmptyFolders keepSourceProject = [bool]$KeepSourceProject dryRun = [bool]$DryRun destinationRoot = [string]$archiveDescriptor.DestinationRoot removeSourceProject = [bool](-not $KeepSourceProject) } ` -Statistics $statistics ` -Archive $archiveInfo ` -CleanupSummary $cleanupSummary if (-not $DryRun) { $manifestFileName = [System.IO.Path]::ChangeExtension( [System.IO.Path]::GetFileName($archiveDescriptor.ArchivePath), "manifest.json" ) $manifestTempPath = Join-Path ` -Path ([System.IO.Path]::GetTempPath()) ` -ChildPath ("renderkit-manifest-{0}-{1}" -f [guid]::NewGuid().ToString("N"), $manifestFileName) try { $manifestTempPath = Save-BackupManifest ` -Manifest $manifest ` -ManifestPath $manifestTempPath $manifestArchiveEntryName = "__renderkit_meta/$manifestFileName" $manifestArchiveEntry = Add-BackupFileToArchive ` -ArchivePath $archiveDescriptor.ArchivePath ` -FilePath $manifestTempPath ` -EntryPath $manifestArchiveEntryName $manifestArchiveEntryPath = [string]$manifestArchiveEntry.EntryPath } finally { if (-not [string]::IsNullOrWhiteSpace($manifestTempPath) -and (Test-Path -Path $manifestTempPath -PathType Leaf)) { Remove-Item -Path $manifestTempPath -Force -ErrorAction SilentlyContinue } } $archiveInfo.manifest = @{ sidecarPath = $null embeddedInArchive = $true archiveEntryPath = $manifestArchiveEntryPath } Write-RenderKitLog -Level Info -Message "Embedded manifest into archive entry '$manifestArchiveEntryPath'." } else { $archiveInfo.manifest = @{ sidecarPath = $null embeddedInArchive = $false archiveEntryPath = $null } Write-RenderKitLog -Level Info -Message "DryRun mode: manifest was generated in-memory but not written to disk." } if (-not $sourceRemoved) { Write-RenderKitLog -Level Info -Message "Backup process completed successfully." } else { Write-Information "Backup process completed successfully." -InformationAction Continue } return [PSCustomObject]@{ ProjectName = $project.Name ProjectId = $project.Id RootPath = $projectRoot BackupPath = if ($DryRun) { $null } else { $archiveDescriptor.ArchivePath } DestinationRoot = $archiveDescriptor.DestinationRoot Profiles = @($rules.Profiles) SourceRemoved = [bool]$sourceRemoved KeepSourceProject = [bool]$KeepSourceProject DryRun = [bool]$DryRun ManifestPath = $manifestPath ManifestArchiveEntryPath = $manifestArchiveEntryPath Manifest = $manifest Statistics = $statistics Archive = $archiveInfo CleanupSummary = $cleanupSummary } } catch { Write-RenderKitLog -Level Error -Message "Backup failed: $($_.Exception.Message)" throw } finally { if ($lockHandle) { [void](Unlock-BackupLock -ProjectRoot $projectRoot -OwnerToken $lockHandle.OwnerToken) } } } |