@caddis/cli 0.4.19 → 0.4.20

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 +428 -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,92 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Copy Gitea/Forgejo Actions workflows to .github/workflows in a working clone, and lint them.
4
+
5
+ .DESCRIPTION
6
+ Run inside a normal (non-bare) clone of the NEW GitHub repository, on a feature branch.
7
+ - Reads each workflow from -SourceRef (default HEAD), NOT from the working tree, so you can
8
+ point it at the last commit before a workflow was frozen (e.g. -SourceRef <sha>).
9
+ - Rewrites the `gitea.` expression context to `github.` (same meaning on both).
10
+ - Optionally adds top-level `permissions: contents: write` (needed to push tags).
11
+ - Never touches scripts the workflows call. GitHub only needs the WORKFLOW under
12
+ .github/workflows; leaving .gitea/scripts where it is keeps the diff small.
13
+ - Never stages or commits. It prints the files it wrote so you can review and add them by name.
14
+
15
+ Exit 1 if any written workflow still contains an empty expression.
16
+ #>
17
+ [CmdletBinding()]
18
+ param(
19
+ [string]$RepoPath = '.',
20
+ [string]$From,
21
+ [string]$SourceRef = 'HEAD',
22
+ [switch]$AddPermissions,
23
+ [switch]$Force
24
+ )
25
+
26
+ $ErrorActionPreference = 'Stop'
27
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
28
+
29
+ $RepoPath = (Resolve-Path -LiteralPath $RepoPath).Path
30
+ if (-not $From) {
31
+ foreach ($c in '.gitea/workflows', '.forgejo/workflows') {
32
+ $ls = Invoke-PortGit -AllowFailure @('-C', $RepoPath, 'ls-tree', '--name-only', "${SourceRef}:$c")
33
+ if ($ls.ExitCode -eq 0 -and ($ls.Output -join '')) { $From = $c; break }
34
+ }
35
+ }
36
+ if (-not $From) { throw "No .gitea/workflows or .forgejo/workflows at $SourceRef. Pass -From." }
37
+
38
+ $names = @(Invoke-PortGit @('-C', $RepoPath, 'ls-tree', '--name-only', "${SourceRef}:$From") | Where-Object { $_ -match '\.ya?ml$' })
39
+ if ($names.Count -eq 0) { throw "No workflow files in $From at $SourceRef." }
40
+
41
+ $target = Join-Path $RepoPath '.github/workflows'
42
+ New-Item -ItemType Directory -Force -Path $target | Out-Null
43
+ $written = @()
44
+ $bad = 0
45
+
46
+ foreach ($n in $names) {
47
+ $dest = Join-Path $target $n
48
+ if ((Test-Path -LiteralPath $dest) -and -not $Force) { Write-PortWarn "$n already exists in .github/workflows. Skipped (use -Force)."; continue }
49
+ $text = (Invoke-PortGit @('-C', $RepoPath, 'show', "${SourceRef}:$From/$n")) -join "`n"
50
+
51
+ $ctx = 0
52
+ foreach ($m in [regex]::Matches($text, '\$\{\{[^}]*\}\}')) { $ctx += ([regex]::Matches($m.Value, '\bgitea\.')).Count }
53
+ $text = [regex]::Replace($text, '(\$\{\{[^}]*?)\bgitea\.', '$1github.')
54
+ # Repeat until no gitea. context remains inside any expression (several per expression).
55
+ while ([regex]::IsMatch($text, '\$\{\{[^}]*?\bgitea\.')) { $text = [regex]::Replace($text, '(\$\{\{[^}]*?)\bgitea\.', '$1github.') }
56
+
57
+ if ($AddPermissions -and $text -notmatch '(?m)^permissions:') {
58
+ # Instance Replace with a real count. The static [regex]::Replace(s, p, r, 1) binds the 1 to
59
+ # RegexOptions.IgnoreCase and replaces EVERY match.
60
+ if ($text -match '(?m)^jobs:') { $text = ([regex]'(?m)^jobs:').Replace($text, "permissions:`n contents: write`n`njobs:", 1) }
61
+ }
62
+
63
+ if ($text -match '(?m)^on:\s*(\[\s*)?workflow_dispatch' -and $text -notmatch '(?m)^\s+(push|pull_request)\s*:') {
64
+ Write-PortWarn "$n triggers only on workflow_dispatch at $SourceRef. That is what a FROZEN workflow looks like. Re-run with -SourceRef set to the last commit before it was frozen."
65
+ }
66
+
67
+ [IO.File]::WriteAllText($dest, ($text -replace "`r?`n", "`n"), (New-Object Text.UTF8Encoding($false)))
68
+ $written += ".github/workflows/$n"
69
+
70
+ $lines = $text -split "`n"
71
+ $empty = @()
72
+ for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '\$\{\{\s*\}\}') { $empty += ($i + 1) } }
73
+ if ($empty.Count -gt 0) {
74
+ $bad++
75
+ Write-PortFail "$n line(s) $($empty -join ', '): empty `${{ }}. GitHub rejects the whole file. Reword the text (a comment inside run: is still scanned)."
76
+ }
77
+ Write-PortOk "$n -> .github/workflows/$n (gitea. context rewritten: $ctx)"
78
+ $creds = @()
79
+ for ($i = 0; $i -lt $lines.Count; $i++) {
80
+ if ($lines[$i] -match 'secrets\.|github\.token|(?i)token\s*:|_TOKEN\b|_USER\b|PASSWORD') { $creds += (" {0}: {1}" -f ($i + 1), $lines[$i].Trim()) }
81
+ }
82
+ if ($creds.Count -gt 0) {
83
+ Write-Host " Credential lines - decide EACH one: ORIGIN-facing (this repo: fetch, checkout, tag push) -> `${{ github.token }} with user x-access-token; DEPENDENCY host -> a secret valid for THAT host."
84
+ $creds | ForEach-Object { Write-Host $_ }
85
+ }
86
+ }
87
+
88
+ Write-Host ''
89
+ Write-Host 'Written (review, then git add each file BY NAME):'
90
+ $written | ForEach-Object { Write-Host " $_" }
91
+ if ($bad -gt 0) { exit 1 }
92
+ exit 0
@@ -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')"