@caddis/cli 0.4.19 → 0.4.21

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.
Files changed (40) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/skills/_registry.md +4 -2
  3. package/bundles/antigravity-plugin/skills/data-lineage/SKILL.md +32 -0
  4. package/bundles/antigravity-plugin/skills/hld/SKILL.md +20 -8
  5. package/bundles/antigravity-plugin/skills/lld/SKILL.md +27 -12
  6. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +231 -0
  7. package/bundles/antigravity-plugin/skills/port-to-github/references/ci-and-credentials.md +66 -0
  8. package/bundles/antigravity-plugin/skills/port-to-github/references/config.example.json +18 -0
  9. package/bundles/antigravity-plugin/skills/port-to-github/references/old-host.md +61 -0
  10. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
  11. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
  12. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Get-PortSource.ps1 +459 -0
  13. package/bundles/antigravity-plugin/skills/port-to-github/scripts/PortCommon.ps1 +130 -0
  14. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
  15. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
  16. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
  17. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
  18. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  19. package/bundles/antigravity-plugin-extras/skills/_registry.md +3 -2
  20. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +158 -0
  21. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +1430 -0
  22. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +155 -35
  23. package/bundles/antigravity-plugin-extras/skills/hld/references/gold-standard-hld.md +419 -0
  24. package/bundles/antigravity-plugin-extras/skills/hld/scripts/convert_doc_to_confluence.py +900 -184
  25. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +145 -41
  26. package/bundles/antigravity-plugin-extras/skills/lld/scripts/convert_doc_to_confluence.py +900 -184
  27. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +231 -0
  28. package/bundles/codex/.codex/skills/devops/port-to-github/references/ci-and-credentials.md +66 -0
  29. package/bundles/codex/.codex/skills/devops/port-to-github/references/config.example.json +18 -0
  30. package/bundles/codex/.codex/skills/devops/port-to-github/references/old-host.md +61 -0
  31. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
  32. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
  33. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Get-PortSource.ps1 +459 -0
  34. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/PortCommon.ps1 +130 -0
  35. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
  36. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
  37. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
  38. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
  39. package/bundles/manifest.json +3 -3
  40. package/package.json +1 -1
@@ -0,0 +1,459 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Copy an app from ANY source into a local staging bare repository, ready to push to GitHub.
4
+
5
+ .DESCRIPTION
6
+ Source kinds, detected from -Source:
7
+ url https://, http://, ssh://, git://, or user@host:path (Gitea, GitLab, Bitbucket, any git server)
8
+ local a folder on this machine, with or without a .git
9
+ unc \\server\share\folder, with or without a .git
10
+ winrm winrm://HOST/C:/path, or -ComputerName HOST with -Source C:\path
11
+
12
+ Every kind ends in the same place: <staging>\<Name>\mirror.git, a bare repository holding
13
+ only refs/heads/* and refs/tags/*, with NO remote configured (so a stray push can never go
14
+ back to the source), plus <staging>\<Name>\manifest.json describing what was found.
15
+
16
+ The source is never modified. A folder without git history becomes one import commit.
17
+
18
+ .EXAMPLE
19
+ ./Get-PortSource.ps1 -Source http://gitea.example:3000/org/app.git
20
+ ./Get-PortSource.ps1 -Source D:\work\app -IncludeUncommitted
21
+ ./Get-PortSource.ps1 -Source \\fileserver\projects\app
22
+ ./Get-PortSource.ps1 -Source winrm://prodbox/D:/apps/app
23
+ #>
24
+ [CmdletBinding()]
25
+ param(
26
+ [Parameter(Mandatory)][string]$Source,
27
+ [string]$ComputerName,
28
+ [pscredential]$Credential,
29
+ [string]$Name,
30
+ [string]$StagingRoot,
31
+ [string]$ConfigPath,
32
+ # Put uncommitted and untracked changes on a separate branch instead of leaving them behind.
33
+ [switch]$IncludeUncommitted,
34
+ # Turn remote-tracking branches of this remote (e.g. 'origin') into real branches when the
35
+ # source has no local branch of that name. Use when a checkout is the only surviving copy.
36
+ [string]$PromoteRemoteBranches,
37
+ # What to do when a plain folder or an uncommitted snapshot contains a likely secret.
38
+ [ValidateSet('Stop', 'Exclude')][string]$OnSecret = 'Stop',
39
+ [string]$InitialBranch = 'main',
40
+ # Replace an existing staging folder for this Name.
41
+ [switch]$Force
42
+ )
43
+
44
+ $ErrorActionPreference = 'Stop'
45
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
46
+
47
+ $config = Get-PortConfig $ConfigPath
48
+ $stagingBase = Get-PortStagingRoot $config $StagingRoot
49
+
50
+ # ---- 1. Work out what kind of source this is -------------------------------------------
51
+ $remotePath = $null
52
+ if ($Source -match '^winrm://(?<host>[^/]+)/(?<path>.+)$') {
53
+ $ComputerName = $Matches.host
54
+ $remotePath = ($Matches.path -replace '/', '\')
55
+ }
56
+ elseif ($ComputerName) {
57
+ $remotePath = $Source
58
+ }
59
+
60
+ if ($ComputerName) { $kind = 'winrm' }
61
+ elseif ($Source -match '^(https?|ssh|git)://' -or $Source -match '^[^@\s\\/]+@[^:\s]+:') { $kind = 'url' }
62
+ elseif ($Source -match '^\\\\') { $kind = 'unc' }
63
+ else { $kind = 'local' }
64
+
65
+ if (-not $Name) {
66
+ $leaf = ($Source.TrimEnd('/', '\') -split '[\\/:]')[-1]
67
+ $Name = $leaf -replace '\.git$', ''
68
+ }
69
+ if ($Name -notmatch '^[A-Za-z0-9._-]+$') { throw "Name '$Name' is not a valid GitHub repository name. Pass -Name." }
70
+
71
+ $stage = Join-Path $stagingBase $Name
72
+ if (Test-Path -LiteralPath $stage) {
73
+ if (-not $Force) { throw "Staging folder $stage already exists. Pass -Force to replace it." }
74
+ Remove-Item -LiteralPath $stage -Recurse -Force
75
+ }
76
+ New-Item -ItemType Directory -Force -Path $stage | Out-Null
77
+ $mirror = Join-Path $stage 'mirror.git'
78
+ $work = Join-Path $stage 'work'
79
+
80
+ $manifest = [ordered]@{
81
+ name = $Name
82
+ sourceKind = $kind
83
+ source = (Remove-UrlCredential $Source).Url
84
+ computerName = $ComputerName
85
+ capturedAt = (Get-Date).ToString('o')
86
+ hadGitHistory = $false
87
+ sourceHead = $null
88
+ defaultBranch = $null
89
+ dirtyFiles = @()
90
+ snapshotBranch = $null
91
+ remoteOnlyBranches = @()
92
+ promotedBranches = @()
93
+ droppedRefs = @()
94
+ secretFindings = @()
95
+ excludedSecretFiles = @()
96
+ suspectDirs = @()
97
+ largeFiles = @()
98
+ heads = @()
99
+ tags = @()
100
+ status = 'in-progress'
101
+ warnings = @()
102
+ }
103
+ if ((Remove-UrlCredential $Source).HadCredential) {
104
+ $manifest.warnings += 'The source URL embedded a credential. It was used for the clone and is not recorded. Consider rotating it.'
105
+ }
106
+
107
+ function Save-Manifest { ConvertTo-PortJsonFile $manifest (Join-Path $stage 'manifest.json') }
108
+
109
+ # ---- helpers used by more than one kind ---------------------------------------------------
110
+
111
+ function Find-Secrets([string]$Root, [string[]]$RelativeFiles) {
112
+ $found = @()
113
+ foreach ($rel in $RelativeFiles) {
114
+ $full = Join-Path $Root $rel
115
+ if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { continue }
116
+ $leafName = Split-Path $rel -Leaf
117
+ if (Test-PortSecretName $leafName) {
118
+ $found += [pscustomobject]@{ file = ($rel -replace '\\', '/'); reason = 'file name' }
119
+ continue
120
+ }
121
+ $info = Get-Item -LiteralPath $full
122
+ if ($info.Length -gt 1MB) { continue }
123
+ $text = $null
124
+ try { $text = [IO.File]::ReadAllText($full) } catch { continue }
125
+ if ($text -match "`0") { continue } # binary
126
+ $hit = Test-PortSecretContent $text
127
+ if ($hit) { $found += [pscustomobject]@{ file = ($rel -replace '\\', '/'); reason = "content matches $hit" } }
128
+ }
129
+ return , $found
130
+ }
131
+
132
+ function Resolve-Secrets([string]$Root, [string[]]$RelativeFiles, [string]$Context) {
133
+ $found = Find-Secrets $Root $RelativeFiles
134
+ if ($found.Count -eq 0) { return @() }
135
+ $manifest.secretFindings += $found
136
+ foreach ($f in $found) { Write-PortWarn "Likely secret in ${Context}: $($f.file) ($($f.reason))" }
137
+ if ($OnSecret -eq 'Stop') {
138
+ $manifest.status = 'blocked-secrets'
139
+ Save-Manifest
140
+ Write-PortFail "Stopped: $($found.Count) likely secret file(s). Nothing was pushed anywhere. Review them, then re-run with -OnSecret Exclude to leave them out, or move the secret out of the file."
141
+ exit 2
142
+ }
143
+ foreach ($f in $found) {
144
+ Remove-Item -LiteralPath (Join-Path $Root $f.file) -Force
145
+ $manifest.excludedSecretFiles += $f.file
146
+ }
147
+ return @($found | ForEach-Object { $_.file })
148
+ }
149
+
150
+ function Copy-Tree([string]$From, [string]$To) {
151
+ New-Item -ItemType Directory -Force -Path $To | Out-Null
152
+ $xd = @($script:PortJunkDirs)
153
+ & robocopy $From $To /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP /XJ /XD @xd | Out-Null
154
+ if ($LASTEXITCODE -ge 8) { throw "robocopy $From -> $To failed with exit $LASTEXITCODE" }
155
+ $global:LASTEXITCODE = 0
156
+ }
157
+
158
+ function Import-PlainTree([string]$Tree) {
159
+ # A folder with no history becomes ONE import commit on $InitialBranch.
160
+ $files = @(Get-ChildItem -LiteralPath $Tree -Recurse -File -Force | ForEach-Object { $_.FullName.Substring($Tree.Length).TrimStart('\', '/') })
161
+ if ($files.Count -eq 0) { throw "The source folder has no files to import." }
162
+ $excluded = @(Resolve-Secrets $Tree $files 'the source folder')
163
+
164
+ foreach ($d in $script:PortSuspectDirs) {
165
+ if (Test-Path -LiteralPath (Join-Path $Tree $d) -PathType Container) { $manifest.suspectDirs += $d }
166
+ }
167
+ foreach ($f in (Get-ChildItem -LiteralPath $Tree -Recurse -File -Force | Where-Object { $_.Length -gt 50MB })) {
168
+ $manifest.largeFiles += [pscustomobject]@{ file = $f.FullName.Substring($Tree.Length).TrimStart('\'); bytes = $f.Length }
169
+ }
170
+
171
+ $gi = Join-Path $Tree '.gitignore'
172
+ $ignoreLines = @()
173
+ if (-not (Test-Path -LiteralPath $gi)) {
174
+ $ignoreLines += '# Written by port-to-github for a folder that had no .gitignore.'
175
+ $ignoreLines += ($script:PortJunkDirs | Where-Object { $_ -ne '.git' } | ForEach-Object { "$_/" })
176
+ $ignoreLines += @('.env', '.env.*', '!.env.example', '*.log')
177
+ }
178
+ if ($excluded.Count -gt 0) {
179
+ $ignoreLines += '# Likely secrets left out of the import by port-to-github:'
180
+ $ignoreLines += ($excluded | ForEach-Object { "/$_" })
181
+ }
182
+ if ($ignoreLines.Count -gt 0) { Add-Content -LiteralPath $gi -Value $ignoreLines -Encoding UTF8 }
183
+
184
+ Invoke-PortGit @('init', '-q', '-b', $InitialBranch, $Tree) | Out-Null
185
+ $email = Invoke-PortGit -AllowFailure @('-C', $Tree, 'config', 'user.email')
186
+ if ($email.ExitCode -ne 0 -or -not ($email.Output -join '')) {
187
+ throw "git has no user.email for this folder. Set your identity (git config --global user.email ...) and re-run."
188
+ }
189
+ # A brand-new repository built from a copied folder: adding everything IS the import.
190
+ Invoke-PortGit @('-C', $Tree, 'add', '-A') | Out-Null
191
+ Invoke-PortGit @('-C', $Tree, 'commit', '-q', '-m', "Import $Name from a $kind folder (no prior git history)", '-m', "Source: $($manifest.source)") | Out-Null
192
+ Invoke-PortGit @('clone', '-q', '--mirror', $Tree, $mirror) | Out-Null
193
+ }
194
+
195
+ function Add-Snapshot([string]$DirtyRoot, [string[]]$Changed, [string[]]$Deleted, [string]$BaseRef) {
196
+ # Uncommitted work goes on its OWN branch, so the real branches stay exactly as committed.
197
+ $snapTree = Join-Path $work 'snapshot'
198
+ Invoke-PortGit @('clone', '-q', $mirror, $snapTree) | Out-Null
199
+ Invoke-PortGit @('-C', $snapTree, 'checkout', '-q', '--detach', $BaseRef) | Out-Null
200
+ $branch = 'port/uncommitted-snapshot-' + (Get-Date -Format 'yyyyMMdd-HHmm')
201
+ Invoke-PortGit @('-C', $snapTree, 'checkout', '-q', '-b', $branch) | Out-Null
202
+ foreach ($rel in $Changed) {
203
+ $src = Join-Path $DirtyRoot $rel
204
+ if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { continue }
205
+ $dst = Join-Path $snapTree $rel
206
+ New-Item -ItemType Directory -Force -Path (Split-Path $dst -Parent) | Out-Null
207
+ Copy-Item -LiteralPath $src -Destination $dst -Force
208
+ }
209
+ foreach ($rel in $Deleted) {
210
+ $dst = Join-Path $snapTree $rel
211
+ if (Test-Path -LiteralPath $dst) { Remove-Item -LiteralPath $dst -Force }
212
+ }
213
+ $excluded = @(Resolve-Secrets $snapTree $Changed 'the uncommitted changes')
214
+ foreach ($rel in $excluded) { Invoke-PortGit -AllowFailure @('-C', $snapTree, 'rm', '-q', '--cached', '--ignore-unmatch', '--', $rel) | Out-Null }
215
+ # A throwaway clone that holds only the snapshot: adding everything is the snapshot.
216
+ Invoke-PortGit @('-C', $snapTree, 'add', '-A') | Out-Null
217
+ $pending = Invoke-PortGit @('-C', $snapTree, 'status', '--porcelain')
218
+ if (-not ($pending -join '')) { Write-PortWarn 'The uncommitted changes produced no difference. No snapshot branch.'; return }
219
+ Invoke-PortGit @('-C', $snapTree, 'commit', '-q', '-m', "Snapshot of uncommitted work found in the source during the port", '-m', "Source: $($manifest.source). Base: $BaseRef.") | Out-Null
220
+ Invoke-PortGit @('-C', $snapTree, 'push', '-q', $mirror, "HEAD:refs/heads/$branch") | Out-Null
221
+ $manifest.snapshotBranch = $branch
222
+ Write-PortOk "Uncommitted work saved on branch $branch"
223
+ }
224
+
225
+ # ---- 2. Acquire -----------------------------------------------------------------------------
226
+ Write-PortStep "Source kind: $kind"
227
+ switch ($kind) {
228
+ 'url' {
229
+ Write-PortStep "Mirror-cloning $($manifest.source)"
230
+ Invoke-PortGit @('clone', '-q', '--mirror', $Source, $mirror) | Out-Null
231
+ $manifest.hadGitHistory = $true
232
+ }
233
+ { $_ -in 'local', 'unc' } {
234
+ if (-not (Test-Path -LiteralPath $Source -PathType Container)) { throw "Folder not found: $Source" }
235
+ $full = (Resolve-Path -LiteralPath $Source).ProviderPath.TrimEnd('\')
236
+ $top = Invoke-PortGit -AllowFailure @('-C', $full, 'rev-parse', '--show-toplevel')
237
+ $bare = Invoke-PortGit -AllowFailure @('-C', $full, 'rev-parse', '--is-bare-repository')
238
+ $isBare = ($bare.ExitCode -eq 0 -and ($bare.Output -join '') -eq 'true')
239
+ $isRepo = $isBare
240
+ if (-not $isBare -and $top.ExitCode -eq 0) {
241
+ $topPath = ($top.Output -join '').Replace('/', '\').TrimEnd('\')
242
+ if ($topPath -ieq $full) { $isRepo = $true }
243
+ else {
244
+ throw "$full is a SUBFOLDER of the repository $topPath. Port the whole repository, or split the folder out first (git subtree split --prefix=<dir>) and port the result."
245
+ }
246
+ }
247
+ if ($isRepo) {
248
+ Write-PortStep "Folder is a git repository. Mirror-cloning it (history is kept)."
249
+ Invoke-PortGit @('clone', '-q', '--mirror', '--no-local', $full, $mirror) | Out-Null
250
+ $manifest.hadGitHistory = $true
251
+ if (-not $isBare) {
252
+ $headRes = Invoke-PortGit -AllowFailure @('-C', $full, 'symbolic-ref', '--short', 'HEAD')
253
+ $manifest.sourceHead = if ($headRes.ExitCode -eq 0) { $headRes.Output -join '' } else { (Invoke-PortGit @('-C', $full, 'rev-parse', 'HEAD')) -join '' }
254
+ $manifest.dirtyFiles = @(Invoke-PortGit @('-C', $full, 'status', '--porcelain', '--untracked-files=all'))
255
+ if ($manifest.dirtyFiles.Count -gt 0) {
256
+ if ($IncludeUncommitted) {
257
+ $changed = @(Invoke-PortGit @('-C', $full, 'ls-files', '-m', '-o', '--exclude-standard'))
258
+ $deleted = @(Invoke-PortGit @('-C', $full, 'ls-files', '-d'))
259
+ $baseRef = (Invoke-PortGit @('-C', $full, 'rev-parse', 'HEAD')) -join ''
260
+ Add-Snapshot $full $changed $deleted $baseRef
261
+ }
262
+ else {
263
+ $manifest.warnings += "The source has $($manifest.dirtyFiles.Count) uncommitted change(s). They are NOT included. Re-run with -IncludeUncommitted to keep them on a separate branch."
264
+ }
265
+ }
266
+ }
267
+ }
268
+ else {
269
+ Write-PortStep "Folder has no git history. Importing it as one commit."
270
+ $tree = Join-Path $work 'tree'
271
+ Copy-Tree $full $tree
272
+ Import-PlainTree $tree
273
+ }
274
+ }
275
+ 'winrm' {
276
+ Write-PortStep "Reading $remotePath on $ComputerName over WinRM"
277
+ $sessArgs = @{ ComputerName = $ComputerName }
278
+ if ($Credential) { $sessArgs.Credential = $Credential }
279
+ $session = New-PSSession @sessArgs
280
+ $remote = $null
281
+ try {
282
+ # Runs on the remote box under Windows PowerShell 5.1: keep it 5.1-compatible.
283
+ $remote = Invoke-Command -Session $session -ArgumentList $remotePath, [bool]$IncludeUncommitted, $script:PortJunkDirs -ScriptBlock {
284
+ param($Path, $WantDirty, $JunkDirs)
285
+ # 'Continue', not 'Stop': under Windows PowerShell 5.1 a native command's stderr
286
+ # becomes a TERMINATING error with 'Stop', even with 2>$null - so probing a plain
287
+ # folder with git would kill the block. Git is judged by $LASTEXITCODE instead;
288
+ # cmdlets that must not fail carry -ErrorAction Stop.
289
+ $ErrorActionPreference = 'Continue'
290
+ $r = @{ exists = $false; isRepo = $false; tmp = $null; bundle = $null; treeZip = $null; dirtyZip = $null; dirty = @(); changed = @(); deleted = @(); head = $null; headSha = $null; error = $null }
291
+ if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $r }
292
+ try {
293
+ $r.exists = $true
294
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
295
+ $tmp = Join-Path $env:TEMP ('port-to-github-' + [guid]::NewGuid().ToString('N'))
296
+ New-Item -ItemType Directory -Path $tmp -ErrorAction Stop | Out-Null
297
+ $r.tmp = $tmp
298
+ $git = $null
299
+ $cmd = Get-Command git -ErrorAction SilentlyContinue
300
+ if ($cmd) { $git = $cmd.Source } elseif (Test-Path 'C:\Program Files\Git\cmd\git.exe') { $git = 'C:\Program Files\Git\cmd\git.exe' }
301
+ $isRepo = $false
302
+ $isBare = $false
303
+ if ($git) {
304
+ # A BARE repository has no work tree, so --show-toplevel fails on it. Check
305
+ # for bare first, or the whole history is imported as one plain-folder commit.
306
+ $bareOut = & $git -c safe.directory=* -C $Path rev-parse --is-bare-repository 2>$null
307
+ if ($LASTEXITCODE -eq 0 -and "$bareOut" -eq 'true') { $isRepo = $true; $isBare = $true }
308
+ }
309
+ if ($git -and -not $isBare) {
310
+ $top = & $git -c safe.directory=* -C $Path rev-parse --show-toplevel 2>$null
311
+ if ($LASTEXITCODE -eq 0 -and $top) {
312
+ $topPath = ("$top").Replace('/', '\').TrimEnd('\')
313
+ if ($topPath -ieq ((Resolve-Path -LiteralPath $Path).ProviderPath.TrimEnd('\'))) { $isRepo = $true }
314
+ else { $r.error = "$Path is a subfolder of the repository $topPath"; return $r }
315
+ }
316
+ }
317
+ $r.isRepo = $isRepo
318
+ if ($isRepo) {
319
+ $bundle = Join-Path $tmp 'repo.bundle'
320
+ & $git -c safe.directory=* -C $Path bundle create $bundle --all 2>&1 | Out-Null
321
+ if ($LASTEXITCODE -ne 0) { $r.error = "git bundle failed with exit $LASTEXITCODE"; return $r }
322
+ $r.bundle = $bundle
323
+ $h = & $git -c safe.directory=* -C $Path symbolic-ref --short HEAD 2>$null
324
+ if ($LASTEXITCODE -eq 0) { $r.head = "$h" }
325
+ $r.headSha = "$(& $git -c safe.directory=* -C $Path rev-parse HEAD)"
326
+ # A bare repository has no working tree, so nothing can be uncommitted.
327
+ if (-not $isBare) { $r.dirty = @(& $git -c safe.directory=* -C $Path status --porcelain --untracked-files=all) }
328
+ if ($WantDirty -and $r.dirty.Count -gt 0) {
329
+ $r.changed = @(& $git -c safe.directory=* -C $Path ls-files -m -o --exclude-standard)
330
+ $r.deleted = @(& $git -c safe.directory=* -C $Path ls-files -d)
331
+ $dd = Join-Path $tmp 'dirty'
332
+ New-Item -ItemType Directory -Path $dd -ErrorAction Stop | Out-Null
333
+ foreach ($rel in $r.changed) {
334
+ $src = Join-Path $Path $rel
335
+ if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { continue }
336
+ $dst = Join-Path $dd $rel
337
+ New-Item -ItemType Directory -Force -Path (Split-Path $dst -Parent) -ErrorAction Stop | Out-Null
338
+ Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop
339
+ }
340
+ $r.dirtyZip = Join-Path $tmp 'dirty.zip'
341
+ [IO.Compression.ZipFile]::CreateFromDirectory($dd, $r.dirtyZip)
342
+ }
343
+ }
344
+ else {
345
+ $tree = Join-Path $tmp 'tree'
346
+ & robocopy $Path $tree /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP /XJ /XD @JunkDirs | Out-Null
347
+ if ($LASTEXITCODE -ge 8) { $r.error = "robocopy failed with exit $LASTEXITCODE"; return $r }
348
+ $r.treeZip = Join-Path $tmp 'tree.zip'
349
+ # ZipFile, not Compress-Archive: Compress-Archive's wildcard skips hidden files (.env, .gitignore).
350
+ [IO.Compression.ZipFile]::CreateFromDirectory($tree, $r.treeZip)
351
+ }
352
+ } catch { $r.error = "remote step failed: $($_.Exception.Message)" }
353
+ return $r
354
+ }
355
+ if (-not $remote.exists) { throw "Folder not found on ${ComputerName}: $remotePath" }
356
+ if ($remote.error) { throw "Remote read failed on ${ComputerName}: $($remote.error)" }
357
+ New-Item -ItemType Directory -Force -Path $work | Out-Null
358
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
359
+ if ($remote.isRepo) {
360
+ $localBundle = Join-Path $work 'repo.bundle'
361
+ Copy-Item -FromSession $session -Path $remote.bundle -Destination $localBundle
362
+ Invoke-PortGit @('bundle', 'verify', $localBundle) | Out-Null
363
+ Invoke-PortGit @('clone', '-q', '--mirror', $localBundle, $mirror) | Out-Null
364
+ $manifest.hadGitHistory = $true
365
+ $manifest.sourceHead = if ($remote.head) { $remote.head } else { $remote.headSha }
366
+ $manifest.dirtyFiles = @($remote.dirty)
367
+ if ($manifest.dirtyFiles.Count -gt 0) {
368
+ if ($IncludeUncommitted -and $remote.dirtyZip) {
369
+ $localDirtyZip = Join-Path $work 'dirty.zip'
370
+ Copy-Item -FromSession $session -Path $remote.dirtyZip -Destination $localDirtyZip
371
+ $dirtyRoot = Join-Path $work 'dirty'
372
+ [IO.Compression.ZipFile]::ExtractToDirectory($localDirtyZip, $dirtyRoot)
373
+ Add-Snapshot $dirtyRoot @($remote.changed) @($remote.deleted) $remote.headSha
374
+ }
375
+ elseif (-not $IncludeUncommitted) {
376
+ $manifest.warnings += "The source has $($manifest.dirtyFiles.Count) uncommitted change(s). They are NOT included. Re-run with -IncludeUncommitted to keep them on a separate branch."
377
+ }
378
+ }
379
+ }
380
+ else {
381
+ $localZip = Join-Path $work 'tree.zip'
382
+ Copy-Item -FromSession $session -Path $remote.treeZip -Destination $localZip
383
+ $tree = Join-Path $work 'tree'
384
+ [IO.Compression.ZipFile]::ExtractToDirectory($localZip, $tree)
385
+ Import-PlainTree $tree
386
+ }
387
+ }
388
+ finally {
389
+ if ($session) {
390
+ if ($remote -and $remote.tmp) {
391
+ Invoke-Command -Session $session -ArgumentList $remote.tmp -ScriptBlock { param($t) Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction SilentlyContinue }
392
+ }
393
+ Remove-PSSession $session
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ # ---- 3. Normalise the mirror to heads and tags only ---------------------------------------
400
+ $allRefs = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)'))
401
+ $headNames = @($allRefs | Where-Object { $_ -like 'refs/heads/*' } | ForEach-Object { $_.Substring(11) })
402
+ foreach ($ref in ($allRefs | Where-Object { $_ -like 'refs/remotes/*' })) {
403
+ $parts = $ref.Substring(13) -split '/', 2
404
+ if ($parts.Count -lt 2 -or $parts[1] -eq 'HEAD') { continue }
405
+ if ($headNames -contains $parts[1]) { continue }
406
+ if ($PromoteRemoteBranches -and $parts[0] -eq $PromoteRemoteBranches) {
407
+ Invoke-PortGit @('-C', $mirror, 'update-ref', "refs/heads/$($parts[1])", $ref) | Out-Null
408
+ $manifest.promotedBranches += $parts[1]
409
+ $headNames += $parts[1]
410
+ }
411
+ else {
412
+ $manifest.remoteOnlyBranches += "$($parts[0])/$($parts[1])"
413
+ }
414
+ }
415
+ foreach ($ref in $allRefs) {
416
+ if ($ref -like 'refs/heads/*' -or $ref -like 'refs/tags/*') { continue }
417
+ Invoke-PortGit @('-C', $mirror, 'update-ref', '-d', $ref) | Out-Null
418
+ $manifest.droppedRefs += $ref
419
+ }
420
+ # A mirror clone keeps a push-capable remote pointing at the SOURCE. Remove it, so no command
421
+ # run in this folder can ever write back to where the app came from.
422
+ $remotes = @(Invoke-PortGit @('-C', $mirror, 'remote'))
423
+ foreach ($r in $remotes) { if ($r) { Invoke-PortGit @('-C', $mirror, 'remote', 'remove', $r) | Out-Null } }
424
+
425
+ $heads = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads'))
426
+ $tags = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/tags'))
427
+ $manifest.heads = $heads
428
+ $manifest.tags = $tags
429
+ if ($heads.Count -eq 0) { throw "The source produced no branches. Nothing to port." }
430
+
431
+ $symHead = Invoke-PortGit -AllowFailure @('-C', $mirror, 'symbolic-ref', '--short', 'HEAD')
432
+ $def = if ($symHead.ExitCode -eq 0) { $symHead.Output -join '' } else { $null }
433
+ if (-not $def -or -not ($headNames -contains $def)) {
434
+ $def = @('main', 'master', 'develop') | Where-Object { $headNames -contains $_ } | Select-Object -First 1
435
+ if (-not $def) { $def = $headNames[0] }
436
+ Invoke-PortGit @('-C', $mirror, 'symbolic-ref', 'HEAD', "refs/heads/$def") | Out-Null
437
+ }
438
+ $manifest.defaultBranch = $def
439
+
440
+ if ($manifest.remoteOnlyBranches.Count -gt 0) {
441
+ $manifest.warnings += "$($manifest.remoteOnlyBranches.Count) branch(es) exist only as remote-tracking refs in the source and are NOT included: $($manifest.remoteOnlyBranches -join ', '). If this checkout is the only copy, re-run with -PromoteRemoteBranches <remote>. If a server holds them, port from the server URL instead."
442
+ }
443
+ if ($manifest.excludedSecretFiles.Count -gt 0) {
444
+ $manifest.warnings += "Left out $($manifest.excludedSecretFiles.Count) likely secret file(s): $($manifest.excludedSecretFiles -join ', '). They stay only in the source. Rotate any real credential they hold."
445
+ }
446
+ if ($manifest.suspectDirs.Count -gt 0) {
447
+ $manifest.warnings += "Imported folders that are often build output or data: $($manifest.suspectDirs -join ', '). Check they belong in the repository."
448
+ }
449
+ if ($manifest.largeFiles.Count -gt 0) {
450
+ $manifest.warnings += "$($manifest.largeFiles.Count) file(s) over 50 MB. GitHub rejects files over 100 MB."
451
+ }
452
+
453
+ $manifest.status = 'staged'
454
+ Save-Manifest
455
+
456
+ Write-PortOk "Staged $Name at $mirror"
457
+ Write-Host (" kind={0} history={1} branches={2} tags={3} default={4} dropped-refs={5}" -f $kind, $manifest.hadGitHistory, $heads.Count, $tags.Count, $def, $manifest.droppedRefs.Count)
458
+ foreach ($w in $manifest.warnings) { Write-PortWarn $w }
459
+ Write-Host " manifest: $(Join-Path $stage 'manifest.json')"
@@ -0,0 +1,130 @@
1
+ # Shared helpers for the port-to-github scripts. Dot-source this file.
2
+ # ASCII only: these scripts may be read by Windows PowerShell 5.1.
3
+
4
+ Set-StrictMode -Version 2.0
5
+
6
+ function Write-PortStep([string]$Message) {
7
+ Write-Host "==> $Message" -ForegroundColor Cyan
8
+ }
9
+
10
+ function Write-PortWarn([string]$Message) {
11
+ Write-Host "[WARN] $Message" -ForegroundColor Yellow
12
+ }
13
+
14
+ function Write-PortFail([string]$Message) {
15
+ Write-Host "[FAIL] $Message" -ForegroundColor Red
16
+ }
17
+
18
+ function Write-PortOk([string]$Message) {
19
+ Write-Host "[OK] $Message" -ForegroundColor Green
20
+ }
21
+
22
+ # Run git and fail loud on a non-zero exit. Returns stdout lines.
23
+ # safe.directory=* lets us READ a repository another account owns (a UNC share,
24
+ # a service-owned prod checkout). -c is forwarded to git's subprocesses through
25
+ # GIT_CONFIG_PARAMETERS, so upload-pack sees it too.
26
+ function Invoke-PortGit {
27
+ param(
28
+ [Parameter(Mandatory)][string[]]$Arguments,
29
+ [switch]$AllowFailure
30
+ )
31
+ $all = @('-c', 'safe.directory=*') + $Arguments
32
+ $out = & git @all 2>&1
33
+ $code = $LASTEXITCODE
34
+ $text = @($out | ForEach-Object { "$_" })
35
+ if ($code -ne 0 -and -not $AllowFailure) {
36
+ throw "git $($Arguments -join ' ') failed with exit $code`n$($text -join "`n")"
37
+ }
38
+ if ($AllowFailure) {
39
+ return [pscustomobject]@{ ExitCode = $code; Output = $text }
40
+ }
41
+ return $text
42
+ }
43
+
44
+ # Read defaults from a project config file. Order: -ConfigPath, $env:PORT_TO_GITHUB_CONFIG,
45
+ # .caddis/port-to-github.json in the current git repository. Missing file = empty config.
46
+ function Get-PortConfig([string]$ConfigPath) {
47
+ $candidates = @()
48
+ if ($ConfigPath) { $candidates += $ConfigPath }
49
+ if ($env:PORT_TO_GITHUB_CONFIG) { $candidates += $env:PORT_TO_GITHUB_CONFIG }
50
+ $top = & git rev-parse --show-toplevel 2>$null
51
+ if ($LASTEXITCODE -eq 0 -and $top) {
52
+ $candidates += (Join-Path $top '.caddis/port-to-github.json')
53
+ }
54
+ foreach ($c in $candidates) {
55
+ if ($c -and (Test-Path -LiteralPath $c)) {
56
+ $cfg = Get-Content -LiteralPath $c -Raw | ConvertFrom-Json
57
+ $cfg | Add-Member -NotePropertyName _path -NotePropertyValue (Resolve-Path -LiteralPath $c).Path -Force
58
+ return $cfg
59
+ }
60
+ }
61
+ return [pscustomobject]@{ _path = $null }
62
+ }
63
+
64
+ function Get-PortConfigValue($Config, [string]$Name, $Default) {
65
+ if ($null -ne $Config -and $Config.PSObject.Properties.Name -contains $Name -and $null -ne $Config.$Name -and "$($Config.$Name)" -ne '') {
66
+ return $Config.$Name
67
+ }
68
+ return $Default
69
+ }
70
+
71
+ function Get-PortStagingRoot($Config, [string]$Override) {
72
+ if ($Override) { $root = $Override }
73
+ else { $root = Get-PortConfigValue $Config 'stagingRoot' (Join-Path ([IO.Path]::GetTempPath()) 'port-to-github') }
74
+ $root = [Environment]::ExpandEnvironmentVariables($root)
75
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
76
+ return (Resolve-Path -LiteralPath $root).Path
77
+ }
78
+
79
+ # Remove "user:secret@" from a URL. Returns @{ Url; HadCredential }.
80
+ function Remove-UrlCredential([string]$Url) {
81
+ if ($Url -match '^(?<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?<userinfo>[^@/]+)@(?<rest>.*)$') {
82
+ return [pscustomobject]@{ Url = "$($Matches.scheme)$($Matches.rest)"; HadCredential = ($Matches.userinfo -match ':') }
83
+ }
84
+ return [pscustomobject]@{ Url = $Url; HadCredential = $false }
85
+ }
86
+
87
+ # Directories that are never source, skipped when importing a plain folder (no .git).
88
+ # Only unambiguous ones: 'dist', 'build', 'bin' can hold real source in some projects,
89
+ # so they are imported and REPORTED instead (see $PortSuspectDirs).
90
+ $script:PortJunkDirs = @(
91
+ 'node_modules', '.venv', 'venv', '__pycache__', '.pytest_cache', '.mypy_cache',
92
+ '.ruff_cache', '.tox', '.next', '.nuxt', '.parcel-cache', '.turbo', 'htmlcov', '.git'
93
+ )
94
+ $script:PortSuspectDirs = @('dist', 'build', 'bin', 'obj', 'out', 'coverage', 'logs', 'data')
95
+
96
+ # File-name patterns that usually hold secrets.
97
+ $script:PortSecretNamePatterns = @(
98
+ '^\.env$', '^\.env\..+', '\.pem$', '\.key$', '\.pfx$', '\.p12$', '^id_rsa', '^id_ed25519',
99
+ '^credentials(\..+)?$', '\.kdbx$', '^\.netrc$', '^\.git-credentials$', '^\.npmrc$', '^\.pypirc$'
100
+ )
101
+ # .env.example / .env.sample / .env.template are documentation, not secrets.
102
+ $script:PortSecretNameAllow = @('\.example$', '\.sample$', '\.template$', '\.dist$')
103
+
104
+ # Content patterns that usually are a real credential.
105
+ $script:PortSecretContentPatterns = @(
106
+ 'gh[pousr]_[A-Za-z0-9]{36,}',
107
+ 'github_pat_[A-Za-z0-9_]{40,}',
108
+ 'AKIA[0-9A-Z]{16}',
109
+ '-----BEGIN [A-Z ]*PRIVATE KEY-----',
110
+ 'xox[baprs]-[A-Za-z0-9-]{10,}',
111
+ '(?i)(password|passwd|pwd|secret|api[_-]?key|token)\s*[:=]\s*[''"][^''"\s]{8,}[''"]',
112
+ '(?i)(password|pwd)=[^;\s]{6,};'
113
+ )
114
+
115
+ function Test-PortSecretName([string]$FileName) {
116
+ foreach ($a in $script:PortSecretNameAllow) { if ($FileName -match $a) { return $false } }
117
+ foreach ($p in $script:PortSecretNamePatterns) { if ($FileName -match $p) { return $true } }
118
+ return $false
119
+ }
120
+
121
+ function Test-PortSecretContent([string]$Text) {
122
+ foreach ($p in $script:PortSecretContentPatterns) {
123
+ if ($Text -match $p) { return $p }
124
+ }
125
+ return $null
126
+ }
127
+
128
+ function ConvertTo-PortJsonFile($Object, [string]$Path) {
129
+ $Object | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding UTF8
130
+ }