Open-GitRepo.psm1

#Region './Private/Get-GitBranchWithCommitHash.ps1' -1

function Get-GitBranchWithCommitHash {
    param([string]$TargetPath)

    $startingLocation = Get-Location

    if (Test-Path ($TargetPath || "") -PathType Container) {
        Set-Location -Path $TargetPath
    }
    elseif (![string]::IsNullOrEmpty($TargetPath)) {
        Write-Error "The specified path '$TargetPath' is not a valid directory."
        return $null
    }

    try {
        $targetBranch = (git for-each-ref --format='%(refname:short)|%(objectname)' refs/heads/ 2>$null || @()) | ForEach-Object {
            $parts = $_ -split '\|'
            if ($parts.Count -eq 2) {
                [PSCustomObject]@{ Branch = $parts[0]; CommitHash = $parts[1] }
            }

            return [PSCustomObject]@{
                Branch     = $null
                CommitHash = $null
            }
        } | Where-Object { $_.Branch -eq $Branch } | Select-Object -First 1

        if ($null -eq $targetBranch -or -not $targetBranch.CommitHash) {
            Write-Error "Branch '$Branch' not found for this repository."
            return $null
        }

        return $targetBranch
    }
    finally {
        if ($startingLocation -ne (Get-Location)) {
            Set-Location -Path $startingLocation
        }
    }  
}
#EndRegion './Private/Get-GitBranchWithCommitHash.ps1' 40
#Region './Private/Get-GitCurrentBranch.ps1' -1

function Get-GitCurrentBranch {
    param([string]$TargetPath, [string]$DefaultBranch)

    if (-not (Test-Path "$TargetPath/.git")) {
        Write-Error "The specified path '$TargetPath' is not a valid git repository."
        return $DefaultBranch
    }

    if ([string]::IsNullOrEmpty($TargetPath)) {
        return git branch --show-current 2>$null
    }

    $currentBranch = git -C $TargetPath rev-parse --abbrev-ref HEAD 2>$null
    if (-not $currentBranch) {
        Write-Error "Could not retrieve current branch. Are you in a git repository?"
        return $DefaultBranch
    }

    return $currentBranch
}
#EndRegion './Private/Get-GitCurrentBranch.ps1' 21
#Region './Private/Get-GitRemoteUrl.ps1' -1

function Get-GitRemoteUrl {
    param([string]$TargetPath)

    if ([string]::IsNullOrEmpty($TargetPath)) {
        return git remote get-url origin 2>$null
    }

    return git -C $TargetPath remote get-url origin 2>$null
}
#EndRegion './Private/Get-GitRemoteUrl.ps1' 10
#Region './Private/Get-PrimaryGitBranch.ps1' -1

function Get-PrimaryGitBranch {
    return git config --list 
    | Where-Object { $_ -like 'branch.*.merge=*' } 
    | ForEach-Object { $_ -replace '^(.+)=(refs/heads/)?', '' } 
    | Select-Object -First 1
}
#EndRegion './Private/Get-PrimaryGitBranch.ps1' 7
#Region './Private/Get-RepoWebUrl.ps1' -1

function Get-RepoWebUrl {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$RemoteUrl, 
        [Parameter(Mandatory)]
        [string]$Branch
    )
            
    $url = $RemoteUrl -replace 'git@', 'https://' -replace 'http://', 'https://' -replace 'com:', 'com/' -replace 'org:', 'org/'
    if (-not $url.StartsWith('https://')) {
        $url = "https://$url"
    }
            
    try {
        $uri = [uri]::new($url)
    }
    catch {
        # check for ssh profile alias(es)
        if ($RemoteUrl -match '^git@([^:]+):') {
            $sshAlias = $Matches[1]
            $hostName = Get-SshAliasHostnameUrl -SshAlias $sshAlias
            if ($hostName) {
                $uri = [uri]::new("https://$hostName/$($RemoteUrl -replace '^git@[^:]+:', '')")
            }
            else {
                Write-Error "Could not resolve SSH alias '$sshAlias' to a hostname."
                return $null
            }
        }
        else {
            Write-Error "Invalid remote URL format: $RemoteUrl"
            return $null
        }

    }

    if (-not $uri.Host) {
        Write-Error "Invalid remote URL format: $RemoteUrl"
        return $null
    }

    switch ($uri.Host) {
        'github.com' {
            $path = $uri.AbsolutePath.TrimEnd('.git')
            return "https://github.com$path/tree/$Branch"
        }
        'bitbucket.org' {
            # Bitbucket URL examples:
            #
            # Default branch:
            # https://bitbucket.org/bbworkspace/customrepo/src/main
            #
            # Secondary branch:
            # https://bitbucket.org/mybbworkspace/mybbrepo/src/acc31fe8745678bc987b123d87d7ac72fec220e52/?at=hotfix%2Fmy-test-branch

            $primaryBranch = Get-PrimaryGitBranch

            if ($primaryBranch -eq $Branch) {
                $path = $uri.AbsolutePath.TrimEnd('.git')
                return "https://bitbucket.org$path/src/$Branch"
            }

            $branchWithHash = Get-GitBranchWithCommitHash 

            $path = $uri.AbsolutePath.TrimEnd('.git')
        
            return "https://bitbucket.org{0}/src/{1}/?at={2}" -f $path, $branchWithHash.CommitHash, $branchWithHash.Branch
        }
        default {
            Write-Error "Unsupported Git provider: $($uri.Host)"
            return $null
        }
    }
}
#EndRegion './Private/Get-RepoWebUrl.ps1' 76
#Region './Private/Get-SshAliasHostnameUrl.ps1' -1

function Get-SshAliasHostnameUrl {
    param([string]$SshAlias)
    $location = Join-Path $env:HOME ".ssh" "config"
    
    if (Test-Path $location) {
        $sshConfig = Get-Content $location

        $inHostBlock = $false
        foreach ($line in $sshConfig) {
            if ($line -match '^\s*Host\s+(\S+)') {
                $inHostBlock = ($Matches[1] -eq $SshAlias)
            }
            elseif ($inHostBlock -and $line -match '^\s*HostName\s+(\S+)') {
                return $Matches[1]
            }
            elseif ($line -match '^\s*$') {
                $inHostBlock = $false
            }
        }
    }
    return $null
}
#EndRegion './Private/Get-SshAliasHostnameUrl.ps1' 23
#Region './Private/Resolve-NormalizedPath.ps1' -1

function Resolve-NormalizedPath {
    param(
        [Parameter(Mandatory)]
        [string]$Path,
        [string]$BasePath = (Get-Location).Path
    )

    if ($Path -like '~*') {
        $Path = $Path -replace '^~', $env:HOME
    }

    if (-not [System.IO.Path]::IsPathRooted($Path)) {
        $Path = [System.IO.Path]::Combine($BasePath, $Path)
    }

    try {
        $resolved = [System.IO.Path]::GetFullPath($Path)
    }
    catch {
        $resolved = $Path
    }
    return $resolved
}
#EndRegion './Private/Resolve-NormalizedPath.ps1' 24
#Region './Public/Open-GitRepo.ps1' -1

function Open-GitRepo {
    <#
    .SYNOPSIS
        Opens a Git repository's web interface in your default browser across any platform.

    .DESCRIPTION
        The Open-GitRepo cmdlet opens the web interface for a GitHub or Bitbucket repository in your default browser.
        It works cross-platform (Windows, macOS, Linux) and supports:
        - The current directory (default behavior)
        - A provided local directory path (looks up the git remote in that directory)
        - A provided git remote URL (parses and opens directly)
        
        The cmdlet can accept input via parameters or from the pipeline, making it easy to use in scripts or with multiple repositories.

    .PARAMETER Path
        The path to a local directory containing a git repository. If specified, the cmdlet will use the git remote and branch from this directory.

    .PARAMETER Url
        A git remote URL (HTTPS or SSH) to open directly. If specified, the cmdlet will parse the URL and open the corresponding web interface.

    .PARAMETER Branch
        The branch name to use when constructing the web URL. If not specified, the cmdlet will attempt to determine the current branch from the repository.

    .INPUTS
        [String] You can pipe a local directory path or git remote URL to this cmdlet.

    .OUTPUTS
        None. This cmdlet does not generate any output.

    .EXAMPLE
        PS C:\MyRepo> Open-GitRepo
        Opens the current repository's web page in your default browser (Windows).

    .EXAMPLE
        PS /home/user/MyRepo> Open-GitRepo -Path /home/user/otherrepo
        Opens the web page for the repository in /home/user/otherrepo (Linux).

    .EXAMPLE
        PS> 'https://github.com/user/repo.git' | Open-GitRepo
        Opens the GitHub repository web page for the provided URL.

    .EXAMPLE
        PS> '/Users/username/anotherrepo' | Open-GitRepo
        Opens the repository web page for the local directory (macOS).

    .EXAMPLE
        PS> Open-GitRepo -Url 'git@bitbucket.org:user/repo.git'
        Opens the Bitbucket repository web page for the provided SSH URL.

    .NOTES
        Author: Jonathan Havens
        Version: 0.0.2
        Cross-platform: Windows, macOS, Linux
        Supports GitHub and Bitbucket repositories.
        Requires PowerShell 7+ and git in PATH.

    .LINK
        https://github.com/jhavenz/open-gitrepo
        https://github.com/jhavenz/open-gitrepo/blob/main/Source/en-US/about_Open-GitRepo.help.txt

    #>

    [Alias('ogr', 'git-open', 'gitopen', 'git-browse', 'gitbrowse')]
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param(
        [Parameter(Position = 0, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Path,
        [Parameter(Position = 0, ParameterSetName = 'Url', ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Url,
        [Parameter(Position = 1)]
        [string]$Branch
    )

    process {
        $targetUrl = $null
        $usedBranch = $Branch

        #normalize the input
        $_url, $_path = $null, $null
        if ($Url -is [string] -and (Test-Path $Url)) {
            $_path = $Url
        }
        
        if ($Path -is [string] -and $Path -match '^https?://|git@') {
            $_url = $Path
        }

        if ($null -ne $_url) {
            $Url = $_url
        } 

        if ($null -ne $_path) {
            $Path = $_path
        }

        if ($Url) {
            if (-not $usedBranch) { $usedBranch = 'main' }
            $branch = Get-GitCurrentBranch -DefaultBranch $usedBranch

            $targetUrl = Get-RepoWebUrl -RemoteUrl $Url -Branch $Branch
            if (-not $targetUrl) { return }
        }
        elseif ($Path) {
            $branch = Get-GitCurrentBranch -TargetPath $Path -DefaultBranch $Branch
            $url = Get-GitRemoteUrl -TargetPath $Path
            
            if ($branch -and $url) {
                $usedBranch = $info.Branch || $usedBranch
                $targetUrl = Get-RepoWebUrl -RemoteUrl $url -Branch $branch
                if (-not $targetUrl) { return }
            }
        }
        elseif ($PSItem) {
            if (Test-Path $PSItem -PathType Container) {
                $url = Get-GitRemoteUrl -TargetPath $PSItem
                $branch = Get-GitCurrentBranch -TargetPath $PSItem -DefaultBranch $usedBranch
                if ($url -and $branch) {
                    $targetUrl = Get-RepoWebUrl -RemoteUrl $url -Branch $branch
                }
            }
            elseif ($PSItem -match '^https?://|git@') {
                if (-not $usedBranch) { $usedBranch = 'main' }
                $targetUrl = Get-RepoWebUrl -RemoteUrl $PSItem -Branch $usedBranch
                if (-not $targetUrl) { return }
            }
            else {
                Write-Error "Unsupported Git provider or unrecognized remote URL format: $PSItem"
                return
            }
        }
        else {
            $url = Get-GitRemoteUrl -TargetPath (Get-Location).Path
            $branch = Get-GitCurrentBranch -TargetPath (Get-Location).Path -DefaultBranch $usedBranch

            if ([string]::IsNullOrEmpty($url)) {
                Write-Error "Could not determine a URL for the remote repository. Have you run 'git remote add origin <url>'?"
                return
            }

            if ([string]::IsNullOrEmpty($branch)) {
                Write-Error "Could not determine the current branch. Are you in a git repository?"
                return
            }
            
            $targetUrl = Get-RepoWebUrl -RemoteUrl $url -Branch $branch
            if (-not $targetUrl) {return}
        }
        if (-not $targetUrl) {
            Write-Error "Could not determine repository URL. Please provide a valid path or URL."
            return
        }
        Start-Process $targetUrl
    }
}
#EndRegion './Public/Open-GitRepo.ps1' 154