Public/克隆-GitHub镜像.ps1

<#
.SYNOPSIS
    通过镜像站点克隆 GitHub 仓库,仅获取默认分支的最新提交(浅克隆)。
.DESCRIPTION
    接受仓库远程地址和本地路径作为参数,使用与拉取相同的动态镜像排序策略,
    依次尝试克隆,成功后记录镜像统计。仅克隆默认分支(--single-branch)且
    只获取最新一次提交(--depth 1),适合快速获取大型仓库的最新代码。
    克隆完成后自动将远程 origin 的地址改回原始 GitHub 地址,
    因此后续手动 git pull/push 等操作直接走 GitHub,不受镜像影响。
.PARAMETER 仓库地址
    GitHub 仓库的 HTTPS 或 SSH 地址,如 https://github.com/用户/仓库.git 或 git@github.com:用户/仓库.git。
.PARAMETER 本地路径
    克隆到的本地目标路径。若该路径不存在则直接克隆到此路径;
    若已存在且为目录,则自动在其下创建与仓库同名的子目录并克隆进去;
    若已存在但不是目录(如同名文件)则报错。
    若目标目录下已存在同一仓库的上次未完成的克隆,则从断点续传(保留已下载的对象)。
.PARAMETER 镜像站前缀
    镜像站地址前缀列表,默认内置多个常用镜像站。
.PARAMETER 记录文件路径
    镜像统计记录文件的保存路径,默认为 "$env:TEMP\镜像拉取记录.json"。
.EXAMPLE
    克隆-GitHub镜像 -仓库地址 "https://github.com/PowerShell/PowerShell.git" -本地路径 "D:\PowerShell"
    通过镜像站克隆 PowerShell 仓库到本地 D:\PowerShell。
.EXAMPLE
    克隆-GitHub镜像 "https://github.com/torvalds/linux.git" "D:\linux" -镜像站前缀 "https://gh-proxy.cn/"
    仅使用指定镜像站克隆 Linux 内核仓库。
#>

function 克隆-GitHub镜像 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$仓库地址,

        [Parameter(Mandatory = $true, Position = 1)]
        [string]$本地路径,

        [Parameter(Position = 2)]
        [string[]]$镜像站前缀 = $script:镜像站前缀,

        [Parameter()]
        [string]$记录文件路径 = $script:默认记录路径
    )

    $HTTPS仓库地址 = 转换为-HTTPS仓库地址 $仓库地址

    # 续传识别:目标目录已存在且是同一仓库的半成品克隆(origin 相同)则复用之,
    # 保留已下载的对象,从断点继续;否则仍报错。
    $续传 = $false
    if (Test-Path -LiteralPath $本地路径) {
        if (-not (Test-Path -LiteralPath $本地路径 -PathType Container)) {
            throw "目标路径已存在且不是目录:$本地路径"
        }

        if (Test-Path -LiteralPath (Join-Path $本地路径 ".git")) {
            $已有origin = 尝试读取-Git文本 @("-C", $本地路径, "remote", "get-url", "origin")
            if ($已有origin -eq $HTTPS仓库地址) {
                $续传 = $true
                Write-Host "目标目录已存在同一仓库的未完成克隆,将从断点续传:$本地路径"
            }
            else {
                throw "目标路径已存在且是其他仓库:$本地路径"
            }
        }
        else {
            $仓库名 = $HTTPS仓库地址.TrimEnd("/").Split("/")[-1] -replace "(?i)\.git$", ""
            if ([string]::IsNullOrWhiteSpace($仓库名)) {
                throw "无法从仓库地址解析出仓库名:$仓库地址"
            }

            $子目录 = Join-Path $本地路径 $仓库名

            if (Test-Path -LiteralPath (Join-Path $子目录 ".git")) {
                $已有origin = 尝试读取-Git文本 @("-C", $子目录, "remote", "get-url", "origin")
                if ($已有origin -eq $HTTPS仓库地址) {
                    $续传 = $true
                    $本地路径 = $子目录
                    Write-Host "目标子目录已存在同一仓库的未完成克隆,将从断点续传:$本地路径"
                }
                else {
                    throw "目标子目录已存在且是其他仓库:$子目录"
                }
            }
            else {
                $本地路径 = $子目录
                Write-Host "目标路径已存在,改为克隆到子目录:$本地路径"

                if (Test-Path -LiteralPath $本地路径) {
                    throw "目标子目录已存在:$本地路径"
                }
            }
        }
    }

    $镜像记录 = 读取-镜像记录 $记录文件路径

    # 统计按镜像站(前缀)记录,与具体仓库无关,可跨仓库共享历史经验;
    # 候选条目按各站历史成功率排序
    $候选镜像条目 = 排序-镜像候选 $镜像记录 $镜像站前缀 $HTTPS仓库地址

    # 用 init + fetch 代替 clone:已下载的对象保留在本地,
    # 失败重试或换镜像续传时只补拉缺失部分,实现断点续传
    if (-not $续传) {
        执行-Git命令 @("init", $本地路径)
        执行-Git命令 @("-C", $本地路径, "remote", "add", "origin", $HTTPS仓库地址)
    }

    Push-Location $本地路径
    $成功镜像地址 = ""
    try {
        foreach ($镜像条目 in $候选镜像条目) {
            $镜像地址 = $镜像条目.地址
            $显示评分 = [math]::Round($镜像条目.评分 * 100, 1)
            Write-Host "尝试从镜像拉取(成功率 $显示评分%):$镜像地址"

            $开始时间 = Get-Date
            $拉取成功 = 尝试-Git命令 @("fetch", "--depth", "1", "--update-shallow", $镜像地址, "HEAD")

            if ($拉取成功) {
                # 镜像可能对上游有提交的仓库返回空数据,造成"成功"的空克隆。
                # 本地为空时用 ls-remote 向该镜像核对:远端确有提交则判定此镜像失败,继续尝试下一个。
                $本地HEAD = 尝试读取-Git文本 @("rev-parse", "FETCH_HEAD")
                if ([string]::IsNullOrWhiteSpace($本地HEAD)) {
                    $远程HEAD = 尝试读取-Git文本 @("ls-remote", $镜像地址, "HEAD")
                    if (-not [string]::IsNullOrWhiteSpace($远程HEAD)) {
                        Write-Host "该镜像返回了空仓库,但远端实际有提交,判定此镜像失败。"
                        $拉取成功 = $false
                    }
                }
            }

            $耗时毫秒 = [int]((Get-Date) - $开始时间).TotalMilliseconds
            记录-镜像尝试 $镜像记录 $镜像条目.站点 $拉取成功 $耗时毫秒
            保存-镜像记录 $镜像记录 $记录文件路径

            if ($拉取成功) {
                $成功镜像地址 = $镜像地址
                break
            }

            Write-Host "该镜像不可用,继续尝试下一个。"
        }
    }
    finally {
        Pop-Location
    }

    if ([string]::IsNullOrWhiteSpace($成功镜像地址)) {
        Write-Host "已保留 $本地路径 中已下载的部分,再次运行本命令可断点续传。"
        throw "所有镜像站都未能克隆仓库:$仓库地址"
    }

    Push-Location $本地路径
    try {
        # 查远端默认分支名,避免硬编码 main
        $远程HEAD行 = 尝试读取-Git文本 @("ls-remote", "--symref", $成功镜像地址, "HEAD")
        $默认分支 = "main"
        foreach ($行 in $远程HEAD行 -split "`n") {
            if ($行 -match "^ref:\s+refs/heads/(.+)\s+HEAD\s*$") {
                $默认分支 = $Matches[1].Trim()
                break
            }
        }

        # 把拉到的 HEAD 提交检出到本地默认分支
        $浅提交 = 读取-Git文本 @("rev-parse", "FETCH_HEAD")
        执行-Git命令 @("checkout", "--force", "-B", $默认分支, $浅提交)

        # origin 从 init 起就指向原始 GitHub 地址,后续 git pull/push 直接走 GitHub

        Write-Host "克隆完成。已将远程 origin 设置为 $HTTPS仓库地址"

        # 空仓库(尚无任何提交)无法查看提交信息
        $最新提交 = 尝试读取-Git文本 @("log", "-1", "--pretty=format:%h %s")

        if ([string]::IsNullOrWhiteSpace($最新提交)) {
            Write-Host "提示:该仓库目前为空(尚无任何提交)。"
        }
        else {
            Write-Host "当前最新提交:$最新提交"
        }
    }
    finally {
        Pop-Location
    }
}