diff --git a/scripts/lib/manifest.ps1 b/scripts/lib/manifest.ps1 index d0d4d46..f568936 100644 --- a/scripts/lib/manifest.ps1 +++ b/scripts/lib/manifest.ps1 @@ -56,13 +56,15 @@ function Sync-ManagedDestination { [string]$AiConfigRoot, [string]$ShellCommand, [string]$WindowsShellCommand, - [switch]$DryRun + [switch]$DryRun, + [switch]$Summary ) Write-Output "SOURCE $Source -> $Destination" $manifestPath = Get-ManagedManifestPath $Destination $oldManifest = Read-ManagedManifest $manifestPath $newManifest = @{} $backupRoot = Join-Path $Destination "backups/$Stamp" + $tally = @{ Created = 0; Updated = 0; Unchanged = 0; Removed = 0; Warned = 0 } Get-ChildItem $Source -File -Recurse | ForEach-Object { $relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/') @@ -79,38 +81,42 @@ function Sync-ManagedDestination { $newManifest[$relative] = $newHash $exists = Test-Path -LiteralPath $target - if ($exists -and (Get-Sha256Hash $target) -eq $newHash) { Write-Output "UNCHANGED $target"; return } + if ($exists -and (Get-Sha256Hash $target) -eq $newHash) { $tally.Unchanged++; if (-not $Summary) { Write-Output "UNCHANGED $target" }; return } $action = if ($exists) { 'UPDATE' } else { 'CREATE' } - if ($DryRun) { Write-Output "DRYRUN $action $target"; return } + if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN $action $target" }; if ($action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }; return } if ($exists) { $wasManaged = $oldManifest.ContainsKey($relative) $backup = Join-Path $backupRoot $relative New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null Copy-Item -LiteralPath $target $backup -Force - Write-Output "BACKUP $target -> $backup" - if (-not $wasManaged) { Write-Output "WARN $target existed before this installation but was not tracked by a previous run; it was backed up before being overwritten." } + if (-not $Summary) { Write-Output "BACKUP $target -> $backup" } + if (-not $wasManaged) { Write-Output "WARN $target existed before this installation but was not tracked by a previous run; it was backed up before being overwritten."; $tally.Warned++ } } New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null [System.IO.File]::WriteAllBytes($target, $newBytes) - Write-Output "$action $target" + if (-not $Summary) { Write-Output "$action $target" } + if ($action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ } } foreach ($relative in @($oldManifest.Keys | Where-Object { -not $newManifest.ContainsKey($_) })) { $target = Join-Path $Destination $relative if (-not (Test-Path -LiteralPath $target)) { continue } - if ($DryRun) { Write-Output "DRYRUN REMOVE $target"; continue } + if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN REMOVE $target" }; $tally.Removed++; continue } $backup = Join-Path $backupRoot $relative New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null Copy-Item -LiteralPath $target $backup -Force - Write-Output "BACKUP $target -> $backup" + if (-not $Summary) { Write-Output "BACKUP $target -> $backup" } if ((Get-Sha256Hash $target) -eq $oldManifest[$relative]) { Remove-Item -LiteralPath $target -Force - Write-Output "REMOVE $target" + if (-not $Summary) { Write-Output "REMOVE $target" } + $tally.Removed++ } else { Write-Output "WARN $target was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed." + $tally.Warned++ } } if (-not $DryRun) { Write-ManagedManifest $manifestPath $newManifest } + if ($Summary) { Write-Output ("SYNC {0}: {1} created, {2} updated, {3} unchanged, {4} removed, {5} warnings" -f $Destination, $tally.Created, $tally.Updated, $tally.Unchanged, $tally.Removed, $tally.Warned) } } diff --git a/scripts/lib/manifest.sh b/scripts/lib/manifest.sh index 9a4ab38..2a2c128 100755 --- a/scripts/lib/manifest.sh +++ b/scripts/lib/manifest.sh @@ -49,9 +49,9 @@ write_managed_manifest() { } > "$path" } -# sync_managed_destination +# sync_managed_destination [summary: true|false] sync_managed_destination() { - local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 windows_shell_command=$6 dry_run=$7 + local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 windows_shell_command=$6 dry_run=$7 summary=${8:-false} printf 'SOURCE %s -> %s\n' "$source" "$destination" local manifest manifest=$(manifest_path "$destination") @@ -59,6 +59,7 @@ sync_managed_destination() { read_managed_manifest "$manifest" old_manifest declare -A new_manifest local backup_root="$destination/backups/$stamp" + local created=0 updated=0 unchanged=0 removed=0 warned=0 while IFS= read -r -d '' source_file; do local relative target content needs_sub new_hash exists current_hash action backup @@ -84,7 +85,8 @@ sync_managed_destination() { if [ "$exists" = true ]; then current_hash=$(sha256_of_file "$target") if [ "$current_hash" = "$new_hash" ]; then - printf 'UNCHANGED %s\n' "$target" + unchanged=$((unchanged + 1)) + [ "$summary" = true ] || printf 'UNCHANGED %s\n' "$target" continue fi action=UPDATE @@ -92,15 +94,20 @@ sync_managed_destination() { action=CREATE fi - if [ "$dry_run" = true ]; then printf 'DRYRUN %s %s\n' "$action" "$target"; continue; fi + if [ "$dry_run" = true ]; then + [ "$summary" = true ] || printf 'DRYRUN %s %s\n' "$action" "$target" + [ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1)) + continue + fi if [ "$exists" = true ]; then backup="$backup_root/$relative" mkdir -p "$(dirname -- "$backup")" cp -- "$target" "$backup" - printf 'BACKUP %s -> %s\n' "$target" "$backup" + [ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup" if [ -z "${old_manifest[$relative]+x}" ]; then printf 'WARN %s existed before this installation but was not tracked by a previous run; it was backed up before being overwritten.\n' "$target" + warned=$((warned + 1)) fi fi mkdir -p "$(dirname -- "$target")" @@ -109,7 +116,8 @@ sync_managed_destination() { else cp -- "$source_file" "$target" fi - printf '%s %s\n' "$action" "$target" + [ "$summary" = true ] || printf '%s %s\n' "$action" "$target" + [ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1)) done < <(find "$source" -type f -print0) local relative target current_hash backup @@ -117,19 +125,28 @@ sync_managed_destination() { [ -z "${new_manifest[$relative]+x}" ] || continue target="$destination/$relative" [ -f "$target" ] || continue - if [ "$dry_run" = true ]; then printf 'DRYRUN REMOVE %s\n' "$target"; continue; fi + if [ "$dry_run" = true ]; then + [ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target" + removed=$((removed + 1)) + continue + fi backup="$backup_root/$relative" mkdir -p "$(dirname -- "$backup")" cp -- "$target" "$backup" - printf 'BACKUP %s -> %s\n' "$target" "$backup" + [ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup" current_hash=$(sha256_of_file "$target") if [ "$current_hash" = "${old_manifest[$relative]}" ]; then rm -f -- "$target" - printf 'REMOVE %s\n' "$target" + [ "$summary" = true ] || printf 'REMOVE %s\n' "$target" + removed=$((removed + 1)) else printf 'WARN %s was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed.\n' "$target" + warned=$((warned + 1)) fi done if [ "$dry_run" != true ]; then write_managed_manifest "$manifest" new_manifest; fi + if [ "$summary" = true ]; then + printf 'SYNC %s: %s created, %s updated, %s unchanged, %s removed, %s warnings\n' "$destination" "$created" "$updated" "$unchanged" "$removed" "$warned" + fi } diff --git a/scripts/test-managed-install.ps1 b/scripts/test-managed-install.ps1 index b5624bf..4e990d4 100644 --- a/scripts/test-managed-install.ps1 +++ b/scripts/test-managed-install.ps1 @@ -44,6 +44,10 @@ try { if (Compare-Object $beforeDry $afterDry) { throw 'A dry run must not change the destination at all.' } if (@($outDry | Where-Object { $_ -match 'DRYRUN REMOVE.*a\.md' }).Count -ne 1) { throw "Dry run should report the planned removal without performing it: $($outDry -join '; ')" } + $outSummary = @(Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'summary' -Summary) + if (@($outSummary | Where-Object { $_ -like 'CREATE*' -or $_ -like 'UPDATE*' -or $_ -like 'UNCHANGED*' -or $_ -like 'BACKUP*' }).Count -ne 0) { throw "Summary mode must not print per-file lines: $($outSummary -join '; ')" } + if (@($outSummary | Where-Object { $_ -match '^SYNC .* unchanged' }).Count -ne 1) { throw "Summary mode must print exactly one SYNC tally line: $($outSummary -join '; ')" } + Write-Output 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free' } finally { Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue diff --git a/scripts/test-managed-install.sh b/scripts/test-managed-install.sh index fdc47ec..d362ea5 100755 --- a/scripts/test-managed-install.sh +++ b/scripts/test-managed-install.sh @@ -44,4 +44,14 @@ after_dry=$(find "$destination" -type f | sort) [ "$before_dry" = "$after_dry" ] || { printf 'A dry run must not change the destination at all.\n' >&2; exit 1; } printf '%s\n' "$out_dry" | grep -q '^DRYRUN REMOVE .*a\.md$' || { printf 'Dry run should report the planned removal without performing it:\n%s\n' "$out_dry" >&2; exit 1; } +out_summary=$(sync_managed_destination "$source_dir" "$destination" "summary" "" "" "" false true) +if printf '%s\n' "$out_summary" | grep -Eq '^(CREATE|UPDATE|UNCHANGED|BACKUP) '; then + printf 'Summary mode must not print per-file lines: %s\n' "$out_summary" >&2 + exit 1 +fi +if [ "$(printf '%s\n' "$out_summary" | grep -c '^SYNC .* unchanged')" -ne 1 ]; then + printf 'Summary mode must print exactly one SYNC tally line: %s\n' "$out_summary" >&2 + exit 1 +fi + printf 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free\n'