@caddis/cli 0.4.21 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Remove excluded paths from the STAGED repository's history, then prove they are gone.
4
+
5
+ .DESCRIPTION
6
+ Works only on <staging>\<Name>\mirror.git. The source is never touched.
7
+
8
+ -Mode Rewrite keep every branch, tag and commit, minus the excluded paths. Commit SHAs
9
+ change from the first commit that touched an excluded path onward, so
10
+ anything that quotes an old SHA (a deploy record, a tag note) stops matching.
11
+ Uses git filter-repo if installed, otherwise git filter-branch.
12
+ -Mode FreshStart one new commit holding the default branch's current files minus the excluded
13
+ paths. All other branches, tags and history are dropped. Smallest, simplest,
14
+ and loses the history - an owner decision.
15
+
16
+ Paths come from content-policy-exclude.txt (written by Test-PortContentPolicy.ps1) unless
17
+ -PathsFile is given. Afterwards it re-runs Test-PortContentPolicy.ps1, which must exit 0, and
18
+ rewrites manifest.json's heads and tags so Publish's stage-drift check sees the cleaned refs.
19
+ #>
20
+ [CmdletBinding(SupportsShouldProcess)]
21
+ param(
22
+ [Parameter(Mandatory)][string]$Name,
23
+ [Parameter(Mandatory)][ValidateSet('Rewrite', 'FreshStart')][string]$Mode,
24
+ [string]$PathsFile,
25
+ [string]$StagingRoot,
26
+ [string]$ConfigPath
27
+ )
28
+
29
+ $ErrorActionPreference = 'Stop'
30
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
31
+
32
+ $config = Get-PortConfig $ConfigPath
33
+ $stage = Join-Path (Get-PortStagingRoot $config $StagingRoot) $Name
34
+ $mirror = Join-Path $stage 'mirror.git'
35
+ $manifestPath = Join-Path $stage 'manifest.json'
36
+ if (-not (Test-Path -LiteralPath $mirror)) { throw "No staged mirror at $mirror." }
37
+ if (-not $PathsFile) { $PathsFile = Join-Path $stage 'content-policy-exclude.txt' }
38
+ if (-not (Test-Path -LiteralPath $PathsFile)) { throw "No paths file at $PathsFile. Run Test-PortContentPolicy.ps1 first." }
39
+ $paths = @(Get-Content -LiteralPath $PathsFile | Where-Object { $_ -and $_.Trim() })
40
+ if ($paths.Count -eq 0) { Write-PortOk 'Nothing to remove.'; exit 0 }
41
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
42
+ $branch = $manifest.defaultBranch
43
+
44
+ # git reads pathspecs as patterns; ":(literal)" makes every entry an exact path, so a file
45
+ # named with [ ] * ? is never widened into a glob that removes something else.
46
+ $specFile = Join-Path $stage 'history-clean-pathspec.txt'
47
+ [IO.File]::WriteAllLines($specFile, [string[]]@($paths | ForEach-Object { ":(literal)$_" }), (New-Object Text.UTF8Encoding($false)))
48
+
49
+ $before = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/tags'))
50
+ if (-not $PSCmdlet.ShouldProcess($mirror, "$Mode history, removing $($paths.Count) path(s)")) { exit 0 }
51
+
52
+ if ($Mode -eq 'Rewrite') {
53
+ $fr = Invoke-PortGit -AllowFailure @('filter-repo', '--version')
54
+ if ($fr.ExitCode -eq 0) {
55
+ Write-PortStep "Rewriting with git filter-repo ($($paths.Count) path(s))"
56
+ $plain = Join-Path $stage 'history-clean-paths.txt'
57
+ [IO.File]::WriteAllLines($plain, [string[]]$paths, (New-Object Text.UTF8Encoding($false)))
58
+ Invoke-PortGit @('-C', $mirror, 'filter-repo', '--force', '--invert-paths', '--paths-from-file', $plain) | Out-Null
59
+ }
60
+ else {
61
+ Write-PortStep "Rewriting with git filter-branch ($($paths.Count) path(s)); git filter-repo is not installed"
62
+ $env:FILTER_BRANCH_SQUELCH_WARNING = '1'
63
+ # The index filter is eval'd by a shell. Pass the path through an environment variable, so a
64
+ # quote in it (C:\Users\O'Brien\...) cannot break the command.
65
+ $env:PORT_PATHSPEC_FILE = $specFile.Replace('\', '/')
66
+ try {
67
+ $idx = 'git rm -r --cached --ignore-unmatch --quiet --pathspec-from-file="$PORT_PATHSPEC_FILE"'
68
+ Invoke-PortGit @('-C', $mirror, 'filter-branch', '-f', '--index-filter', $idx, '--prune-empty', '--tag-name-filter', 'cat', '--', '--all') | Out-Null
69
+ }
70
+ finally { Remove-Item Env:FILTER_BRANCH_SQUELCH_WARNING, Env:PORT_PATHSPEC_FILE -ErrorAction SilentlyContinue }
71
+ foreach ($r in @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)', 'refs/original'))) {
72
+ if ($r) { Invoke-PortGit @('-C', $mirror, 'update-ref', '-d', $r) | Out-Null }
73
+ }
74
+ }
75
+ }
76
+ else {
77
+ Write-PortStep "Fresh start: one commit from $branch minus $($paths.Count) path(s)"
78
+ $tmpIndex = Join-Path $stage 'fresh.index'
79
+ # git rm needs a work tree even with --cached; a bare mirror has none, so lend it an empty one.
80
+ $tmpWork = Join-Path $stage 'fresh-worktree'
81
+ New-Item -ItemType Directory -Force -Path $tmpWork | Out-Null
82
+ $env:GIT_INDEX_FILE = $tmpIndex
83
+ $env:GIT_WORK_TREE = $tmpWork
84
+ try {
85
+ Invoke-PortGit @('-C', $mirror, 'read-tree', $branch) | Out-Null
86
+ Invoke-PortGit @('-C', $mirror, 'rm', '-r', '--cached', '--ignore-unmatch', '--quiet', "--pathspec-from-file=$specFile") | Out-Null
87
+ $tree = (Invoke-PortGit @('-C', $mirror, 'write-tree')) -join ''
88
+ }
89
+ finally {
90
+ Remove-Item Env:GIT_INDEX_FILE, Env:GIT_WORK_TREE -ErrorAction SilentlyContinue
91
+ Remove-Item -LiteralPath $tmpIndex -ErrorAction SilentlyContinue
92
+ Remove-Item -LiteralPath $tmpWork -Recurse -Force -ErrorAction SilentlyContinue
93
+ }
94
+ $msg = "Initial import of $Name (fresh start: history not carried over)"
95
+ $body = "Source: $($manifest.source), branch $branch at $((Invoke-PortGit @('-C', $mirror, 'rev-parse', $branch)) -join ''). $($paths.Count) path(s) excluded by the content policy."
96
+ $commit = (Invoke-PortGit @('-C', $mirror, 'commit-tree', $tree, '-m', $msg, '-m', $body)) -join ''
97
+ foreach ($r in @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/tags'))) {
98
+ if ($r) { Invoke-PortGit @('-C', $mirror, 'update-ref', '-d', $r) | Out-Null }
99
+ }
100
+ Invoke-PortGit @('-C', $mirror, 'update-ref', "refs/heads/$branch", $commit) | Out-Null
101
+ Invoke-PortGit @('-C', $mirror, 'symbolic-ref', 'HEAD', "refs/heads/$branch") | Out-Null
102
+ }
103
+
104
+ # Drop the unreachable objects, so a later readiness scan measures what will actually be pushed.
105
+ Invoke-PortGit @('-C', $mirror, 'reflog', 'expire', '--expire=now', '--all') | Out-Null
106
+ Invoke-PortGit @('-C', $mirror, 'gc', '--prune=now', '--quiet') | Out-Null
107
+
108
+ $after = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/tags'))
109
+ $manifest.heads = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads'))
110
+ $manifest.tags = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/tags'))
111
+ $manifest | Add-Member -NotePropertyName historyClean -NotePropertyValue ([pscustomobject]@{
112
+ mode = $Mode; at = (Get-Date).ToString('o'); pathsRemoved = $paths.Count
113
+ refsBefore = $before.Count; refsAfter = $after.Count
114
+ note = 'Commit SHAs changed. The source still holds the original history.'
115
+ }) -Force
116
+ ConvertTo-PortJsonFile $manifest $manifestPath
117
+
118
+ # The proof: the policy must now pass. A clean that cannot fail is not a clean.
119
+ Write-PortStep 'Re-checking the content policy on the cleaned history'
120
+ & (Join-Path $PSScriptRoot 'Test-PortContentPolicy.ps1') -Name $Name -StagingRoot $StagingRoot -ConfigPath $ConfigPath
121
+ if ($LASTEXITCODE -ne 0) { Write-PortFail 'Excluded content is STILL in history after the clean. Do not publish.'; exit 1 }
122
+ Write-PortOk ("History cleaned ({0}): refs {1} -> {2}. Re-run Test-PortReadiness.ps1 before publishing." -f $Mode, $before.Count, $after.Count)
123
+ exit 0
@@ -0,0 +1,258 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Record the runtimes an app needs but the repository must not carry, so the environment can
4
+ be rebuilt from the repository alone.
5
+
6
+ .DESCRIPTION
7
+ Scans the app's SOURCE FOLDER (not the git history), including ignored folders such as tools/,
8
+ for runtime distributions: MariaDB/MySQL, PHP, Node, Python, Java, Apache, nginx, Redis,
9
+ PostgreSQL. Versions come from file metadata. NOTHING it finds is executed.
10
+
11
+ Writes to -OutDir (put it in the repository, e.g. environment/):
12
+ runtimes.json name, version, where it lived, binary SHA-256, data folders, config files
13
+ README.md the same for people, plus every decision still open
14
+ provision.ps1 downloads and unpacks each runtime with a known public source; fails
15
+ loudly for any runtime it cannot provision, so a gap is never silent
16
+ config-templates/ configuration found INSIDE runtime folders (my.ini, php.ini, config/,
17
+ metadata/). The teammate rule keeps configuration in the repo even when
18
+ the runtime around it is excluded. A file that looks like it holds a
19
+ credential is NOT copied; it is listed for a hand-made template.
20
+
21
+ Folders beside a runtime that match nothing (a deployed web tree, a vendored library) are
22
+ listed as UNCLASSIFIED: each needs a human decision.
23
+
24
+ Works on a local or UNC path, or on a remote box with -ComputerName (Windows PowerShell 5.1
25
+ on the far side is fine).
26
+ #>
27
+ [CmdletBinding()]
28
+ param(
29
+ [Parameter(Mandatory)][string]$Path,
30
+ [string]$ComputerName,
31
+ [pscredential]$Credential,
32
+ [Parameter(Mandatory)][string]$OutDir,
33
+ [int]$MaxDepth = 5,
34
+ [int]$ConfigMaxKB = 256
35
+ )
36
+
37
+ $ErrorActionPreference = 'Stop'
38
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
39
+
40
+ # Runs locally or remotely. 5.1-compatible, read-only, never executes a found binary.
41
+ $scan = {
42
+ param($Root, $Depth, $CfgMaxKB)
43
+ $ErrorActionPreference = 'Continue'
44
+ $res = @{ ok = $false; error = $null; runtimes = @(); unclassified = @() }
45
+ if (-not (Test-Path -LiteralPath $Root -PathType Container)) { $res.error = "Folder not found: $Root"; return $res }
46
+ $Root = (Resolve-Path -LiteralPath $Root).ProviderPath.TrimEnd('\')
47
+ function Rel($p) { $p.Substring($Root.Length).TrimStart('\').Replace('\', '/') }
48
+ # A plain loop: Measure-Object over an EMPTY folder has no .Sum under strict mode, and an
49
+ # empty data/ folder (a runtime never started) is normal here.
50
+ function DirMB($p) { $t = [int64]0; foreach ($f in @(Get-ChildItem -LiteralPath $p -Recurse -File -Force -ErrorAction SilentlyContinue)) { $t += $f.Length }; [math]::Round($t / 1MB, 1) }
51
+
52
+ $binRx = '(?i)^(mariadbd|mysqld|php|node|python|java|httpd|nginx|redis-server|postgres)\.exe$'
53
+ $skipRx = '(?i)\\(node_modules|\.venv|venv|\.git|site-packages|vendor)\\'
54
+ $bins = @(Get-ChildItem -LiteralPath $Root -Recurse -Depth $Depth -File -Force -ErrorAction SilentlyContinue |
55
+ Where-Object { $_.Name -match $binRx -and $_.FullName -notmatch $skipRx })
56
+ $roots = @{}
57
+ $priority = @('mariadbd', 'mysqld', 'php', 'node', 'python', 'java', 'httpd', 'nginx', 'redis-server', 'postgres')
58
+ foreach ($b in $bins) {
59
+ $dir = $b.Directory
60
+ $rootDir = if ($dir.Name -ieq 'bin' -and $dir.Parent) { $dir.Parent.FullName } else { $dir.FullName }
61
+ if ($rootDir -ieq $Root) { continue }
62
+ $base = [IO.Path]::GetFileNameWithoutExtension($b.Name).ToLower()
63
+ if (-not $roots.ContainsKey($rootDir) -or ($priority.IndexOf($base) -lt $priority.IndexOf($roots[$rootDir].base))) {
64
+ $roots[$rootDir] = @{ file = $b; base = $base }
65
+ }
66
+ }
67
+ # A runtime nested inside another runtime (php inside apache/) belongs to the outer one.
68
+ $rootList = @($roots.Keys | Sort-Object Length)
69
+ $kept = @()
70
+ foreach ($r in $rootList) { if (-not ($kept | Where-Object { $r.StartsWith($_ + '\', [StringComparison]::OrdinalIgnoreCase) })) { $kept += $r } }
71
+
72
+ $cfgExt = '(?i)\.(ini|cnf|conf|php|ya?ml|json|xml|properties|toml)$'
73
+ function Get-Configs($dir, $dataDirs) {
74
+ $out = @()
75
+ $cands = @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)\.(ini|cnf|conf)$' })
76
+ foreach ($sub in @(Get-ChildItem -LiteralPath $dir -Directory -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)^(conf|config|etc|metadata|cert)$' })) {
77
+ $cands += @(Get-ChildItem -LiteralPath $sub.FullName -Recurse -Depth 2 -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -match $cfgExt -or $sub.Name -ieq 'cert' })
78
+ }
79
+ foreach ($f in $cands) {
80
+ if ($dataDirs | Where-Object { $f.FullName.StartsWith($_ + '\', [StringComparison]::OrdinalIgnoreCase) }) { continue }
81
+ if ($f.Name -match '(?i)\.(dist|example|sample|template)$') { continue }
82
+ $o = @{ rel = (Rel $f.FullName); bytes = $f.Length; b64 = $null; tooBig = $false }
83
+ if ($f.Length -le ($CfgMaxKB * 1KB)) { $o.b64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($f.FullName)) } else { $o.tooBig = $true }
84
+ $out += $o
85
+ }
86
+ return , $out
87
+ }
88
+
89
+ foreach ($rd in $kept) {
90
+ $e = $roots[$rd]; $vi = $e.file.VersionInfo
91
+ $ver = "$($vi.ProductVersion)".Trim(); if (-not $ver) { $ver = "$($vi.FileVersion)".Trim() }
92
+ $kind = switch -Regex ($e.base) { '^(mariadbd|mysqld)$' { if ("$($vi.ProductName)$($vi.FileDescription)" -match '(?i)mariadb' -or (Test-Path (Join-Path $rd 'bin\mariadbd.exe'))) { 'mariadb' } else { 'mysql' } } '^php$' { 'php' } '^node$' { 'node' } '^python$' { 'python' } '^java$' { 'java' } '^httpd$' { 'apache' } '^nginx$' { 'nginx' } '^redis-server$' { 'redis' } '^postgres$' { 'postgresql' } default { $e.base } }
93
+ $sha = (Get-FileHash -LiteralPath $e.file.FullName -Algorithm SHA256).Hash.ToLower()
94
+ $dataDirs = @(Get-ChildItem -LiteralPath $rd -Directory -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)^(data|db|pgdata|var)$' } | ForEach-Object { $_.FullName })
95
+ $ts = $null
96
+ if ($kind -eq 'php') { $ts = [bool](Get-ChildItem -LiteralPath $rd -Filter 'php*ts.dll' -File -ErrorAction SilentlyContinue) }
97
+ $res.runtimes += @{
98
+ kind = $kind; root = (Rel $rd); binary = (Rel $e.file.FullName); version = $ver
99
+ product = "$($vi.ProductName)"; binarySha256 = $sha; sizeMB = (DirMB $rd); threadSafe = $ts
100
+ dataDirs = @($dataDirs | ForEach-Object { @{ rel = (Rel $_); sizeMB = (DirMB $_) } })
101
+ configs = (Get-Configs $rd $dataDirs)
102
+ }
103
+ }
104
+
105
+ # Siblings of runtime roots that match nothing: a human must decide what they are.
106
+ $containers = @($kept | ForEach-Object { Split-Path $_ -Parent } | Sort-Object -Unique | Where-Object { $_ -and ($_ -ine $Root) })
107
+ foreach ($c in $containers) {
108
+ foreach ($d in @(Get-ChildItem -LiteralPath $c -Directory -Force -ErrorAction SilentlyContinue)) {
109
+ if ($kept -contains $d.FullName) { continue }
110
+ $hints = @()
111
+ $cj = Join-Path $d.FullName 'composer.json'
112
+ if (Test-Path $cj) { try { $j = Get-Content $cj -Raw | ConvertFrom-Json; $hints += "composer package $($j.name)$(if ($j.version) { ' ' + $j.version })" } catch { $hints += 'composer.json' } }
113
+ if (Test-Path (Join-Path $d.FullName 'package.json')) { $hints += 'package.json' }
114
+ foreach ($s in 'conf', 'config', 'cert', 'data', 'log') { if (Test-Path (Join-Path $d.FullName $s)) { $hints += "$s/" } }
115
+ $res.unclassified += @{ rel = (Rel $d.FullName); sizeMB = (DirMB $d.FullName); hints = $hints; configs = (Get-Configs $d.FullName @()) }
116
+ }
117
+ }
118
+ $res.ok = $true
119
+ return $res
120
+ }
121
+
122
+ if ($ComputerName) {
123
+ $ia = @{ ComputerName = $ComputerName; ScriptBlock = $scan; ArgumentList = @($Path, $MaxDepth, $ConfigMaxKB) }
124
+ if ($Credential) { $ia.Credential = $Credential }
125
+ Write-PortStep "Scanning $Path on $ComputerName for runtimes (read-only)"
126
+ $res = Invoke-Command @ia
127
+ }
128
+ else {
129
+ Write-PortStep "Scanning $Path for runtimes (read-only)"
130
+ $res = & $scan $Path $MaxDepth $ConfigMaxKB
131
+ }
132
+ if (-not $res.ok) { throw $res.error }
133
+
134
+ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
135
+ $tplRoot = Join-Path $OutDir 'config-templates'
136
+ $withheld = @(); $copied = @(); $tooBig = @()
137
+ function Save-Configs($list) {
138
+ foreach ($c in @($list)) {
139
+ if ($c.tooBig) { $script:tooBig += $c.rel; continue }
140
+ $bytes = [Convert]::FromBase64String($c.b64)
141
+ $text = [Text.Encoding]::UTF8.GetString($bytes)
142
+ $leaf = Split-Path $c.rel -Leaf
143
+ # Heuristics, so they err toward WITHHOLDING. Found by the dogfood: SimpleSAMLphp's
144
+ # exampleauth writes a user as 'name:password' => array(...), which no key=value rule sees.
145
+ $secret = (Test-PortSecretName $leaf) -or ($c.rel -match '(?i)(^|/)cert/') -or (Test-PortSecretContent $text) -or
146
+ ($text -match '(?i)(password|passwd|pass|pwd|secret|salt|token|api_?key|private_?key|auth\.adminpassword|credentials?)\s*[''"]?\s*(=>|=|:)\s*[''"][^''"\s]{4,}[''"]') -or
147
+ ($text -match '[''"][^''"\s:/]{2,}:[^''"\s]{4,}[''"]\s*=>') -or
148
+ ($text -match '(?i)[a-z][a-z0-9+.-]*://[^/\s:@''"]+:[^/\s@''"]+@') -or
149
+ # Unquoted INI/properties form, the one my.ini and php.ini actually use: password=Secret123
150
+ ($text -match '(?im)^\s*[\w.-]*(password|passwd|pwd|pass|_pw|secret|api[_-]?key|token|credential)s?\s*=\s*[^\s;#]+')
151
+ if ($secret) { $script:withheld += $c.rel; continue }
152
+ $dst = Join-Path $tplRoot ($c.rel -replace '/', '\')
153
+ New-Item -ItemType Directory -Force -Path (Split-Path $dst -Parent) | Out-Null
154
+ [IO.File]::WriteAllBytes($dst, $bytes)
155
+ $script:copied += $c.rel
156
+ }
157
+ }
158
+ foreach ($r in $res.runtimes) { Save-Configs $r.configs }
159
+ foreach ($u in $res.unclassified) { Save-Configs $u.configs }
160
+
161
+ function Get-Source($r) {
162
+ $v = ($r.version -split '\.')
163
+ $v3 = ($v | Select-Object -First 3) -join '.'
164
+ switch ($r.kind) {
165
+ 'mariadb' { return @("https://archive.mariadb.org/mariadb-$v3/winx64-packages/mariadb-$v3-winx64.zip") }
166
+ 'php' {
167
+ $mm = [version](($v | Select-Object -First 2) -join '.')
168
+ $vs = if ($mm -ge [version]'8.4') { 'vs17' } elseif ($mm -ge [version]'8.0') { 'vs16' } else { 'vc15' }
169
+ $ts = if ($r.threadSafe) { 'Win32' } else { 'nts-Win32' }
170
+ $f = "php-$v3-$ts-$vs-x64.zip"
171
+ return @("https://windows.php.net/downloads/releases/$f", "https://windows.php.net/downloads/releases/archives/$f")
172
+ }
173
+ 'node' { return @("https://nodejs.org/dist/v$v3/node-v$v3-win-x64.zip") }
174
+ 'python' { return @("https://www.python.org/ftp/python/$v3/python-$v3-embed-amd64.zip") }
175
+ default { return @() }
176
+ }
177
+ }
178
+
179
+ $runtimes = @(foreach ($r in $res.runtimes) {
180
+ [ordered]@{
181
+ kind = $r.kind; version = $r.version; product = $r.product; wasAt = $r.root; binary = $r.binary
182
+ binarySha256 = $r.binarySha256; installedSizeMB = $r.sizeMB; threadSafe = $r.threadSafe
183
+ sources = @(Get-Source $r); archiveSha256 = $null
184
+ dataDirs = @($r.dataDirs | ForEach-Object { [ordered]@{ path = $_.rel; sizeMB = $_.sizeMB } })
185
+ }
186
+ })
187
+ $uncl = @(foreach ($u in $res.unclassified) { [ordered]@{ path = $u.rel; sizeMB = $u.sizeMB; hints = @($u.hints); decision = 'OPEN' } })
188
+ $doc = [ordered]@{
189
+ generatedAt = (Get-Date).ToString('o'); scannedPath = $Path; computerName = $ComputerName
190
+ runtimes = $runtimes; unclassified = $uncl
191
+ configTemplates = @($copied); configWithheld = @($withheld); configTooBig = @($tooBig)
192
+ }
193
+ ConvertTo-PortJsonFile $doc (Join-Path $OutDir 'runtimes.json')
194
+
195
+ # ---- provision.ps1: downloads what has a known source, and FAILS for the rest ----------------
196
+ $prov = New-Object System.Collections.Generic.List[string]
197
+ $prov.Add('# Generated by port-to-github New-PortRuntimeManifest.ps1. Rebuilds the local runtimes that the')
198
+ $prov.Add('# repository deliberately does not carry. Review the URLs once, then record each archive''s')
199
+ $prov.Add('# SHA-256 in runtimes.json (archiveSha256) so later runs verify the download.')
200
+ $prov.Add('[CmdletBinding()] param([string]$ToolsRoot = (Join-Path $PSScriptRoot ''..\tools''), [switch]$Force)')
201
+ $prov.Add('$ErrorActionPreference = ''Stop''')
202
+ $prov.Add('$spec = Get-Content -LiteralPath (Join-Path $PSScriptRoot ''runtimes.json'') -Raw | ConvertFrom-Json')
203
+ $prov.Add('$missing = @()')
204
+ $prov.Add('New-Item -ItemType Directory -Force -Path $ToolsRoot | Out-Null')
205
+ $prov.Add('foreach ($r in $spec.runtimes) {')
206
+ $prov.Add(' $dest = Join-Path $ToolsRoot (Split-Path $r.wasAt -Leaf)')
207
+ $prov.Add(' if ((Test-Path $dest) -and -not $Force) { Write-Host "[skip] $($r.kind) $($r.version): $dest exists"; continue }')
208
+ $prov.Add(' $replace = Test-Path $dest')
209
+ $prov.Add(' if (@($r.sources).Count -eq 0) { $missing += "$($r.kind) $($r.version) (no known public source)"; continue }')
210
+ $prov.Add(' $zip = Join-Path ([IO.Path]::GetTempPath()) "$($r.kind)-$($r.version).zip"; $got = $false')
211
+ $prov.Add(' foreach ($u in $r.sources) { try { Invoke-WebRequest -Uri $u -OutFile $zip -UseBasicParsing; $got = $true; break } catch { Write-Host "[miss] $u" } }')
212
+ $prov.Add(' if (-not $got) { $missing += "$($r.kind) $($r.version) (download failed)"; continue }')
213
+ $prov.Add(' $h = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()')
214
+ $prov.Add(' if ($r.archiveSha256 -and $h -ne $r.archiveSha256) { throw "$($r.kind): archive SHA-256 $h does not match runtimes.json $($r.archiveSha256)" }')
215
+ $prov.Add(' if (-not $r.archiveSha256) { Write-Warning "$($r.kind): unverified download, SHA-256 $h - record it in runtimes.json" }')
216
+ $prov.Add(' $tmp = Join-Path ([IO.Path]::GetTempPath()) "$($r.kind)-$($r.version)-x"; Expand-Archive -LiteralPath $zip -DestinationPath $tmp -Force')
217
+ $prov.Add(' $inner = @(Get-ChildItem $tmp); $src = if ($inner.Count -eq 1 -and $inner[0].PSIsContainer) { $inner[0].FullName } else { $tmp }')
218
+ $prov.Add(' if ($replace) { Remove-Item -LiteralPath $dest -Recurse -Force } # Move-Item -Force onto a folder NESTS; replace it instead')
219
+ $prov.Add(' Move-Item -LiteralPath $src -Destination $dest; Write-Host "[ok] $($r.kind) $($r.version) -> $dest"')
220
+ $prov.Add(' $bin = Join-Path $ToolsRoot ((Split-Path $r.wasAt -Leaf) + ''/'' + ($r.binary.Substring($r.wasAt.Length).TrimStart(''/'')))')
221
+ $prov.Add(' if (-not (Test-Path $bin)) { $missing += "$($r.kind): expected binary $bin not found after unpack" }')
222
+ $prov.Add(' elseif ((Get-FileHash $bin -Algorithm SHA256).Hash.ToLower() -ne $r.binarySha256) { Write-Warning "$($r.kind): binary differs from the recorded build (different package variant?)" }')
223
+ $prov.Add('}')
224
+ $prov.Add('Write-Host ''Copy config-templates\ into place, fill in every withheld secret, and restore data from its backup - see README.md.''')
225
+ $prov.Add('if ($missing.Count) { $missing | ForEach-Object { Write-Host "[FAIL] $_" }; exit 1 }')
226
+ $prov.Add('exit 0')
227
+ [IO.File]::WriteAllLines((Join-Path $OutDir 'provision.ps1'), [string[]]$prov, (New-Object Text.UTF8Encoding($false)))
228
+
229
+ # ---- README.md ------------------------------------------------------------------------------------
230
+ $md = New-Object System.Collections.Generic.List[string]
231
+ $md.Add('# Local runtime environment')
232
+ $md.Add('')
233
+ $md.Add('The runtimes below are needed to run this app locally or in UAT. They are NOT in the repository:')
234
+ $md.Add('they are third-party distributions, not application source. `provision.ps1` rebuilds them.')
235
+ $md.Add('Generated by the port-to-github skill; edit freely, but keep `runtimes.json` the source of truth.')
236
+ $md.Add('')
237
+ $md.Add('| Runtime | Version | Was at | Installed MB | Source |')
238
+ $md.Add('|---|---|---|---|---|')
239
+ foreach ($r in $runtimes) { $md.Add(('| {0} | {1} | `{2}` | {3} | {4} |' -f $r.kind, $r.version, $r.wasAt, $r.installedSizeMB, $(if (@($r.sources).Count) { $r.sources[0] } else { '**none known - install by hand**' }))) }
240
+ $data = @($runtimes | ForEach-Object { $k = $_.kind; $_.dataDirs | ForEach-Object { "- ``$($_.path)`` ($k, $($_.sizeMB) MB): runtime STATE, not code. Rebuild from the schema and migrations in this repository, or restore from backup. Never commit it." } })
241
+ if ($data.Count) { $md.Add(''); $md.Add('## Data folders (never committed)'); $data | ForEach-Object { $md.Add($_) } }
242
+ if ($uncl.Count) {
243
+ $md.Add(''); $md.Add('## OPEN: folders beside the runtimes that nothing identified')
244
+ $md.Add('Decide each: provision it (add it here), rebuild it from repository code, or keep it in the repository.')
245
+ foreach ($u in $uncl) { $md.Add(('- `{0}` ({1} MB){2}' -f $u.path, $u.sizeMB, $(if (@($u.hints).Count) { ' - ' + ($u.hints -join ', ') } else { '' }))) }
246
+ }
247
+ $md.Add(''); $md.Add('## Configuration')
248
+ $md.Add("- Copied to ``config-templates/`` ($($copied.Count) file(s)): configuration that lived inside a runtime folder. It IS part of the app. **Read each one before committing it**: the credential check is a heuristic.")
249
+ if ($withheld.Count) { $md.Add("- **Withheld, $($withheld.Count) file(s) that look like they hold a credential.** Make a template by hand with the secret replaced by a placeholder:"); $withheld | ForEach-Object { $md.Add(" - ``$_``") } }
250
+ if ($tooBig.Count) { $md.Add("- Too large to copy automatically ($($tooBig.Count)): " + (($tooBig | ForEach-Object { "``$_``" }) -join ', ')) }
251
+ [IO.File]::WriteAllLines((Join-Path $OutDir 'README.md'), [string[]]$md, (New-Object Text.UTF8Encoding($false)))
252
+
253
+ Write-Host ''
254
+ Write-Host ("Runtimes: {0} unclassified folders: {1} config templates: {2} withheld (credentials): {3}" -f $runtimes.Count, $uncl.Count, $copied.Count, $withheld.Count)
255
+ foreach ($r in $runtimes) { Write-Host (" {0,-11} {1,-12} {2,-35} {3} MB{4}" -f $r.kind, $r.version, $r.wasAt, $r.installedSizeMB, $(if (@($r.dataDirs).Count) { " data: " + (($r.dataDirs | ForEach-Object { "$($_.path) $($_.sizeMB) MB" }) -join ', ') } else { '' })) }
256
+ foreach ($u in $uncl) { Write-PortWarn ("UNCLASSIFIED {0} ({1} MB) {2}" -f $u.path, $u.sizeMB, ($u.hints -join ', ')) }
257
+ Write-Host " written to $OutDir (runtimes.json, README.md, provision.ps1, config-templates/)"
258
+ exit 0
@@ -27,7 +27,10 @@ param(
27
27
  [switch]$AllowExisting,
28
28
  # The staged branches already contain .github/workflows; pushing them WILL run those
29
29
  # workflows (a deploy job included). Pass only after reading them.
30
- [switch]$AllowWorkflowRuns
30
+ [switch]$AllowWorkflowRuns,
31
+ # Publish although the content policy is not met. An explicit owner exception, recorded in
32
+ # manifest.json; never a default.
33
+ [switch]$AcceptContentPolicy
31
34
  )
32
35
 
33
36
  $ErrorActionPreference = 'Stop'
@@ -44,6 +47,22 @@ $readinessPath = Join-Path $stage 'readiness.json'
44
47
  if (-not (Test-Path -LiteralPath $readinessPath)) { throw "No readiness report. Run Test-PortReadiness.ps1 -Name $Name first." }
45
48
  $readiness = Get-Content -LiteralPath $readinessPath -Raw | ConvertFrom-Json
46
49
  if (-not $readiness.ready) { throw "Readiness has $(@($readiness.blockers).Count) blocker(s). Fix them and re-run Test-PortReadiness.ps1 before publishing." }
50
+ $policyPath = Join-Path $stage 'content-policy.json'
51
+ if (-not (Test-Path -LiteralPath $policyPath)) {
52
+ throw "No content-policy report. Run Test-PortContentPolicy.ps1 -Name $Name first (phase 2b)."
53
+ }
54
+ $policyReport = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json
55
+ # The report must describe THIS stage: a clean or re-stage after the check makes it stale.
56
+ if ((Get-Item -LiteralPath $policyPath).LastWriteTime -lt (Get-Item -LiteralPath $manifestPath).LastWriteTime) {
57
+ throw "content-policy.json is older than manifest.json, so it does not describe the current stage. Re-run Test-PortContentPolicy.ps1."
58
+ }
59
+ if (-not $policyReport.compliant) {
60
+ if (-not $AcceptContentPolicy) {
61
+ throw "Content policy not met: $($policyReport.excludedPaths) excluded path(s), $($policyReport.excludedMB) MB in history. Clean it (Invoke-PortHistoryClean.ps1), add keep rules, or pass -AcceptContentPolicy as an explicit owner exception."
62
+ }
63
+ Write-PortWarn "Publishing WITH $($policyReport.excludedPaths) excluded path(s) by explicit owner exception (-AcceptContentPolicy)."
64
+ $manifest | Add-Member -NotePropertyName contentPolicyException -NotePropertyValue ([pscustomobject]@{ at = (Get-Date).ToString('o'); excludedPaths = $policyReport.excludedPaths; excludedMB = $policyReport.excludedMB }) -Force
65
+ }
47
66
  $live = @()
48
67
  if ($readiness.PSObject.Properties.Name -contains 'branchesWithGithubWorkflows') { $live = @($readiness.branchesWithGithubWorkflows) }
49
68
  if ($live.Count -gt 0 -and -not $AllowWorkflowRuns) {
@@ -0,0 +1,218 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Check a staged repository against the source-only content policy, over the tips AND all history.
4
+
5
+ .DESCRIPTION
6
+ The policy: a repository holds application code, configuration and templates, migration
7
+ SCRIPTS, tests, and the scripts/manifests that rebuild the environment. It does NOT hold:
8
+
9
+ runtime a runtime or server distribution (MariaDB, PHP, JDK, Node, Python, Apache...)
10
+ binary compiled or packaged files (.exe .dll .so .jar .msi, archives)
11
+ db-data database data files (ibdata1, *.ibd, *.frm, *.mdf, SQLite files, dumps)
12
+ data-export data files above the size threshold (.csv .xlsx .json .xml .sql ...), or any
13
+ data file under a backups/dumps/exports folder
14
+ dependency installed dependencies (node_modules, vendor, .venv, site-packages)
15
+ backup editor and copy backups (*.bak, *.orig, *.old, *~)
16
+
17
+ Large files that match no rule are reported as 'large-review' (a decision, not a violation).
18
+
19
+ .gitignore does not remove a file that was committed before it: every excluded path is
20
+ checked in ALL history, not only in the current files.
21
+
22
+ Reads <staging>\<Name>\mirror.git. Changes nothing. Writes content-policy.json next to it.
23
+ Exit 0: compliant. Exit 1: at least one excluded path exists somewhere in history.
24
+
25
+ Project overrides (optional) in the config file, key "contentPolicy":
26
+ { "keep": ["regex", ...], "exclude": ["regex", ...], "dataThresholdMB": 5, "largeMB": 10 }
27
+ Regexes match the repository path (forward slashes).
28
+ #>
29
+ [CmdletBinding()]
30
+ param(
31
+ [Parameter(Mandatory)][string]$Name,
32
+ [string]$StagingRoot,
33
+ [string]$ConfigPath,
34
+ [double]$DataThresholdMB,
35
+ [double]$LargeMB,
36
+ # Print every finding, not only the per-folder summary.
37
+ [switch]$Detail
38
+ )
39
+
40
+ $ErrorActionPreference = 'Stop'
41
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
42
+
43
+ $config = Get-PortConfig $ConfigPath
44
+ $stage = Join-Path (Get-PortStagingRoot $config $StagingRoot) $Name
45
+ $mirror = Join-Path $stage 'mirror.git'
46
+ if (-not (Test-Path -LiteralPath $mirror)) { throw "No staged mirror at $mirror. Run Get-PortSource.ps1 first." }
47
+ $manifest = Get-Content -LiteralPath (Join-Path $stage 'manifest.json') -Raw | ConvertFrom-Json
48
+ $branch = $manifest.defaultBranch
49
+
50
+ $policy = Get-PortConfigValue $config 'contentPolicy' $null
51
+ $keepRx = @(); $extraExcludeRx = @()
52
+ if ($policy) {
53
+ if ($policy.PSObject.Properties.Name -contains 'keep') { $keepRx = @($policy.keep) }
54
+ if ($policy.PSObject.Properties.Name -contains 'exclude') { $extraExcludeRx = @($policy.exclude) }
55
+ if (-not $DataThresholdMB -and $policy.PSObject.Properties.Name -contains 'dataThresholdMB') { $DataThresholdMB = [double]$policy.dataThresholdMB }
56
+ if (-not $LargeMB -and $policy.PSObject.Properties.Name -contains 'largeMB') { $LargeMB = [double]$policy.largeMB }
57
+ }
58
+ if (-not $DataThresholdMB) { $DataThresholdMB = 5 }
59
+ if (-not $LargeMB) { $LargeMB = 10 }
60
+ $dataBytes = [int64]($DataThresholdMB * 1MB)
61
+ $largeBytes = [int64]($LargeMB * 1MB)
62
+
63
+ # A runtime folder is the runtime's NAME, optionally followed by a version or platform tag
64
+ # (php-8.3.33, jdk-17.0.2, node-v20.11.0-win-x64, apache24). Not any folder that merely STARTS
65
+ # with one: redis-migrations/ or apache-poi-wrapper/ are source.
66
+ $ver = '([-_ ]?v?\d[\w.]*(-(win|linux|x64|x86|amd64|arm64)[\w.-]*)?)?'
67
+ $runtimeDirRx = "(?i)^(mariadb|mysql|mysql-server|php|jdk|jre|openjdk|java|python|python-embed|nodejs|node|apache|httpd|nginx|tomcat|apache-tomcat|redis|postgres|postgresql|pgsql|erlang|ruby|dotnet|go)$ver$"
68
+ $runtimeBinRx = '(?i)^(mariadbd|mysqld|mariadb|mysql|php|php-cgi|node|python|pythonw|java|javaw|httpd|nginx|redis-server|postgres|pg_ctl|erl|ruby|dotnet)\.exe$'
69
+ $dependencyDirRx = '(?i)^(node_modules|vendor|\.venv|venv|site-packages|bower_components|\.yarn|jspm_packages)$'
70
+ $binaryExtRx = '(?i)\.(exe|dll|so|dylib|msi|msix|bin|iso|img|jar|war|ear|class|pyc|pdb|lib|a|o|obj|zip|7z|tar|gz|tgz|rar|bz2|xz|cab|nupkg|whl|deb|rpm)$'
71
+ $dbDataRx = '(?i)(^|/)(ibdata\d*|ib_logfile\d*|ib_buffer_pool|aria_log[\w.]*|undo\d+)$|\.(ibd|frm|myd|myi|mdf|ldf|ndf|sqlite3?|db3|dmp)$'
72
+ $backupRx = '(?i)(\.(bak|orig|old|swp)|~)$'
73
+ $dataExtRx = '(?i)\.(csv|tsv|xlsx|xlsm|xlsb|xls|ods|parquet|avro|orc|jsonl|ndjson|json|xml|sql|dat|txt|log)$'
74
+ $dataDirRx = '(?i)^(backups?|dumps?|exports?|snapshots?|data-?dumps?)$'
75
+
76
+ function Get-Category([string]$Path, [int64]$Bytes) {
77
+ foreach ($rx in $keepRx) { if ($Path -match $rx) { return $null } }
78
+ foreach ($rx in $extraExcludeRx) { if ($Path -match $rx) { return 'project-exclude' } }
79
+ $parts = $Path -split '/'
80
+ $dirs = if ($parts.Count -gt 1) { $parts[0..($parts.Count - 2)] } else { @() }
81
+ $leaf = $parts[-1]
82
+ foreach ($d in $dirs) { if ($d -match $runtimeDirRx) { return 'runtime' } }
83
+ if ($leaf -match $runtimeBinRx) { return 'runtime' }
84
+ foreach ($d in $dirs) { if ($d -match $dependencyDirRx) { return 'dependency' } }
85
+ if ($Path -match $dbDataRx) { return 'db-data' }
86
+ if ($leaf -match $backupRx) { return 'backup' }
87
+ if ($leaf -match $binaryExtRx) { return 'binary' }
88
+ if ($leaf -match $dataExtRx) {
89
+ # Under a backups/dumps folder any data file is excluded, but a plain note (.txt/.md) is not data.
90
+ if ($leaf -notmatch '(?i)\.(txt|md)$') { foreach ($d in $dirs) { if ($d -match $dataDirRx) { return 'data-export' } } }
91
+ # Migration SCRIPTS stay by policy. A large .sql under migration(s)/ may be a script or a
92
+ # data load, so it is a decision, not an automatic exclusion. Data FILES there (csv, json,
93
+ # xlsx) are still data.
94
+ if ($leaf -match '(?i)\.sql$' -and ($dirs | Where-Object { $_ -match '(?i)^migrations?$' })) {
95
+ if ($Bytes -ge $largeBytes -or $Bytes -ge $dataBytes) { return 'large-review' }
96
+ return $null
97
+ }
98
+ if ($Bytes -ge $dataBytes) { return 'data-export' }
99
+ }
100
+ if ($Bytes -ge $largeBytes) { return 'large-review' }
101
+ return $null
102
+ }
103
+
104
+ function MGit([string[]]$a) { Invoke-PortGit (@('-C', $mirror) + $a) }
105
+
106
+ # ---- every blob in history, with every path it ever had ----------------------------------
107
+ Write-PortStep 'Reading every path and blob in history'
108
+ $pathsBySha = @{}
109
+ $submodules = New-Object System.Collections.Generic.HashSet[string]
110
+ function Add-PathForSha([string]$sha, [string]$p) {
111
+ if (-not $pathsBySha.ContainsKey($sha)) { $pathsBySha[$sha] = New-Object System.Collections.Generic.HashSet[string] }
112
+ [void]$pathsBySha[$sha].Add($p)
113
+ }
114
+ # EVERY path a blob was ever written to. rev-list --objects names each blob only ONCE, by the
115
+ # first path it meets, so identical content at a second path was invisible (found by the
116
+ # dogfood: a 3-byte test image at two paths survived a clean). log --raw lists every add or
117
+ # change, per commit, including merges (-m) and root commits.
118
+ foreach ($l in (MGit @('-c', 'core.quotePath=false', 'log', '--all', '--raw', '--no-abbrev', '--no-renames', '-m', '--root', '--format=', '--diff-filter=AMT'))) {
119
+ if (-not $l -or $l[0] -ne ':') { continue }
120
+ $meta, $p = $l -split "`t", 2
121
+ $f = $meta -split ' '
122
+ if ($f.Count -lt 4 -or -not $p -or $f[3] -match '^0+$') { continue }
123
+ # Mode 160000 is a submodule: its "blob" is a commit in ANOTHER repository, invisible to a
124
+ # size scan. Record it, so a runtime vendored as a submodule cannot pass unnoticed.
125
+ if ($f[1] -eq '160000') { [void]$submodules.Add($p); continue }
126
+ Add-PathForSha $f[3] $p
127
+ }
128
+ # Safety net: anything reachable that the log did not show.
129
+ foreach ($l in (MGit @('rev-list', '--objects', '--all'))) {
130
+ $sha, $p = $l -split ' ', 2
131
+ if ($p -and -not $pathsBySha.ContainsKey($sha)) { Add-PathForSha $sha $p }
132
+ }
133
+ $sizeBySha = @{}
134
+ foreach ($l in (MGit @('cat-file', '--batch-all-objects', '--batch-check=%(objecttype) %(objectname) %(objectsize)'))) {
135
+ $t, $o, $s = $l -split ' '
136
+ if ($t -eq 'blob' -and $pathsBySha.ContainsKey($o)) { $sizeBySha[$o] = [int64]$s }
137
+ }
138
+
139
+ # Aggregate per path: largest version, number of versions.
140
+ $byPath = @{}
141
+ foreach ($sha in $sizeBySha.Keys) {
142
+ foreach ($p in $pathsBySha[$sha]) {
143
+ if (-not $byPath.ContainsKey($p)) { $byPath[$p] = [pscustomobject]@{ path = $p; maxBytes = [int64]0; versions = 0 } }
144
+ $e = $byPath[$p]; $e.versions++
145
+ if ($sizeBySha[$sha] -gt $e.maxBytes) { $e.maxBytes = $sizeBySha[$sha] }
146
+ }
147
+ }
148
+
149
+ $tipFiles = @{}
150
+ foreach ($l in (MGit @('-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', $branch))) { $tipFiles[$l] = $true }
151
+
152
+ # ---- classify ----------------------------------------------------------------------------------
153
+ $findings = New-Object System.Collections.Generic.List[object]
154
+ foreach ($e in $byPath.Values) {
155
+ $cat = Get-Category $e.path $e.maxBytes
156
+ if (-not $cat) { continue }
157
+ $findings.Add([pscustomobject]@{
158
+ path = $e.path; category = $cat; maxBytes = $e.maxBytes; versions = $e.versions
159
+ inDefaultBranch = [bool]$tipFiles[$e.path]
160
+ action = if ($cat -eq 'large-review') { 'review' } else { 'exclude' }
161
+ })
162
+ }
163
+ foreach ($sm in $submodules) {
164
+ $findings.Add([pscustomobject]@{ path = $sm; category = 'submodule'; maxBytes = [int64]0; versions = 1; inDefaultBranch = [bool]$tipFiles[$sm]; action = 'review' })
165
+ }
166
+ $exclude = @($findings | Where-Object { $_.action -eq 'exclude' })
167
+ $review = @($findings | Where-Object { $_.action -eq 'review' })
168
+
169
+ # Group by top two path segments so a 3,000-file vendored runtime reads as one line.
170
+ # Group by folder, at most 4 levels deep, so one vendored runtime reads as one line and a
171
+ # data folder deep in an app is still named precisely.
172
+ function Get-Group([string]$p) { $s = $p -split '/'; if ($s.Count -le 1) { return $p }; $d = $s[0..([math]::Min(3, $s.Count - 2))]; ($d -join '/') + '/' }
173
+ $groups = @($findings | Group-Object { "$($_.category)|$(Get-Group $_.path)" } | ForEach-Object {
174
+ $cat, $grp = $_.Name -split '\|', 2
175
+ [pscustomobject]@{
176
+ category = $cat; group = $grp; files = $_.Count
177
+ historyMB = [math]::Round((($_.Group | Measure-Object maxBytes -Sum).Sum) / 1MB, 1)
178
+ inDefaultBranch = @($_.Group | Where-Object { $_.inDefaultBranch }).Count
179
+ }
180
+ } | Sort-Object historyMB -Descending)
181
+
182
+ $totalBytes = [int64]0; foreach ($v in $sizeBySha.Values) { $totalBytes += $v }
183
+ # Count each blob once, even when it sits at several excluded paths (a per-path sum can exceed
184
+ # the whole history). Plain loops: Measure-Object on NO input has no .Sum under strict mode.
185
+ $exPaths = New-Object System.Collections.Generic.HashSet[string]
186
+ foreach ($x in $exclude) { [void]$exPaths.Add($x.path) }
187
+ $exBytes = [int64]0
188
+ foreach ($sha in $sizeBySha.Keys) { foreach ($p in $pathsBySha[$sha]) { if ($exPaths.Contains($p)) { $exBytes += $sizeBySha[$sha]; break } } }
189
+ $report = [ordered]@{
190
+ name = $Name; defaultBranch = $branch; checkedAt = (Get-Date).ToString('o')
191
+ thresholds = [ordered]@{ dataThresholdMB = $DataThresholdMB; largeMB = $LargeMB }
192
+ historyBlobMB = [math]::Round($totalBytes / 1MB, 1)
193
+ excludedPaths = $exclude.Count; excludedMB = [math]::Round($exBytes / 1MB, 1)
194
+ reviewPaths = $review.Count
195
+ compliant = ($exclude.Count -eq 0)
196
+ groups = $groups
197
+ findings = @($findings | Sort-Object maxBytes -Descending)
198
+ }
199
+ ConvertTo-PortJsonFile $report (Join-Path $stage 'content-policy.json')
200
+ # Plain path list, one per line, for Invoke-PortHistoryClean.ps1 and for .gitignore review.
201
+ Set-Content -LiteralPath (Join-Path $stage 'content-policy-exclude.txt') -Value @($exclude | Sort-Object path | ForEach-Object { $_.path }) -Encoding UTF8
202
+
203
+ Write-Host ''
204
+ Write-Host ("Content policy for {0}: {1} excluded path(s), {2} MB of history; {3} path(s) to review. History holds {4} MB of blobs in total." -f $Name, $exclude.Count, $report.excludedMB, $review.Count, $report.historyBlobMB)
205
+ if ($groups.Count -gt 0) {
206
+ Write-Host ''
207
+ Write-Host ('{0,-13} {1,-55} {2,7} {3,10} {4}' -f 'category', 'where', 'files', 'MB', 'in default branch')
208
+ foreach ($g in $groups) { Write-Host ('{0,-13} {1,-55} {2,7} {3,10} {4}' -f $g.category, $g.group, $g.files, $g.historyMB, $g.inDefaultBranch) }
209
+ }
210
+ if ($Detail) { $findings | Sort-Object maxBytes -Descending | ForEach-Object { Write-Host (' {0,-13} {1,9:N0} KB {2}' -f $_.category, ($_.maxBytes / 1KB), $_.path) } }
211
+ Write-Host " report: $(Join-Path $stage 'content-policy.json')"
212
+ Write-Host " exclude list: $(Join-Path $stage 'content-policy-exclude.txt')"
213
+ if ($exclude.Count -gt 0) {
214
+ Write-PortWarn 'Not compliant. Decide per group: keep (add a "keep" regex to contentPolicy), or remove from history with Invoke-PortHistoryClean.ps1. Adding a path to .gitignore does NOT remove it from history.'
215
+ exit 1
216
+ }
217
+ Write-PortOk 'Compliant: no excluded content anywhere in history.'
218
+ exit 0