@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/skills/_registry.md +4 -2
- package/bundles/antigravity-plugin/skills/data-lineage/SKILL.md +32 -0
- package/bundles/antigravity-plugin/skills/hld/SKILL.md +20 -8
- package/bundles/antigravity-plugin/skills/lld/SKILL.md +27 -12
- package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +231 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/ci-and-credentials.md +66 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/config.example.json +18 -0
- package/bundles/antigravity-plugin/skills/port-to-github/references/old-host.md +61 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Get-PortSource.ps1 +459 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/PortCommon.ps1 +130 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
- package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +3 -2
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +158 -0
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +1430 -0
- package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +155 -35
- package/bundles/antigravity-plugin-extras/skills/hld/references/gold-standard-hld.md +428 -0
- package/bundles/antigravity-plugin-extras/skills/hld/scripts/convert_doc_to_confluence.py +900 -184
- package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +145 -41
- package/bundles/antigravity-plugin-extras/skills/lld/scripts/convert_doc_to_confluence.py +900 -184
- package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +231 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/ci-and-credentials.md +66 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/config.example.json +18 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/references/old-host.md +61 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Get-PortSource.ps1 +459 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/PortCommon.ps1 +130 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
- package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
- package/bundles/manifest.json +3 -3
- 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')"
|