# Shared helpers for git-init skill scripts. # Dot-source only. Never prints token values. $ErrorActionPreference = 'Stop' if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) { $PSNativeCommandUseErrorActionPreference = $false } # Windows PowerShell 5.1 promotes native stderr to a terminating error when # $ErrorActionPreference='Stop', so 2>$null alone is not enough. This helper # runs a native executable with stderr discarded and $LASTEXITCODE preserved. function Invoke-GitSkillsNative { param( [Parameter(Mandatory = $true)] [string]$FilePath, [string[]]$ArgumentList = @() ) $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $output = @(& $FilePath @ArgumentList 2>$null | ForEach-Object { "$_" }) return ,$output } finally { $ErrorActionPreference = $prevEap } } function Invoke-GitSkillsNativeWithInput { param( [Parameter(Mandatory = $true)] [string]$FilePath, [string[]]$ArgumentList = @(), [Parameter(Mandatory = $true)] [string]$InputText ) $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $FilePath $psi.UseShellExecute = $false $psi.RedirectStandardInput = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true $psi.Arguments = ($ArgumentList | ForEach-Object { if ($_ -match '\s|"') { '"{0}"' -f ($_ -replace '"', '\"') } else { $_ } }) -join ' ' $proc = New-Object System.Diagnostics.Process $proc.StartInfo = $psi [void]$proc.Start() foreach ($line in ($InputText -split "`r?`n", 0)) { $proc.StandardInput.WriteLine($line) } $proc.StandardInput.Close() $stdout = $proc.StandardOutput.ReadToEnd() $stderr = $proc.StandardError.ReadToEnd() $proc.WaitForExit() $script:LastNativeExitCode = $proc.ExitCode if ($stderr) { $null = $stderr } if ([string]::IsNullOrWhiteSpace($stdout)) { return @() } return @($stdout -split "`r?`n") } Add-Type -AssemblyName System.Security | Out-Null $script:GitSkillsHome = Join-Path $env:USERPROFILE '.git-skills' $script:ProfilePath = Join-Path $script:GitSkillsHome 'profile.json' $script:TokenPath = Join-Path $script:GitSkillsHome 'token.dpapi' $script:DefaultsPath = Join-Path $PSScriptRoot 'defaults.json' function Get-GitSkillsDefaults { if (-not (Test-Path -LiteralPath $script:DefaultsPath)) { return [pscustomobject]@{ userName = 'Traveler' userEmail = 'user@example.com' } } return Get-Content -LiteralPath $script:DefaultsPath -Raw -Encoding UTF8 | ConvertFrom-Json } function Ensure-GitSkillsHome { if (-not (Test-Path -LiteralPath $script:GitSkillsHome)) { New-Item -ItemType Directory -Path $script:GitSkillsHome -Force | Out-Null } } function Get-GitSkillsProfile { if (-not (Test-Path -LiteralPath $script:ProfilePath)) { return $null } return Get-Content -LiteralPath $script:ProfilePath -Raw -Encoding UTF8 | ConvertFrom-Json } function Save-GitSkillsProfile { param( [Parameter(Mandatory = $true)] $Profile ) Ensure-GitSkillsHome $json = $Profile | ConvertTo-Json -Depth 5 $utf8Bom = New-Object System.Text.UTF8Encoding $true [System.IO.File]::WriteAllText($script:ProfilePath, $json, $utf8Bom) } function Protect-SecretString { param( [Parameter(Mandatory = $true)] [string]$PlainText ) $bytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText) return [System.Security.Cryptography.ProtectedData]::Protect( $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser ) } function Unprotect-SecretBytes { param( [Parameter(Mandatory = $true)] [byte[]]$Data ) $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect( $Data, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser ) return [System.Text.Encoding]::UTF8.GetString($bytes) } function ConvertFrom-SecureStringPlain { param( [Parameter(Mandatory = $true)] [System.Security.SecureString]$Secure ) $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) try { return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } function Parse-GitRemoteUrl { param( [Parameter(Mandatory = $true)] [string]$RemoteUrl ) $url = $RemoteUrl.Trim() if ([string]::IsNullOrWhiteSpace($url)) { throw 'Remote URL is empty.' } function New-ParsedRemote { param( [string]$Protocol, [string]$HostName, [object]$Port, [string]$Path ) $portNum = $null if ($null -ne $Port -and "$Port" -ne '') { $portNum = [int]$Port } $credentialHost = $HostName if ($Protocol -eq 'https' -or $Protocol -eq 'http') { $defaultPort = if ($Protocol -eq 'https') { 443 } else { 80 } if ($null -ne $portNum -and $portNum -ne $defaultPort) { $credentialHost = '{0}:{1}' -f $HostName, $portNum } } return [pscustomobject]@{ Protocol = $Protocol Host = $HostName Port = $portNum Path = $Path CredentialHost = $credentialHost } } # Gitea / Git SCP: git@host:owner/repo.git if ($url -match '^git@([^:]+):(.+)$') { return New-ParsedRemote -Protocol 'ssh' -HostName $Matches[1] -Port $null -Path $Matches[2] } # ssh://git@host:2222/owner/repo.git or ssh://git@host/owner/repo.git if ($url -match '^ssh://(?:[^@/]+@)?(\[[^\]]+\]|[^:/]+)(?::(\d+))?/(.+)$') { $port = if ($Matches[2]) { [int]$Matches[2] } else { $null } return New-ParsedRemote -Protocol 'ssh' -HostName $Matches[1].Trim('[]') -Port $port -Path $Matches[3] } try { $uri = [Uri]$url } catch { throw "Unsupported remote URL: $url" } if (-not $uri.IsAbsoluteUri -or [string]::IsNullOrWhiteSpace($uri.Host)) { throw "Unsupported remote URL: $url" } $port = $null if (-not $uri.IsDefaultPort) { $port = $uri.Port } return New-ParsedRemote -Protocol $uri.Scheme -HostName $uri.Host -Port $port -Path $uri.AbsolutePath.TrimStart('/') } function Write-StatusLine { param( [Parameter(Mandatory = $true)] [string]$Key, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Value ) Write-Output ("{0}={1}" -f $Key, $Value) } function Get-CoAuthorTrailerLines { param( $GitProfile = $null ) if ($null -eq $GitProfile) { $GitProfile = Get-GitSkillsProfile } if ($null -eq $GitProfile) { return @() } $lines = New-Object System.Collections.Generic.List[string] $primaryName = ([string]$GitProfile.userName).Trim() $primaryEmail = ([string]$GitProfile.userEmail).Trim() if (-not [string]::IsNullOrWhiteSpace($primaryName) -and -not [string]::IsNullOrWhiteSpace($primaryEmail)) { [void]$lines.Add(('Co-authored-by: {0} <{1}>' -f $primaryName, $primaryEmail)) } $extras = @() if ($GitProfile.PSObject.Properties.Name -contains 'coAuthors' -and $null -ne $GitProfile.coAuthors) { $extras = @($GitProfile.coAuthors) } foreach ($item in $extras) { $name = '' $email = '' if ($null -eq $item) { continue } if ($item -is [string]) { if ($item -match '^\s*(.+?)\s*<([^>]+)>\s*$') { $name = $Matches[1].Trim() $email = $Matches[2].Trim() } } else { $name = ([string]$item.name).Trim() $email = ([string]$item.email).Trim() } if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($email)) { continue } $line = 'Co-authored-by: {0} <{1}>' -f $name, $email if (-not $lines.Contains($line)) { [void]$lines.Add($line) } } return @($lines) } function Format-CommitMessageWithTrailers { param( [Parameter(Mandatory = $true)] [string]$Subject, $GitProfile = $null ) $subject = $Subject.Trim() if ([string]::IsNullOrWhiteSpace($subject)) { throw 'Commit subject is empty.' } $trailers = Get-CoAuthorTrailerLines -GitProfile $GitProfile if ($trailers.Count -eq 0) { return $subject } return ($subject + "`n`n" + ($trailers -join "`n")) }