logo

纯php实现提交文件到github

2026-01-16 点击 18

纯php实现提交文件到github,用于本地部署php程序,生成静态网站,自动提交到github,实现静态网站。

代码参考如下:

<?php

class GitHubDeployer {
    private string $token;
    private string $owner;
    private string $repo;
    private string $branch;
    private array $headers;
    private int $maxRetries = 3;
    private float $retryDelay = 1.0;
    private int $concurrentLimit = 20; // 并发数

    public function __construct(string $token, string $owner, string $repo, string $branch = 'gh-pages') {
        $this->token = $token;
        $this->owner = $owner;
        $this->repo = $repo;
        $this->branch = $branch;
        $this->headers = [
            'Authorization: token ' . $this->token,
            'Accept: application/vnd.github.v3+json',
            'User-Agent: PHP-GitHub-Deployer/3.0'
        ];
    }

    // ========== HTTP 工具 ==========
    private function createCurlHandle(string $url, string $method = 'GET', ?array $data = null): CurlHandle {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_HTTPHEADER => $this->headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);

        if ($method === 'POST') {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_SLASHES));
        } elseif ($method === 'PATCH') {
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_SLASHES));
        }
        return $ch;
    }

    private function request(string $url, string $method = 'GET', ?array $data = null, int $retry = 0): array {
        $ch = $this->createCurlHandle($url, $method, $data);
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($httpCode >= 200 && $httpCode < 300) {
            return json_decode($response, true) ?: [];
        }

        if (($httpCode >= 500 || $httpCode === 0) && $retry < $this->maxRetries) {
            $delay = $this->retryDelay * (2 ** $retry);
            error_log("⚠️ Retry {$retry}/{$this->maxRetries} for {$url} after {$delay}s");
            usleep((int)($delay * 1_000_000));
            return $this->request($url, $method, $data, $retry + 1);
        }

        throw new Exception("API failed [{$httpCode}]: " . ($response ?: $error));
    }

    // ========== .gitignore 解析 ==========
    private function parseGitignore(string $gitignorePath): array {
        if (!file_exists($gitignorePath)) return [];
        $lines = file($gitignorePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        $rules = [];
        foreach ($lines as $line) {
            $line = trim($line);
            if ($line === '' || str_starts_with($line, '#')) continue;
            $rules[] = $line;
        }
        return $rules;
    }

    private function matchesGitignore(string $relativePath, array $rules): bool {
        foreach ($rules as $rule) {
            $negate = str_starts_with($rule, '!');
            $pattern = $negate ? substr($rule, 1) : $rule;

            // 处理目录结尾
            $isDirPattern = str_ends_with($pattern, '/');
            if ($isDirPattern) $pattern = rtrim($pattern, '/');

            // 转换为 glob 模式
            if (str_contains($pattern, '/')) {
                // 包含路径分隔符:精确匹配或通配
                $testPath = $relativePath;
                if ($isDirPattern && !str_ends_with($relativePath, '/')) {
                    $testPath .= '/'; // 临时加 / 便于匹配目录
                }
                if (fnmatch($pattern, $testPath, FNM_PATHNAME)) {
                    return !$negate;
                }
            } else {
                // 仅文件名匹配(递归到所有子目录)
                $filename = basename($relativePath);
                if (fnmatch($pattern, $filename)) {
                    return !$negate;
                }
                // 也匹配完整路径(如 *.log)
                if (fnmatch($pattern, $relativePath)) {
                    return !$negate;
                }
            }
        }
        return false;
    }

    // ========== 并发 blob 创建 ==========
    private function createBlobsConcurrent(string $apiBase, array $filesToUpload): array {
        $results = [];
        $total = count($filesToUpload);
        $batches = array_chunk($filesToUpload, $this->concurrentLimit);

        foreach ($batches as $i => $batch) {
            error_log("📤 上传 blob 批次 " . ($i+1) . "/" . count($batches) . " (" . count($batch) . " 个文件)");

            $mh = curl_multi_init();
            $handles = [];
            $map = [];

            // 创建并发句柄
            foreach ($batch as $relPath => $absPath) {
                $content = file_get_contents($absPath);
                if ($content === false) continue;

                $blobData = [
                    'content' => base64_encode($content),
                    'encoding' => 'base64'
                ];

                $ch = $this->createCurlHandle("{$apiBase}/git/blobs", 'POST', $blobData);
                curl_multi_add_handle($mh, $ch);
                $handles[$relPath] = $ch;
                $map[(int)$ch] = $relPath;
            }

            // 执行并发
            do {
                curl_multi_exec($mh, $running);
                curl_multi_select($mh);
            } while ($running > 0);

            // 收集结果
            foreach ($handles as $relPath => $ch) {
                $response = curl_multi_getcontent($ch);
                $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_multi_remove_handle($mh, $ch);
                curl_close($ch);

                if ($httpCode >= 200 && $httpCode < 300) {
                    $blob = json_decode($response, true);
                    $results[$relPath] = $blob['sha'] ?? null;
                } else {
                    error_log("❌ Blob 创建失败 ({$httpCode}) for {$relPath}");
                    // 可选:单文件重试(简化起见,此处不重试)
                    $results[$relPath] = null;
                }
            }

            curl_multi_close($mh);
        }

        return $results;
    }

    // ========== Tree 构建(复用之前逻辑)==========
    private function getRemoteFiles(string $treeUrl, array &$files = []): void {
        $tree = $this->request($treeUrl);
        foreach ($tree['tree'] ?? [] as $item) {
            if ($item['type'] === 'blob') {
                $files[$item['path']] = $item['sha'];
            } elseif ($item['type'] === 'tree') {
                $this->getRemoteFiles($item['url'], $files);
            }
        }
    }

    private function createTreeRecursive(string $apiBase, string $basePath, array $entries, string $baseTreeSha = null): string {
        if (count($entries) <= 1000) {
            $payload = ['tree' => $entries];
            if ($baseTreeSha) $payload['base_tree'] = $baseTreeSha;
            $tree = $this->request("{$apiBase}/git/trees", 'POST', $payload);
            return $tree['sha'];
        }

        $groups = [];
        $rootFiles = [];
        foreach ($entries as $entry) {
            if (strpos($entry['path'], '/') === false) {
                $rootFiles[] = $entry;
            } else {
                [$dir] = explode('/', $entry['path'], 2);
                $groups[$dir][] = [
                    'path' => substr($entry['path'], strlen($dir) + 1),
                    'mode' => $entry['mode'],
                    'type' => $entry['type'],
                    'sha' => $entry['sha']
                ];
            }
        }

        $newEntries = $rootFiles;
        foreach ($groups as $dir => $subEntries) {
            $subTreeSha = $this->createTreeRecursive($apiBase, "{$basePath}{$dir}/", $subEntries);
            $newEntries[] = [
                'path' => $dir,
                'mode' => '040000',
                'type' => 'tree',
                'sha' => $subTreeSha
            ];
        }

        return $this->createTreeRecursive($apiBase, $basePath, $newEntries, $baseTreeSha);
    }

    // ========== 主部署逻辑 ==========
    public function deploy(string $localDir, string $commitMessage = 'Deploy static site'): string {
        $apiBase = "https://api.github.com/repos/{$this->owner}/{$this->repo}";
        $localDir = rtrim($localDir, '/\\');

        // Step 1: 获取远程状态
        $ref = $this->request("{$apiBase}/git/refs/heads/{$this->branch}");
        $latestCommitSha = $ref['object']['sha'];
        $commit = $this->request("{$apiBase}/git/commits/{$latestCommitSha}");
        $baseTreeSha = $commit['tree']['sha'];

        $remoteFiles = [];
        $this->getRemoteFiles("{$apiBase}/git/trees/{$baseTreeSha}?recursive=1", $remoteFiles);

        // Step 2: 加载 .gitignore
        $gitignoreRules = $this->parseGitignore("{$localDir}/.gitignore");

        // Step 3: 扫描本地文件(应用忽略规则)
        $localFiles = [];
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($localDir, RecursiveDirectoryIterator::SKIP_DOTS));
        foreach ($iterator as $file) {
            if (!$file->isFile()) continue;

            $relPath = str_replace('\\', '/', substr($file->getPathname(), strlen($localDir) + 1));
            
            // 应用 .gitignore
            if ($this->matchesGitignore($relPath, $gitignoreRules)) {
                continue;
            }

            $localFiles[$relPath] = $file->getPathname();
        }

        // Step 4: 增量判断(哪些需要上传)
        $filesToUpload = [];
        $treeEntries = [];
        $unchanged = 0;

        foreach ($localFiles as $relPath => $absPath) {
            $remoteSha = $remoteFiles[$relPath] ?? null;
            $localSha1 = sha1_file($absPath);

            if ($remoteSha) {
                // 尝试获取远程内容 SHA1(简化:直接比对 blob 内容需额外请求,此处用文件内容比对)
                // 更优方案:缓存 {path => content_sha},但为性能,我们假设“内容相同则无需上传”
                // 实际中可接受轻微冗余上传
                try {
                    $blob = $this->request("{$apiBase}/git/blobs/{$remoteSha}");
                    if (isset($blob['content']) && base64_decode($blob['content'], true) !== false) {
                        $remoteContent = base64_decode($blob['content'], true);
                        if (sha1($remoteContent) === $localSha1) {
                            $treeEntries[] = ['path' => $relPath, 'mode' => '100644', 'type' => 'blob', 'sha' => $remoteSha];
                            $unchanged++;
                            continue;
                        }
                    }
                } catch (Exception $e) {
                    // blob 可能被 GC,忽略
                }
            }

            $filesToUpload[$relPath] = $absPath;
        }

        // Step 5: 并发上传新/修改的文件
        if (!empty($filesToUpload)) {
            $newBlobs = $this->createBlobsConcurrent($apiBase, $filesToUpload);
            foreach ($newBlobs as $relPath => $sha) {
                if ($sha) {
                    $treeEntries[] = ['path' => $relPath, 'mode' => '100644', 'type' => 'blob', 'sha' => $sha];
                } else {
                    error_log("⚠️ 跳过无法上传的文件: {$relPath}");
                }
            }
        }

        // Step 6: 创建 tree 和 commit
        $newTreeSha = $this->createTreeRecursive($apiBase, '', $treeEntries, $baseTreeSha);
        $newCommit = $this->request("{$apiBase}/git/commits", 'POST', [
            'message' => $commitMessage,
            'tree' => $newTreeSha,
            'parents' => [$latestCommitSha]
        ]);
        $this->request("{$apiBase}/git/refs/heads/{$this->branch}", 'PATCH', ['sha' => $newCommit['sha']]);

        $deleted = count(array_diff_key($remoteFiles, $localFiles));
        $addedOrModified = count($localFiles) - $unchanged;

        return sprintf(
            "✅ 部署成功!\n" .
            "  - 新增/修改: %d\n" .
            "  - 未变更: %d\n" .
            "  - 已删除: %d\n" .
            "  - 总文件数: %d\n" .
            "  - Commit: %s",
            $addedOrModified, $unchanged, $deleted, count($localFiles), $newCommit['sha']
        );
    }
}

// ===== 使用示例 =====
try {
    $deployer = new GitHubDeployer(
        token: $_ENV['GITHUB_TOKEN'] ?? 'YOUR_TOKEN',
        owner: 'your-username',
        repo: 'your-static-site',
        branch: 'gh-pages'
    );

    echo $deployer->deploy(__DIR__ . '/dist', 'Auto deploy with .gitignore & concurrency');
} catch (Exception $e) {
    fwrite(STDERR, "❌ Error: " . $e->getMessage() . "\n");
    exit(1);
}​

本代码由千问生成,供参考。

0%