Eigenverft.Manifested.Drydock.Git.ps1

function Get-GitTopLevelDirectory {
    <#
    .SYNOPSIS
        Retrieves the top-level directory of the current Git repository.
 
    .DESCRIPTION
        This function calls Git using 'git rev-parse --show-toplevel' to determine
        the root directory of the current Git repository. If Git is not available
        or the current directory is not within a Git repository, the function returns
        an error. The function converts any forward slashes to the system's directory
        separator (works correctly on both Windows and Linux).
 
    .PARAMETER None
        This function does not require any parameters.
 
    .EXAMPLE
        PS C:\Projects\MyRepo> Get-GitTopLevelDirectory
        C:\Projects\MyRepo
 
    .NOTES
        Ensure Git is installed and available in your system's PATH.
    #>

    [CmdletBinding()]
    [alias("ggtd")]
    param()

    try {
        # Attempt to retrieve the top-level directory of the Git repository.
        $topLevel = git rev-parse --show-toplevel 2>$null

        if (-not $topLevel) {
            Write-Error "Not a Git repository or Git is not available in the PATH."
            return $null
        }

        # Trim the result and replace forward slashes with the current directory separator.
        $topLevel = $topLevel.Trim().Replace('/', [System.IO.Path]::DirectorySeparatorChar)
        return $topLevel
    }
    catch {
        Write-Error "Error retrieving Git top-level directory: $_"
    }
}

function Get-GitCurrentBranch {
    <#
    .SYNOPSIS
    Retrieves the current Git branch name.
 
    .DESCRIPTION
    This function calls Git to determine the current branch. It first uses
    'git rev-parse --abbrev-ref HEAD' to get the branch name. If the output is
    "HEAD" (indicating a detached HEAD state), it then attempts to find a branch
    that contains the current commit using 'git branch --contains HEAD'. If no
    branch is found, it falls back to returning the commit hash.
 
    .EXAMPLE
    PS C:\> Get-GitCurrentBranch
 
    Returns:
    master
 
    .NOTES
    - Ensure Git is available in your system's PATH.
    - In cases of a detached HEAD with multiple containing branches, the first
      branch found is returned.
    #>

    [CmdletBinding()]
    [alias("ggcb")]
    param()
    
    try {
        # Get the abbreviated branch name
        $branch = git rev-parse --abbrev-ref HEAD 2>$null

        # If HEAD is returned, we're in a detached state.
        if ($branch -eq 'HEAD') {
            # Try to get branch names that contain the current commit.
            $branches = git branch --contains HEAD 2>$null | ForEach-Object {
                # Remove any asterisks or leading/trailing whitespace.
                $_.Replace('*','').Trim()
            } | Where-Object { $_ -ne '' }

            if ($branches.Count -gt 0) {
                # Return the first branch found
                return $branches[0]
            }
            else {
                # As a fallback, return the commit hash.
                return git rev-parse HEAD 2>$null
            }
        }
        else {
            return $branch.Trim()
        }
    }
    catch {
        Write-Error "Error retrieving Git branch: $_"
    }
}

function Get-GitCurrentBranchRoot {
    <#
    .SYNOPSIS
    Retrieves the root portion of the current Git branch name.
 
    .DESCRIPTION
    This function retrieves the current Git branch name by invoking Git commands directly.
    It first attempts to get the branch name using 'git rev-parse --abbrev-ref HEAD'. If the result is
    "HEAD" (indicating a detached HEAD state), it then looks for a branch that contains the current commit
    via 'git branch --contains HEAD'. If no branch is found, it falls back to using the commit hash.
    The function then splits the branch name on both forward (/) and backslashes (\) and returns the first
    segment as the branch root.
 
    .EXAMPLE
    PS C:\> Get-GitCurrentBranchRoot
 
    Returns:
    feature
 
    .NOTES
    - Ensure Git is available in your system's PATH.
    - For detached HEAD states with multiple containing branches, the first branch found is used.
    #>

    [CmdletBinding()]
    [alias("ggcbr")]
    param()

    try {
        # Attempt to get the abbreviated branch name.
        $branch = git rev-parse --abbrev-ref HEAD 2>$null

        # Check for detached HEAD state.
        if ($branch -eq 'HEAD') {
            # Retrieve branches containing the current commit.
            $branches = git branch --contains HEAD 2>$null | ForEach-Object {
                $_.Replace('*','').Trim()
            } | Where-Object { $_ -ne '' }

            if ($branches.Count -gt 0) {
                $branch = $branches[0]
            }
            else {
                # Fallback to commit hash if no branch is found.
                $branch = git rev-parse HEAD 2>$null
            }
        }
        
        $branch = $branch.Trim()
        if ([string]::IsNullOrWhiteSpace($branch)) {
            Write-Error "Unable to determine the current Git branch."
            return
        }
        
        # Split the branch name on both '/' and '\' and return the first segment.
        $root = $branch -split '[\\/]' | Select-Object -First 1
        return $root
    }
    catch {
        Write-Error "Error retrieving Git branch root: $_"
    }
}

function Get-GitRepositoryName {
    <#
    .SYNOPSIS
        Gibt den Namen des Git-Repositories anhand der Remote-URL zurück.
 
    .DESCRIPTION
        Diese Funktion ruft über 'git config --get remote.origin.url' die Remote-URL des Repositories ab.
        Anschließend wird der Repository-Name aus der URL extrahiert, indem der letzte Teil der URL (nach dem letzten "/" oder ":")
        entnommen und eine eventuell vorhandene ".git"-Endung entfernt wird.
        Sollte keine Remote-URL vorhanden sein, wird ein Fehler ausgegeben.
 
    .PARAMETER None
        Diese Funktion benötigt keine Parameter.
 
    .EXAMPLE
        PS C:\Projects\MyRepo> Get-GitRepositoryName
        MyRepo
 
    .NOTES
        Stelle sicher, dass Git installiert ist und in deinem Systempfad verfügbar ist.
    #>

    [CmdletBinding()]
    [alias("ggrn")]
    param()

    try {
        # Remote-URL des Repositories abrufen
        $remoteUrl = git config --get remote.origin.url 2>$null

        if (-not $remoteUrl) {
            Write-Error "No remote URL found. Ensure the repository has a remote URL.."
            return $null
        }

        $remoteUrl = $remoteUrl.Trim()

        # Entferne eine eventuell vorhandene ".git"-Endung
        if ($remoteUrl -match "\.git$") {
            $remoteUrl = $remoteUrl.Substring(0, $remoteUrl.Length - 4)
        }

        # Unterscheidung zwischen URL-Formaten (HTTPS/SSH)
        if ($remoteUrl.Contains('/')) {
            $parts = $remoteUrl.Split('/')
        }
        else {
            # SSH-Format: z.B. git@github.com:User/Repo
            $parts = $remoteUrl.Split(':')
        }

        # Letztes Element als Repository-Name extrahieren
        $repoName = $parts[-1]
        return $repoName
    }
    catch {
        Write-Error "Fehler beim Abrufen des Repository-Namens: $_"
    }
}

function Get-GitRemoteUrl {
    <#
    .SYNOPSIS
        Gibt den Namen des Git-Repositories anhand der Remote-URL zurück.
 
    .DESCRIPTION
        Diese Funktion ruft über 'git config --get remote.origin.url' die Remote-URL des Repositories ab.
        Anschließend wird der Repository-Name aus der URL extrahiert, indem der letzte Teil der URL (nach dem letzten "/" oder ":")
        entnommen und eine eventuell vorhandene ".git"-Endung entfernt wird.
        Sollte keine Remote-URL vorhanden sein, wird ein Fehler ausgegeben.
 
    .PARAMETER None
        Diese Funktion benötigt keine Parameter.
 
    .EXAMPLE
        PS C:\Projects\MyRepo> Get-GitRepositoryName
        MyRepo
 
    .NOTES
        Stelle sicher, dass Git installiert ist und in deinem Systempfad verfügbar ist.
    #>

    [CmdletBinding()]
    [alias("gru")]
    param()

    try {
        # Remote-URL des Repositories abrufen
        $remoteUrl = git config --get remote.origin.url 2>$null

        if (-not $remoteUrl) {
            Write-Error "No remote URL found. Ensure the repository has a remote URL.."
            return $null
        }

        $remoteUrl = $remoteUrl.Trim()

        return $remoteUrl
    }
    catch {
        Write-Error "Fehler beim Abrufen des Repository-Namens: $_"
    }
}

function Invoke-GitAddCommitPush {
<#
.SYNOPSIS
Stages a module folder, optionally configures safe.directory, commits with a transient identity, and pushes to origin. Optionally tags HEAD.
 
.DESCRIPTION
Wraps these Git calls (kept close to your original flags):
  For each item in $Folders:
    git -C "$TopLevelDirectory" add -v -A -- "<item>"
  (optional) git -C "$TopLevelDirectory" config --global --add safe.directory "$TopLevelDirectory"
  git -C "$TopLevelDirectory" -c user.name="..." -c user.email="..." commit -m "..."
  git -C "$TopLevelDirectory" push origin "$CurrentBranch"
 
If -Tags are provided, creates annotated tags on HEAD and pushes them:
  git -C "$TopLevelDirectory" tag -a <tag> -m "<msg>" <commit>
  git -C "$TopLevelDirectory" push origin <tag>
 
Writes status via Write-Host and emits no return value. Optionally exits the host on errors.
 
.PARAMETER TopLevelDirectory
Git repository root to pass via -C. If omitted, the current repo root is detected.
 
.PARAMETER Folders
Pathspec/folder values to stage (ideally relative to repo root). Each value is passed after the pathspec separator: -- "<item>".
 
.PARAMETER CurrentBranch
Target branch for push. If omitted, the current branch is detected.
 
.PARAMETER CommitMessage
Commit message. Default: 'Updated from Workflow [skip ci]'.
 
.PARAMETER UserName
Transient user.name for the commit via 'git -c'. Default: 'github-actions[bot]'.
 
.PARAMETER UserEmail
Transient user.email for the commit via 'git -c'. Default matches GitHub Actions bot.
 
.PARAMETER SafeDirectory
When set, adds the repo root to global safe.directory before committing/pushing.
 
.PARAMETER Tags
Optional array of tag names to create and push, e.g. @('v1.2.3','latest').
 
.PARAMETER TagMessage
Optional annotation message to use for each tag; defaults to "Tag <tag>".
 
.PARAMETER ForceTagUpdate
If set, existing tags with the same name are moved (force-updated) to the new commit.
 
.PARAMETER ExitOnError
On any failure, exits the PowerShell host with a non-zero code (atomic behavior).
 
.EXAMPLE
Invoke-GitAddCommitPush -TopLevelDirectory (Get-GitTopLevelDirectory) -Folders 'src/My.Module' -CurrentBranch 'main'
 
.EXAMPLE
Invoke-GitAddCommitPush -Folders 'src/My.Module','src/Another.Module' -Tags @('v1.4.0','latest') -TagMessage 'Release 1.4.0'
 
.EXAMPLE
Invoke-GitAddCommitPush -Folders 'src/My.Module' -SafeDirectory
Adds the repo to safe.directory before proceeding.
 
.NOTES
- Uses Write-Host per requirement; no objects returned.
- Reviewer note: Keeps original flags and structure; pushes are always performed.
#>

    [CmdletBinding()]
    [Alias('igacp')]
    param(
        [Parameter(Mandatory=$false)]
        [string]$TopLevelDirectory,

        [Parameter(Mandatory=$true)]
        [string[]]$Folders,

        [Parameter(Mandatory=$false)]
        [string]$CurrentBranch,

        [Parameter(Mandatory=$false)]
        [string]$CommitMessage = 'Updated from Workflow [skip ci]',

        [Parameter(Mandatory=$false)]
        [string]$UserName  = 'github-actions[bot]',

        [Parameter(Mandatory=$false)]
        [string]$UserEmail = '41898282+github-actions[bot]@users.noreply.github.com',

        [Parameter(Mandatory=$false)]
        [switch]$SafeDirectory,

        [Parameter(Mandatory=$false)]
        [string[]]$Tags = @(),

        [Parameter(Mandatory=$false)]
        [string]$TagMessage,

        [Parameter(Mandatory=$false)]
        [switch]$ForceTagUpdate,

        [Parameter(Mandatory=$false)]
        [switch]$ExitOnError
    )

    # --- Preflight: Git availability (external reviewer note: keep user-facing feedback clear) ---
    if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
        Write-Host "[Invoke-GitAddCommitPush] Git not found in PATH."
        if ($ExitOnError) { exit 1 }; return
    }

    # --- Resolve repo root: provided or detect via rev-parse --------------------------------------
    if (-not $TopLevelDirectory) {
        try { $TopLevelDirectory = (git rev-parse --show-toplevel 2>$null).Trim() } catch { $TopLevelDirectory = $null }
    }
    if ([string]::IsNullOrWhiteSpace($TopLevelDirectory)) {
        Write-Host "[Invoke-GitAddCommitPush] Unable to determine repo root (TopLevelDirectory)."
        if ($ExitOnError) { exit 1 }; return
    }
    try {
        # PS5-safe: use Get-Item.FullName instead of Resolve-Path.ProviderPath
        $repoPath = (Get-Item -LiteralPath $TopLevelDirectory -ErrorAction Stop).FullName
    }
    catch {
        Write-Host "[Invoke-GitAddCommitPush] Repo root not found: '$TopLevelDirectory'."
        if ($ExitOnError) { exit 1 }; return
    }

    # --- git add -v -A -- "<each folder>" ---------------------------------------------------------
    foreach ($folder in $Folders) {
        $f = ([string]$folder).Trim()
        if ([string]::IsNullOrWhiteSpace($f)) { continue }
        Write-Host "[Invoke-GitAddCommitPush] git add -v -A -- '$f'"
        & git -C $repoPath add -v -A -- $f 2>&1 | ForEach-Object { Write-Host $_ }
        if ($LASTEXITCODE -ne 0) {
            Write-Host "[Invoke-GitAddCommitPush] git add failed for '$f' (code $LASTEXITCODE)."
            if ($ExitOnError) { exit $LASTEXITCODE }; return
        }
    }

    # --- Optional: safe.directory ----------------------------------------------------------------
    if ($SafeDirectory) {
        Write-Host "[Invoke-GitAddCommitPush] git config --global --add safe.directory '$repoPath'"
        & git -C $repoPath config --global --add safe.directory $repoPath 2>&1 | ForEach-Object { Write-Host $_ }
        if ($LASTEXITCODE -ne 0) {
            Write-Host "[Invoke-GitAddCommitPush] git config safe.directory failed (code $LASTEXITCODE)."
            if ($ExitOnError) { exit $LASTEXITCODE }; return
        }
    }

    # --- Commit with transient identity -----------------------------------------------------------
    Write-Host "[Invoke-GitAddCommitPush] git commit -m '$CommitMessage'"
    & git -C $repoPath -c "user.name=$UserName" -c "user.email=$UserEmail" commit -m $CommitMessage 2>&1 | ForEach-Object { Write-Host $_ }
    $commitCode = $LASTEXITCODE
    if ($commitCode -ne 0) {
        # Common case: nothing to commit -> nonzero exit with message. Treat as soft success unless atomic.
        Write-Host "[Invoke-GitAddCommitPush] git commit returned $commitCode (possibly nothing to commit)."
        if ($ExitOnError) { exit $commitCode }
    }

    # --- Determine branch if not provided ---------------------------------------------------------
    if (-not $CurrentBranch) {
        $CurrentBranch = git -C $repoPath rev-parse --abbrev-ref HEAD 2>$null
        if ($CurrentBranch) { $CurrentBranch = $CurrentBranch.Trim() }
    }
    if ([string]::IsNullOrWhiteSpace($CurrentBranch)) {
        Write-Host "[Invoke-GitAddCommitPush] Unable to determine branch."
        if ($ExitOnError) { exit 1 }; return
    }

    # --- Always push branch -----------------------------------------------------------------------
    Write-Host "[Invoke-GitAddCommitPush] git push origin '$CurrentBranch'"
    & git -C $repoPath push origin $CurrentBranch 2>&1 | ForEach-Object { Write-Host $_ }
    if ($LASTEXITCODE -ne 0) {
        Write-Host "[Invoke-GitAddCommitPush] git push failed (code $LASTEXITCODE)."
        if ($ExitOnError) { exit $LASTEXITCODE }; return
    }

    # --- Tagging (optional): create annotated tags on HEAD and push -------------------------------
    if ($Tags -and $Tags.Count -gt 0) {
        $head = git -C $repoPath rev-parse HEAD 2>$null
        if ($head) { $head = $head.Trim() }

        if ([string]::IsNullOrWhiteSpace($head)) {
            Write-Host "[Invoke-GitAddCommitPush] Unable to resolve HEAD for tagging."
            if ($ExitOnError) { exit 1 }; return
        }

        foreach ($rawTag in $Tags) {
            $tag = ([string]$rawTag).Trim()   # [string] casts $null -> ''
            if ([string]::IsNullOrWhiteSpace($tag)) { continue }

            & git -C $repoPath show-ref --tags --verify --quiet ("refs/tags/$tag")
            $exists = ($LASTEXITCODE -eq 0)

            if ($exists -and -not $ForceTagUpdate) {
                Write-Host "[Invoke-GitAddCommitPush] Tag '$tag' already exists; skipping (use -ForceTagUpdate to move it)."
            } else {
                $msg = if ($TagMessage) { $TagMessage } else { "Tag $tag" }
                $tagArgs = @('-C', $repoPath, 'tag', '-a', $tag, $head, '-m', $msg)
                if ($exists -and $ForceTagUpdate) { $tagArgs = @('-C', $repoPath, 'tag', '-f', '-a', $tag, $head, '-m', $msg) }

                Write-Host ("[Invoke-GitAddCommitPush] {0} annotated tag '{1}' on {2}." -f ($(if ($exists) { 'Updating' } else { 'Creating' }), $tag, $head))
                & git @tagArgs 2>&1 | ForEach-Object { Write-Host $_ }
                if ($LASTEXITCODE -ne 0) {
                    Write-Host "[Invoke-GitAddCommitPush] git tag failed for '$tag' (code $LASTEXITCODE)."
                    if ($ExitOnError) { exit $LASTEXITCODE }; continue
                }
            }

            # Always push tag (force if moved)
            $pushArgs = @('-C', $repoPath, 'push', 'origin', $tag)
            if ($exists -and $ForceTagUpdate) { $pushArgs = @('-C', $repoPath, 'push', '--force', 'origin', $tag) }

            Write-Host "[Invoke-GitAddCommitPush] Pushing tag '$tag' to 'origin'."
            & git @pushArgs 2>&1 | ForEach-Object { Write-Host $_ }
            if ($LASTEXITCODE -ne 0) {
                Write-Host "[Invoke-GitAddCommitPush] git push for tag '$tag' failed (code $LASTEXITCODE)."
                if ($ExitOnError) { exit $LASTEXITCODE }
            }
        }
    } else {
        Write-Host "[Invoke-GitAddCommitPush] No tags specified."
    }

    Write-Host "[Invoke-GitAddCommitPush] Completed."
}