
# Git LFS

Git 大文件存储（LFS）会将仓库中的大文件替换为小型指针文件。实际文件内容存储在单独的 LFS 服务器上。Pitmaster 可以解析和创建 LFS 指针，并通过批处理 API 与 LFS 服务器通信。它不会读取 `.gitattributes`，也不会运行 LFS clean 和 smudge 过滤器：`add()` 会原样存储文件，而 `checkout()` 会写入指针文件。获取实际内容由你的代码负责，如下所示。

## LFS 指针文件

跟踪文件时，LFS 会用指针文件代替实际内容，git 会存储该指针文件：

```
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
size 12345
```

指针包含：
- `version`：LFS 规范版本 URL
- `oid`：实际文件内容的 SHA-256 哈希（前缀为 `sha256:`）
- `size`：实际文件大小，单位为字节

### 解析指针

```php
use Pitmaster\Lfs\LfsPointer;

$content = file_get_contents('.git-lfs-tracked-file');
$pointer = LfsPointer::parse($content);

if ($pointer !== null) {
    echo "OID: {$pointer->oid}\n";
    echo "Size: {$pointer->size}\n";
    echo "Version: {$pointer->version}\n";
}
```

如果缺少 `version`、`oid sha256:` 或 `size` 行，`parse()` 会返回 `null`。它不会验证值（非十六进制 OID 或非数字大小也会被接受）。

### 检测指针

```php
if (LfsPointer::isPointer($blobContent)) {
    // This blob is an LFS pointer, not actual file content
    $pointer = LfsPointer::parse($blobContent);
}
```

`isPointer()` 会检查版本 URL、`oid sha256:` 和 `size` 字段是否存在，但不会完整解析。

### 创建指针

```php
// From known OID and size
$pointer = new LfsPointer(
    oid: '4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393',
    size: 12345,
);

// From file content (computes SHA-256 hash)
$pointer = LfsPointer::forContent($actualFileContent);

echo $pointer->oid;   // SHA-256 hash
echo $pointer->size;  // Content length
```

### 序列化指针

```php
$content = $pointer->serialize();
// "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a21...\nsize 12345\n"
```

## LFS 客户端

`LfsClient` 使用批处理 API 与 LFS 服务器通信（向 `<url>/objects/batch` 发送 POST 请求）。

### 创建客户端

```php
use Pitmaster\Lfs\LfsClient;

// From a git remote URL (appends /info/lfs)
$client = LfsClient::fromRemoteUrl('https://github.com/user/repo.git');
// LFS URL: https://github.com/user/repo.git/info/lfs

// Direct URL
$client = new LfsClient('https://lfs.example.com/repo', timeout: 60);
```

`fromRemoteUrl()` 方法遵循标准惯例：将 `/info/lfs` 附加到 git URL（如果尚不存在，则添加 `.git`）。只有 HTTP(S) 远程 URL 才能提供可用的 LFS URL；SCP 风格的 SSH 远程地址（`git@host:repo.git`）则不行。

`LfsClient` 使用 PHP 的 HTTP 流包装器（必须启用 `allow_url_fopen`）。它不会发送凭据，也会忽略每个操作返回的 `header` 映射，因此仅适用于无需身份验证的 LFS 服务器和存储 URL。它不依赖仓库句柄，因此即使你使用的是通过 `Pitmaster::processFreeOptions()` 打开的仓库，它也会发出网络请求。

### 下载文件

```php
// Single file download
$content = $client->download($pointer->oid, $pointer->size);
file_put_contents('large-file.bin', $content);
```

在底层，`download()` 会：
1. 为 `download` 操作发送批处理请求
2. 从服务器接收下载 URL
3. 从该 URL 获取文件内容

下载的字节不会与 OID 或大小进行比对。

### 批量下载

```php
$results = $client->batchDownload([
    ['oid' => 'abc123...', 'size' => 12345],
    ['oid' => 'def456...', 'size' => 67890],
]);

foreach ($results as $result) {
    if ($result['error'] !== null) {
        echo "Error: {$result['error']}\n";
        continue;
    }

    if ($result['href'] !== null) {
        // Download from $result['href']
    }
}
```

### 上传文件

```php
$client->upload($pointer->oid, $pointer->size, $fileContent);
```

上传会为 `upload` 操作发送批处理请求。如果服务器已拥有该对象，就不会返回上传 URL，操作也不会执行任何操作。上传请求本身的结果不会被检查，因此上传失败不会被报告，也不会调用 `verify` 操作。

### 批量上传

```php
$results = $client->batchUpload([
    ['oid' => 'abc123...', 'size' => 12345],
]);

foreach ($results as $result) {
    if ($result['href'] !== null) {
        // Server needs this object, upload to $result['href']
    }
    // No href means server already has it
}
```

## 批处理 API 协议

LFS 批处理 API 使用 JSON，通过 HTTP 传输：

### 请求

```
POST <lfs-url>/objects/batch
Content-Type: application/vnd.git-lfs+json
Accept: application/vnd.git-lfs+json

{
    "operation": "download",
    "transfers": ["basic"],
    "objects": [
        {"oid": "abc123...", "size": 12345}
    ]
}
```

### 响应

```json
{
    "objects": [
        {
            "oid": "abc123...",
            "size": 12345,
            "actions": {
                "download": {
                    "href": "https://storage.example.com/abc123...",
                    "header": {
                        "Authorization": "Bearer token"
                    }
                }
            }
        }
    ]
}
```

## 将 LFS 集成到仓库操作中

读取使用 LFS 的仓库时，blob 内容可能是 LFS 指针，而非实际文件数据。要获取实际内容：

```php
$blob = $repo->readObject($hash);

if (LfsPointer::isPointer($blob->content)) {
    $pointer = LfsPointer::parse($blob->content);
    $remoteUrl = $repo->config()->get('remote.origin.url');
    $client = LfsClient::fromRemoteUrl($remoteUrl);
    $realContent = $client->download($pointer->oid, $pointer->size);
} else {
    $realContent = $blob->content;
}
```

## 错误处理

批处理请求失败或无效，以及下载请求失败，都会抛出 `ProtocolException`。如果服务器没有为对象返回下载 URL，`download()` 也会抛出错误，但不会包含服务器的错误消息。`batchDownload()` 和 `batchUpload()` 不会因单个对象错误而抛出异常；它们会将错误放在 `error` 字段中返回。

```php
use Pitmaster\Exceptions\ProtocolException;

try {
    $content = $client->download($oid, $size);
} catch (ProtocolException $e) {
    // Server unreachable, invalid response, no download URL
    echo "LFS error: {$e->getMessage()}\n";
}
```

## LFS 指针属性

`LfsPointer` 类为只读：

```php
$pointer->oid;      // string: SHA-256 hash (hex)
$pointer->size;     // int: file size in bytes
$pointer->version;  // string: spec version URL
```
