GitGram — gitlib.php — GitGram
GitGram / main / v2.00 / gitlib.php41,832 B↓ Raw
<?php
/**
 * gitlib.php — Pure PHP git object store + smart HTTP protocol
 * No exec/shell_exec/proc_open required.
 *
 * Implements:
 *   - Loose object read/write (zlib RFC 1950)
 *   - Pack file v2 read (fan-out, OFS_DELTA, REF_DELTA, delta application)
 *   - Ref read/write (loose + packed-refs, atomic via .lock files)
 *   - Object parsers: commit, tree
 *   - Object builders: blob, tree, commit
 *   - High-level reads: gl_log, gl_ls_tree, gl_cat_file, gl_last_commit
 *   - High-level writes: gl_write_files
 *   - Smart HTTP: info/refs, upload-pack, receive-pack (pktline wire protocol)
 */

// ─────────────────────────────────────────────────────────────
// 1. LOOSE OBJECT STORE
// ─────────────────────────────────────────────────────────────

/**
 * Read a loose git object.
 * Returns ['type'=>string, 'data'=>string] or false on failure.
 */
function gl_read_loose(string $repo, string $sha): array|false
{
    $path = $repo . '/objects/' . substr($sha, 0, 2) . '/' . substr($sha, 2);
    if (!is_file($path)) return false;
    $raw = gzuncompress(file_get_contents($path));
    if ($raw === false) return false;
    $sp  = strpos($raw, "\0");
    if ($sp === false) return false;
    $header = substr($raw, 0, $sp);
    $data   = substr($raw, $sp + 1);
    [$type] = explode(' ', $header, 2);
    return ['type' => $type, 'data' => $data];
}

/**
 * Write a git object (loose). Returns the hex SHA1 on success.
 */
function gl_write_loose(string $repo, string $type, string $data): string
{
    $header  = "$type " . strlen($data) . "\0";
    $raw     = $header . $data;
    $sha     = sha1($raw);
    $dir     = $repo . '/objects/' . substr($sha, 0, 2);
    $path    = $dir . '/' . substr($sha, 2);
    if (!is_file($path)) {
        if (!is_dir($dir)) mkdir($dir, 0755, true);
        $tmp = $path . '.tmp.' . getmypid();
        file_put_contents($tmp, gzcompress($raw, 1));
        rename($tmp, $path);
    }
    return $sha;
}

// ─────────────────────────────────────────────────────────────
// 2. PACK FILE v2 READER
// ─────────────────────────────────────────────────────────────

/**
 * Find the byte offset of $sha inside a pack index file (.idx v2).
 * Returns int offset or false.
 */
function gl_pack_find_offset(string $idx_path, string $sha): int|false
{
    $fh = fopen($idx_path, 'rb');
    if (!$fh) return false;

    // Magic + version
    $magic   = fread($fh, 4);
    $version = unpack('N', fread($fh, 4))[1];
    if ($magic !== "\xff\x74\x4f\x63" || $version !== 2) { fclose($fh); return false; }

    // Fan-out table (256 × 4 bytes)
    $fan = unpack('N256', fread($fh, 256 * 4));
    $total = $fan[256];

    // Binary search for SHA
    $bin_sha = hex2bin($sha);
    $lo = 0; $hi = $total - 1;
    $sha_table_start = 8 + 256 * 4;
    $idx = -1;
    while ($lo <= $hi) {
        $mid = (int)(($lo + $hi) / 2);
        fseek($fh, $sha_table_start + $mid * 20);
        $s = fread($fh, 20);
        $cmp = strcmp($s, $bin_sha);
        if ($cmp === 0) { $idx = $mid; break; }
        if ($cmp < 0) $lo = $mid + 1; else $hi = $mid - 1;
    }
    if ($idx === -1) { fclose($fh); return false; }

    // CRC32 table: skip ($total × 4 bytes)
    $crc_table_start = $sha_table_start + $total * 20;
    // Offset table: 4-byte entries
    $off_table_start = $crc_table_start + $total * 4;
    fseek($fh, $off_table_start + $idx * 4);
    $off32 = unpack('N', fread($fh, 4))[1];

    if ($off32 & 0x80000000) {
        // Large offset: index into 8-byte table
        $large_idx = $off32 & 0x7fffffff;
        $large_start = $off_table_start + $total * 4;
        fseek($fh, $large_start + $large_idx * 8);
        $hi32 = unpack('N', fread($fh, 4))[1];
        $lo32 = unpack('N', fread($fh, 4))[1];
        $offset = ($hi32 << 32) | $lo32;
    } else {
        $offset = $off32;
    }
    fclose($fh);
    return $offset;
}

/**
 * Read a packed object at $offset from a pack file.
 * Returns ['type'=>string, 'data'=>string] or false.
 */
function gl_pack_read_at(string $pack_path, int $offset): array|false
{
    $fh = fopen($pack_path, 'rb');
    if (!$fh) return false;
    fseek($fh, $offset);

    // Read variable-length size header
    $byte   = ord(fread($fh, 1));
    $type   = ($byte >> 4) & 0x07;
    $size   = $byte & 0x0f;
    $shift  = 4;
    while ($byte & 0x80) {
        $byte   = ord(fread($fh, 1));
        $size  |= ($byte & 0x7f) << $shift;
        $shift += 7;
    }

    $pack_types = [1=>'commit',2=>'tree',3=>'blob',4=>'tag',6=>'ofs_delta',7=>'ref_delta'];

    if ($type === 6) { // OFS_DELTA
        // Read negative offset varint (special encoding)
        $byte = ord(fread($fh, 1));
        $neg  = $byte & 0x7f;
        while ($byte & 0x80) {
            $byte = ord(fread($fh, 1));
            $neg  = (($neg + 1) << 7) | ($byte & 0x7f);
        }
        $base_offset = $offset - $neg;
        $delta_data  = gl_inflate_from_fh($fh);
        fclose($fh);
        $base = gl_pack_read_at($pack_path, $base_offset);
        if (!$base) return false;
        return ['type' => $base['type'], 'data' => gl_apply_delta($base['data'], $delta_data)];
    }

    if ($type === 7) { // REF_DELTA
        $base_sha = bin2hex(fread($fh, 20));
        $delta_data = gl_inflate_from_fh($fh);
        fclose($fh);
        $base = gl_read_object(dirname(dirname($pack_path)), $base_sha);
        if (!$base) return false;
        return ['type' => $base['type'], 'data' => gl_apply_delta($base['data'], $delta_data)];
    }

    $data = gl_inflate_from_fh($fh);
    fclose($fh);
    if ($data === false) return false;
    return ['type' => $pack_types[$type] ?? 'unknown', 'data' => $data];
}

/**
 * Inflate zlib-compressed data from current file handle position.
 */
function gl_inflate_from_fh($fh): string|false
{
    $ctx = inflate_init(ZLIB_ENCODING_DEFLATE);
    if (!$ctx) {
        // Fall back to reading rest of file and gzuncompressing
        $rest = stream_get_contents($fh);
        return @gzuncompress($rest);
    }
    $out = '';
    while (!feof($fh)) {
        $chunk  = fread($fh, 8192);
        $status = inflate_add($ctx, $chunk);
        if ($status === false) return false;
        $out .= $status;
        if (inflate_get_status($ctx) === ZLIB_STREAM_END) break;
    }
    return $out;
}

/**
 * Apply a git binary delta to base data.
 * delta format: src_size varint, dst_size varint, then instructions.
 */
function gl_apply_delta(string $base, string $delta): string
{
    $pos = 0;
    $len = strlen($delta);

    $read_varint = function() use (&$pos, $delta) {
        $val = 0; $shift = 0;
        do {
            $b = ord($delta[$pos++]);
            $val |= ($b & 0x7f) << $shift;
            $shift += 7;
        } while ($b & 0x80);
        return $val;
    };

    $src_size = $read_varint();
    $dst_size = $read_varint();
    $out = '';

    while ($pos < $len) {
        $cmd = ord($delta[$pos++]);
        if ($cmd & 0x80) { // COPY from base
            $copy_off  = 0; $copy_len = 0;
            if ($cmd & 0x01) $copy_off  |= ord($delta[$pos++]);
            if ($cmd & 0x02) $copy_off  |= ord($delta[$pos++]) << 8;
            if ($cmd & 0x04) $copy_off  |= ord($delta[$pos++]) << 16;
            if ($cmd & 0x08) $copy_off  |= ord($delta[$pos++]) << 24;
            if ($cmd & 0x10) $copy_len  |= ord($delta[$pos++]);
            if ($cmd & 0x20) $copy_len  |= ord($delta[$pos++]) << 8;
            if ($cmd & 0x40) $copy_len  |= ord($delta[$pos++]) << 16;
            if ($copy_len === 0) $copy_len = 0x10000;
            $out .= substr($base, $copy_off, $copy_len);
        } elseif ($cmd) { // INSERT literal
            $out .= substr($delta, $pos, $cmd);
            $pos += $cmd;
        }
    }
    return $out;
}

// ─────────────────────────────────────────────────────────────
// 3. UNIFIED OBJECT READ
// ─────────────────────────────────────────────────────────────

/**
 * Read any git object (loose first, then packs).
 * Returns ['type'=>string, 'data'=>string] or false.
 */
function gl_read_object(string $repo, string $sha): array|false
{
    $obj = gl_read_loose($repo, $sha);
    if ($obj !== false) return $obj;

    // Search pack files
    $pack_dir = $repo . '/objects/pack';
    if (!is_dir($pack_dir)) return false;
    foreach (glob($pack_dir . '/*.idx') as $idx_path) {
        $pack_path = substr($idx_path, 0, -4) . '.pack';
        if (!is_file($pack_path)) continue;
        $offset = gl_pack_find_offset($idx_path, $sha);
        if ($offset === false) continue;
        return gl_pack_read_at($pack_path, $offset);
    }
    return false;
}

// ─────────────────────────────────────────────────────────────
// 4. REFS
// ─────────────────────────────────────────────────────────────

/**
 * Read a ref (e.g. 'refs/heads/main'). Returns SHA or false.
 */
function gl_read_ref(string $repo, string $ref): string|false
{
    // Follow symbolic refs
    $sym = $repo . '/' . $ref;
    if (is_file($sym)) {
        $content = trim(file_get_contents($sym));
        if (str_starts_with($content, 'ref: ')) {
            return gl_read_ref($repo, substr($content, 5));
        }
        return strlen($content) === 40 ? $content : false;
    }
    // packed-refs
    $packed = $repo . '/packed-refs';
    if (is_file($packed)) {
        foreach (file($packed) as $line) {
            $line = trim($line);
            if (str_starts_with($line, '#')) continue;
            [$sha, $name] = explode(' ', $line, 2) + [1 => ''];
            if ($name === $ref) return $sha;
        }
    }
    return false;
}

/**
 * Write a ref atomically.
 */
function gl_write_ref(string $repo, string $ref, string $sha): bool
{
    $path = $repo . '/' . $ref;
    $dir  = dirname($path);
    if (!is_dir($dir)) mkdir($dir, 0755, true);
    $tmp  = $path . '.lock';
    if (file_put_contents($tmp, $sha . "\n") === false) return false;
    return rename($tmp, $path);
}

/**
 * Return the current HEAD SHA (follows symbolic ref).
 */
function gl_head_sha(string $repo): string|false
{
    return gl_read_ref($repo, 'HEAD');
}

/**
 * Return the default branch name from HEAD symbolic ref.
 */
function gl_default_branch(string $repo): string
{
    $head = $repo . '/HEAD';
    if (is_file($head)) {
        $content = trim(file_get_contents($head));
        if (str_starts_with($content, 'ref: refs/heads/')) {
            return substr($content, strlen('ref: refs/heads/'));
        }
    }
    return 'main';
}

/**
 * List all branches. Returns ['branch'=>'sha', ...]
 */
function gl_list_branches(string $repo): array
{
    $result = [];
    $heads  = $repo . '/refs/heads';
    if (is_dir($heads)) {
        $iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($heads, FilesystemIterator::SKIP_DOTS));
        foreach ($iter as $file) {
            if ($file->isFile()) {
                $sha = trim(file_get_contents($file->getPathname()));
                $rel = ltrim(str_replace($heads, '', $file->getPathname()), '/');
                $result[$rel] = $sha;
            }
        }
    }
    // packed-refs
    $packed = $repo . '/packed-refs';
    if (is_file($packed)) {
        foreach (file($packed) as $line) {
            $line = trim($line);
            if ($line === '' || $line[0] === '#' || $line[0] === '^') continue;
            [$sha, $name] = explode(' ', $line, 2) + [1 => ''];
            if (str_starts_with($name, 'refs/heads/')) {
                $branch = substr($name, strlen('refs/heads/'));
                if (!isset($result[$branch])) $result[$branch] = $sha;
            }
        }
    }
    return $result;
}

// ─────────────────────────────────────────────────────────────
// 5. OBJECT PARSERS
// ─────────────────────────────────────────────────────────────

/**
 * Parse a commit object. Returns associative array.
 */
function gl_parse_commit(string $data): array
{
    $lines  = explode("\n", $data);
    $commit = ['tree'=>'','parents'=>[],'author'=>'','committer'=>'','message'=>''];
    $in_msg = false;
    $msg    = [];
    foreach ($lines as $line) {
        if ($in_msg) { $msg[] = $line; continue; }
        if ($line === '') { $in_msg = true; continue; }
        [$key, $val] = explode(' ', $line, 2) + [1 => ''];
        if ($key === 'tree')      $commit['tree'] = $val;
        elseif ($key === 'parent') $commit['parents'][] = $val;
        elseif ($key === 'author')     $commit['author'] = $val;
        elseif ($key === 'committer')  $commit['committer'] = $val;
    }
    $commit['message'] = implode("\n", $msg);
    return $commit;
}

/**
 * Parse a tree object. Returns array of ['mode','name','sha'] entries.
 */
function gl_parse_tree(string $data): array
{
    $entries = [];
    $pos     = 0;
    $len     = strlen($data);
    while ($pos < $len) {
        $sp = strpos($data, ' ', $pos);
        if ($sp === false) break;
        $mode = substr($data, $pos, $sp - $pos);
        $nul  = strpos($data, "\0", $sp + 1);
        if ($nul === false) break;
        $name = substr($data, $sp + 1, $nul - $sp - 1);
        $sha  = bin2hex(substr($data, $nul + 1, 20));
        $entries[] = ['mode' => $mode, 'name' => $name, 'sha' => $sha];
        $pos = $nul + 21;
    }
    return $entries;
}

// ─────────────────────────────────────────────────────────────
// 6. OBJECT BUILDERS
// ─────────────────────────────────────────────────────────────

/**
 * Build and write a blob. Returns SHA.
 */
function gl_write_blob(string $repo, string $content): string
{
    return gl_write_loose($repo, 'blob', $content);
}

/**
 * Build and write a tree from entries array [['mode','name','sha'], ...].
 * Entries are sorted per git spec (dirs get '/' appended for sort key).
 * Returns SHA.
 */
function gl_write_tree(string $repo, array $entries): string
{
    usort($entries, function($a, $b) {
        $ka = ($a['mode'] === '40000' || str_starts_with($a['mode'], '04')) ? $a['name'] . '/' : $a['name'];
        $kb = ($b['mode'] === '40000' || str_starts_with($b['mode'], '04')) ? $b['name'] . '/' : $b['name'];
        return strcmp($ka, $kb);
    });
    $data = '';
    foreach ($entries as $e) {
        $data .= $e['mode'] . ' ' . $e['name'] . "\0" . hex2bin($e['sha']);
    }
    return gl_write_loose($repo, 'tree', $data);
}

/**
 * Build and write a commit object. Returns SHA.
 */
function gl_write_commit(string $repo, string $tree_sha, array $parents,
                          string $message, string $author, string $committer): string
{
    $ts = time();
    $tz = '+0000';
    $author_line    = $author    ? $author    : "GitGram <gitgram@localhost> $ts $tz";
    $committer_line = $committer ? $committer : $author_line;

    $data = "tree $tree_sha\n";
    foreach ($parents as $p) $data .= "parent $p\n";
    $data .= "author $author_line\n";
    $data .= "committer $committer_line\n";
    $data .= "\n$message";
    return gl_write_loose($repo, 'commit', $data);
}

// ─────────────────────────────────────────────────────────────
// 7. HIGH-LEVEL READS
// ─────────────────────────────────────────────────────────────

/**
 * Resolve a path within a tree SHA. Returns ['type','sha'] or false.
 */
function gl_resolve_path(string $repo, string $tree_sha, string $path): array|false
{
    $parts = array_filter(explode('/', $path), 'strlen');
    $sha   = $tree_sha;
    foreach ($parts as $part) {
        $obj = gl_read_object($repo, $sha);
        if (!$obj || $obj['type'] !== 'tree') return false;
        $entries = gl_parse_tree($obj['data']);
        $found   = false;
        foreach ($entries as $e) {
            if ($e['name'] === $part) { $sha = $e['sha']; $found = true; break; }
        }
        if (!$found) return false;
    }
    $obj = gl_read_object($repo, $sha);
    if (!$obj) return false;
    return ['type' => $obj['type'], 'sha' => $sha];
}

/**
 * List a tree: returns array of ['mode','name','sha','type'] at $path.
 */
function gl_ls_tree(string $repo, string $ref, string $path = ''): array|false
{
    $head_sha = gl_read_ref($repo, $ref);
    if (!$head_sha) return false;
    $commit_obj = gl_read_object($repo, $head_sha);
    if (!$commit_obj || $commit_obj['type'] !== 'commit') return false;
    $commit  = gl_parse_commit($commit_obj['data']);
    $tree_sha = $commit['tree'];

    if ($path !== '') {
        $res = gl_resolve_path($repo, $tree_sha, $path);
        if (!$res || $res['type'] !== 'tree') return false;
        $tree_sha = $res['sha'];
    }

    $tree_obj = gl_read_object($repo, $tree_sha);
    if (!$tree_obj) return false;
    $entries  = gl_parse_tree($tree_obj['data']);
    foreach ($entries as &$e) {
        $e['type'] = ($e['mode'] === '40000' || $e['mode'] === '040000') ? 'tree' : 'blob';
    }
    return $entries;
}

/**
 * Read a file's content from a ref + path.
 */
function gl_cat_file(string $repo, string $ref, string $path): string|false
{
    $head_sha = gl_read_ref($repo, $ref);
    if (!$head_sha) return false;
    $commit_obj = gl_read_object($repo, $head_sha);
    if (!$commit_obj) return false;
    $commit  = gl_parse_commit($commit_obj['data']);
    $res     = gl_resolve_path($repo, $commit['tree'], $path);
    if (!$res || $res['type'] !== 'blob') return false;
    $blob = gl_read_object($repo, $res['sha']);
    return $blob ? $blob['data'] : false;
}

/**
 * Get the last commit info on a branch ref.
 * Returns ['sha','message','author','time'] or false.
 */
function gl_last_commit(string $repo, string $ref = 'HEAD'): array|false
{
    $sha = gl_read_ref($repo, $ref);
    if (!$sha) return false;
    $obj = gl_read_object($repo, $sha);
    if (!$obj || $obj['type'] !== 'commit') return false;
    $c = gl_parse_commit($obj['data']);
    // Extract timestamp from committer line "Name <email> timestamp tz"
    preg_match('/(\d+) [+\-]\d{4}$/', $c['committer'], $m);
    return ['sha' => $sha, 'message' => trim($c['message']),
            'author' => $c['author'], 'time' => isset($m[1]) ? (int)$m[1] : 0];
}

/**
 * Return commit log as array of ['sha','message','author','time',...].
 * $limit: max entries (0 = all).
 */
function gl_log(string $repo, string $ref = 'HEAD', int $limit = 50): array
{
    $sha    = gl_read_ref($repo, $ref);
    $result = [];
    $seen   = [];
    while ($sha && (!$limit || count($result) < $limit)) {
        if (isset($seen[$sha])) break;
        $seen[$sha] = true;
        $obj = gl_read_object($repo, $sha);
        if (!$obj || $obj['type'] !== 'commit') break;
        $c = gl_parse_commit($obj['data']);
        preg_match('/(\d+) [+\-]\d{4}$/', $c['committer'], $m);
        $result[] = [
            'sha'     => $sha,
            'message' => trim($c['message']),
            'author'  => $c['author'],
            'committer' => $c['committer'],
            'tree'    => $c['tree'],
            'parents' => $c['parents'],
            'time'    => isset($m[1]) ? (int)$m[1] : 0,
        ];
        $sha = $c['parents'][0] ?? null;
    }
    return $result;
}

// ─────────────────────────────────────────────────────────────
// 8. HIGH-LEVEL WRITE: gl_write_files
// ─────────────────────────────────────────────────────────────

/**
 * Recursively build/modify a tree, inserting $files (path=>content).
 * $base_tree_sha: existing tree SHA to merge into ('' for empty).
 * Returns new tree SHA.
 */
function gl_build_tree_with_files(string $repo, string $base_tree_sha, array $files): string
{
    // Load existing entries
    $entries = [];
    if ($base_tree_sha !== '') {
        $obj = gl_read_object($repo, $base_tree_sha);
        if ($obj && $obj['type'] === 'tree') {
            foreach (gl_parse_tree($obj['data']) as $e) {
                $entries[$e['name']] = $e;
            }
        }
    }

    // Separate files into root-level and sub-directory groups
    $root   = []; // name => content (null = delete)
    $subdirs = []; // dirname => [relative_path => content]
    foreach ($files as $path => $content) {
        $slash = strpos($path, '/');
        if ($slash === false) {
            $root[$path] = $content;
        } else {
            $dir  = substr($path, 0, $slash);
            $rest = substr($path, $slash + 1);
            $subdirs[$dir][$rest] = $content;
        }
    }

    // Apply root files
    foreach ($root as $name => $content) {
        if ($content === null) {
            unset($entries[$name]);
        } else {
            $blob_sha = gl_write_blob($repo, $content);
            $entries[$name] = ['mode' => '100644', 'name' => $name, 'sha' => $blob_sha];
        }
    }

    // Apply subdirectory modifications
    foreach ($subdirs as $dir => $sub_files) {
        $base = isset($entries[$dir]) ? $entries[$dir]['sha'] : '';
        $new_tree = gl_build_tree_with_files($repo, $base, $sub_files);
        $entries[$dir] = ['mode' => '40000', 'name' => $dir, 'sha' => $new_tree];
    }

    return gl_write_tree($repo, array_values($entries));
}

/**
 * Write files to a branch and create a commit.
 *
 * $files: associative array of relative_path => string_content
 * Returns ['ok'=>bool, 'sha'=>string (commit sha), 'error'=>string]
 */
function gl_write_files(string $repo, string $branch, array $files,
                         string $message, string $author_name, string $author_email): array
{
    $ref = "refs/heads/$branch";
    $parent_sha = gl_read_ref($repo, $ref);

    // Get existing root tree
    $base_tree = '';
    $parents   = [];
    if ($parent_sha) {
        $commit_obj = gl_read_object($repo, $parent_sha);
        if ($commit_obj && $commit_obj['type'] === 'commit') {
            $c = gl_parse_commit($commit_obj['data']);
            $base_tree = $c['tree'];
            $parents   = [$parent_sha];
        }
    }

    $new_tree = gl_build_tree_with_files($repo, $base_tree, $files);

    $ts      = time();
    $tz      = '+0000';
    $author  = "$author_name <$author_email> $ts $tz";
    $new_sha = gl_write_commit($repo, $new_tree, $parents, $message, $author, $author);

    // If first commit, also set HEAD if pointing to this branch
    $head_file = $repo . '/HEAD';
    if (is_file($head_file)) {
        $head_content = trim(file_get_contents($head_file));
        if ($head_content === "ref: $ref" || $head_content === '') {
            // OK — HEAD will resolve through the ref we're about to write
        }
    } else {
        // Bare repo: create HEAD
        file_put_contents($head_file, "ref: $ref\n");
    }

    $ok = gl_write_ref($repo, $ref, $new_sha);
    if (!$ok) return ['ok' => false, 'sha' => '', 'error' => 'Failed to update ref'];
    return ['ok' => true, 'sha' => $new_sha, 'error' => ''];
}

/**
 * Initialise a bare git repository (no shell needed).
 */
function gl_init_bare(string $repo): bool
{
    $dirs = ['', '/objects', '/objects/info', '/objects/pack',
             '/refs', '/refs/heads', '/refs/tags'];
    foreach ($dirs as $d) {
        if (!is_dir($repo . $d) && !mkdir($repo . $d, 0755, true)) return false;
    }
    if (!is_file($repo . '/HEAD'))
        file_put_contents($repo . '/HEAD', "ref: refs/heads/main\n");
    if (!is_file($repo . '/config'))
        file_put_contents($repo . '/config', "[core]\n\trepositoryformatversion = 0\n\tfilemode = false\n\tbare = true\n");
    if (!is_file($repo . '/description'))
        file_put_contents($repo . '/description', "Unnamed repository\n");
    return true;
}

/**
 * Fork a bare repo by copying all objects and refs to a new location.
 * No shell required — pure filesystem copy.
 * Returns true on success.
 */
function gl_fork_repo(string $src_repo, string $dst_repo): bool
{
    if (!gl_init_bare($dst_repo)) return false;

    // Recursive directory copy helper (defined inline via closure)
    $copy_dir = null;
    $copy_dir = function(string $src, string $dst) use (&$copy_dir): bool {
        if (!is_dir($dst) && !mkdir($dst, 0755, true)) return false;
        $iter = new DirectoryIterator($src);
        foreach ($iter as $entry) {
            if ($entry->isDot()) continue;
            $s = $entry->getPathname();
            $d = $dst . '/' . $entry->getFilename();
            if ($entry->isDir()) {
                if (!$copy_dir($s, $d)) return false;
            } else {
                if (!copy($s, $d)) return false;
            }
        }
        return true;
    };

    // Copy the full objects directory (loose objects + pack files)
    if (!$copy_dir($src_repo . '/objects', $dst_repo . '/objects')) return false;

    // Copy all loose refs
    if (is_dir($src_repo . '/refs')) {
        $copy_dir($src_repo . '/refs', $dst_repo . '/refs');
    }

    // Copy packed-refs if present
    if (is_file($src_repo . '/packed-refs')) {
        copy($src_repo . '/packed-refs', $dst_repo . '/packed-refs');
    }

    // Copy HEAD to preserve default branch
    if (is_file($src_repo . '/HEAD')) {
        copy($src_repo . '/HEAD', $dst_repo . '/HEAD');
    }

    return true;
}

// ─────────────────────────────────────────────────────────────
// 9. PACK BUILDER (for upload-pack responses)
// ─────────────────────────────────────────────────────────────

/**
 * Collect all objects reachable from $sha (commit + tree + blob walk).
 * Returns set of SHAs (array keys).
 */
function gl_collect_objects(string $repo, string $sha, array &$seen = []): void
{
    if (isset($seen[$sha])) return;
    $obj = gl_read_object($repo, $sha);
    if (!$obj) return;
    $seen[$sha] = $obj;

    if ($obj['type'] === 'commit') {
        $c = gl_parse_commit($obj['data']);
        gl_collect_objects($repo, $c['tree'], $seen);
        foreach ($c['parents'] as $p) gl_collect_objects($repo, $p, $seen);
    } elseif ($obj['type'] === 'tree') {
        foreach (gl_parse_tree($obj['data']) as $e) {
            gl_collect_objects($repo, $e['sha'], $seen);
        }
    }
}

/**
 * Build a PACK byte string for a set of objects (no deltas — simple/safe).
 */
function gl_build_pack(array $objects): string
{
    $type_codes = ['commit'=>1,'tree'=>2,'blob'=>3,'tag'=>4];
    $entries    = '';
    $count      = 0;

    foreach ($objects as $sha => $obj) {
        if (!isset($type_codes[$obj['type']])) continue;
        $type = $type_codes[$obj['type']];
        $data = $obj['data'];
        $size = strlen($data);

        // Variable-length size header
        $first = ($type << 4) | ($size & 0x0f);
        $size >>= 4;
        $header = '';
        while ($size > 0) {
            $first |= 0x80;
            $header .= chr($first);
            $first  = $size & 0x7f;
            $size >>= 7;
        }
        $header .= chr($first);
        $entries .= $header . gzcompress($data, 1);
        $count++;
    }

    $pack  = "PACK" . pack('N', 2) . pack('N', $count) . $entries;
    $pack .= hex2bin(sha1($pack));
    return $pack;
}

// ─────────────────────────────────────────────────────────────
// 10. SMART HTTP PROTOCOL
// ─────────────────────────────────────────────────────────────

/** Encode one pktline frame. */
function gl_pkt_line(string $data): string
{
    $len = strlen($data) + 4;
    return sprintf('%04x', $len) . $data;
}

/** Flush packet. */
function gl_pkt_flush(): string { return '0000'; }

/** Parse pktlines from a string. Returns array of data strings (flush = null). */
function gl_parse_pktlines(string $input): array
{
    $lines = [];
    $pos   = 0;
    $len   = strlen($input);
    while ($pos < $len) {
        $hex  = substr($input, $pos, 4);
        $size = hexdec($hex);
        if ($size === 0) { $lines[] = null; $pos += 4; continue; }
        if ($size < 4)   { break; }
        $lines[] = substr($input, $pos + 4, $size - 4);
        $pos += $size;
    }
    return $lines;
}

/**
 * Handle GET /info/refs?service=git-upload-pack or git-receive-pack
 * Outputs headers + body. Call instead of git-http-backend.
 */
function gl_http_info_refs(string $repo, string $service): void
{
    header('Content-Type: application/x-' . $service . '-advertisement');
    header('Cache-Control: no-cache');

    $body  = gl_pkt_line("# service=$service\n") . gl_pkt_flush();

    if ($service === 'git-upload-pack') {
        $body .= gl_build_refs_advertisement($repo, 'upload-pack');
    } else {
        $body .= gl_build_refs_advertisement($repo, 'receive-pack');
    }
    echo $body;
}

/**
 * Build the refs advertisement packet list.
 */
function gl_build_refs_advertisement(string $repo, string $cap_type): string
{
    $branches = gl_list_branches($repo);
    $tags     = gl_list_tags($repo);
    $all_refs = [];
    foreach ($branches as $b => $sha) $all_refs["refs/heads/$b"] = $sha;
    foreach ($tags    as $t => $sha) $all_refs["refs/tags/$t"]   = $sha;

    // Read HEAD symref target (e.g. "refs/heads/master")
    $head_file    = $repo . '/HEAD';
    $head_content = is_file($head_file) ? trim(file_get_contents($head_file)) : '';
    $head_target  = '';
    if (str_starts_with($head_content, 'ref: ')) {
        $head_target = substr($head_content, 5); // e.g. "refs/heads/master"
    }

    if (empty($all_refs)) {
        // Empty repo: advertise capability line with null sha
        $caps = ($cap_type === 'receive-pack')
            ? " side-band-64k delete-refs report-status" : "";
        if ($head_target) $caps .= " symref=HEAD:$head_target";
        return gl_pkt_line("0000000000000000000000000000000000000000 capabilities^{}\0" . $caps . "\n")
             . gl_pkt_flush();
    }

    // Capabilities — include symref so clients know which branch HEAD tracks
    $caps = "side-band-64k ofs-delta";
    if ($cap_type === 'receive-pack') $caps .= " report-status delete-refs";
    if ($head_target) $caps .= " symref=HEAD:$head_target";

    $out = '';

    // HEAD must be the very first line in the advertisement.
    // Resolve it: prefer the symref target SHA, fall back to any branch.
    $head_sha = ($head_target && isset($all_refs[$head_target]))
        ? $all_refs[$head_target]
        : reset($all_refs);  // first available branch as fallback

    $out .= gl_pkt_line($head_sha . " HEAD\0" . $caps . "\n");

    // All named refs follow (no capabilities on subsequent lines)
    foreach ($all_refs as $name => $sha) {
        $out .= gl_pkt_line($sha . ' ' . $name . "\n");
    }
    $out .= gl_pkt_flush();
    return $out;
}

/**
 * List tags. Returns ['tag_name' => 'sha']
 */
function gl_list_tags(string $repo): array
{
    $result = [];
    $tags   = $repo . '/refs/tags';
    if (is_dir($tags)) {
        $iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tags, FilesystemIterator::SKIP_DOTS));
        foreach ($iter as $file) {
            if ($file->isFile()) {
                $sha = trim(file_get_contents($file->getPathname()));
                $rel = ltrim(str_replace($tags, '', $file->getPathname()), '/');
                $result[$rel] = $sha;
            }
        }
    }
    $packed = $repo . '/packed-refs';
    if (is_file($packed)) {
        foreach (file($packed) as $line) {
            $line = trim($line);
            if ($line === '' || $line[0] === '#' || $line[0] === '^') continue;
            [$sha, $name] = explode(' ', $line, 2) + [1 => ''];
            if (str_starts_with($name, 'refs/tags/')) {
                $tag = substr($name, strlen('refs/tags/'));
                if (!isset($result[$tag])) $result[$tag] = $sha;
            }
        }
    }
    return $result;
}

/**
 * Handle POST /git-upload-pack (git fetch/clone).
 */
function gl_http_upload_pack(string $repo): void
{
    header('Content-Type: application/x-git-upload-pack-result');
    header('Cache-Control: no-cache');

    $body = file_get_contents('php://input');
    $lines = gl_parse_pktlines($body);

    $wants = [];
    $haves = [];
    foreach ($lines as $line) {
        if ($line === null) continue;
        $line = rtrim($line, "\n");
        if (str_starts_with($line, 'want ')) $wants[] = substr($line, 5, 40);
        if (str_starts_with($line, 'have ')) $haves[] = substr($line, 5, 40);
    }

    // Collect all objects from wanted commits
    $objects = [];
    foreach ($wants as $sha) {
        gl_collect_objects($repo, $sha, $objects);
    }
    // Remove objects already held by client (haves)
    foreach ($haves as $sha) {
        unset($objects[$sha]);
        // Ideally walk haves' reachable objects too — keep it simple for now
    }

    $pack = gl_build_pack($objects);

    // NAK if we got haves, then packfile
    if (!empty($haves)) echo gl_pkt_line("NAK\n");
    else                 echo gl_pkt_line("NAK\n");

    // Side-band-64k: band 1 = packfile data, band 2 = progress, band 3 = error
    $chunk_size = 65515; // 64k minus sideband byte
    for ($i = 0; $i < strlen($pack); $i += $chunk_size) {
        $chunk = substr($pack, $i, $chunk_size);
        echo gl_pkt_line("\x01" . $chunk);
    }
    echo gl_pkt_flush();
}

/**
 * Handle POST /git-receive-pack (git push).
 */
function gl_http_receive_pack(string $repo): void
{
    header('Content-Type: application/x-git-receive-pack-result');
    header('Cache-Control: no-cache');

    $body = file_get_contents('php://input');
    $pos  = 0;
    $len  = strlen($body);

    // Parse ref update commands
    $updates = [];
    while ($pos < $len) {
        $hex  = substr($body, $pos, 4);
        $size = hexdec($hex);
        if ($size === 0) { $pos += 4; break; } // flush
        if ($size < 4)   break;
        $line = substr($body, $pos + 4, $size - 4);
        $pos += $size;
        $line = rtrim($line, "\n");
        // Strip capabilities after NUL on first line
        if (($nul = strpos($line, "\0")) !== false) $line = substr($line, 0, $nul);
        if (preg_match('/^([0-9a-f]{40}) ([0-9a-f]{40}) (.+)$/', $line, $m)) {
            $updates[] = ['old' => $m[1], 'new' => $m[2], 'ref' => $m[3]];
        }
    }

    // The rest of the body is a PACK file
    $pack_data = substr($body, $pos);

    // Unpack objects from the PACK
    $errors = [];
    if (strlen($pack_data) > 8) {
        $result = gl_unpack($repo, $pack_data);
        if (!$result['ok']) {
            echo gl_pkt_line("unpack " . $result['error'] . "\n");
            foreach ($updates as $u) echo gl_pkt_line("ng {$u['ref']} unpack failed\n");
            echo gl_pkt_flush();
            return;
        }
    }
    echo gl_pkt_line("unpack ok\n");

    // Apply ref updates
    $null40       = str_repeat('0', 40);
    $first_pushed = null; // track first branch ref successfully written
    foreach ($updates as $u) {
        if ($u['new'] === $null40) {
            // Delete ref
            $ref_path = $repo . '/' . $u['ref'];
            if (is_file($ref_path)) unlink($ref_path);
            echo gl_pkt_line("ok {$u['ref']}\n");
        } else {
            if (gl_write_ref($repo, $u['ref'], $u['new'])) {
                echo gl_pkt_line("ok {$u['ref']}\n");
                if ($first_pushed === null && str_starts_with($u['ref'], 'refs/heads/')) {
                    $first_pushed = $u['ref'];
                }
            } else {
                echo gl_pkt_line("ng {$u['ref']} failed to update\n");
            }
        }
    }
    echo gl_pkt_flush();

    // Fix HEAD if it still points to a branch that doesn't exist.
    // This happens when a repo is initialised with HEAD→main but the first
    // push comes from a client whose default branch is "master" (or anything else).
    if ($first_pushed !== null) {
        $head_file = $repo . '/HEAD';
        $head_content = is_file($head_file) ? trim(file_get_contents($head_file)) : '';
        if (str_starts_with($head_content, 'ref: ')) {
            $current_target = substr($head_content, 5);
            if (!gl_read_ref($repo, $current_target)) {
                // HEAD's target branch still doesn't exist — repoint HEAD at the
                // branch that was just pushed
                file_put_contents($head_file, 'ref: ' . $first_pushed . "\n");
            }
        }
    }
}

/**
 * Unpack a PACK stream into the loose object store.
 * Returns ['ok'=>bool, 'error'=>string].
 */
function gl_unpack(string $repo, string $pack_data): array
{
    if (substr($pack_data, 0, 4) !== 'PACK')
        return ['ok' => false, 'error' => 'not a PACK file'];

    $version = unpack('N', substr($pack_data, 4, 4))[1];
    if ($version !== 2 && $version !== 3)
        return ['ok' => false, 'error' => "unsupported PACK version $version"];

    $obj_count = unpack('N', substr($pack_data, 8, 4))[1];
    $pos       = 12;
    $len       = strlen($pack_data);

    // Write pack to a temp file so we can seek (needed for OFS_DELTA)
    $tmp = tempnam(sys_get_temp_dir(), 'gitpack');
    file_put_contents($tmp, $pack_data);

    $type_names = [1=>'commit',2=>'tree',3=>'blob',4=>'tag'];
    $ofs_cache  = []; // offset => ['type','data']

    for ($i = 0; $i < $obj_count; $i++) {
        $obj_start = $pos;
        $byte  = ord($pack_data[$pos++]);
        $type  = ($byte >> 4) & 0x07;
        $size  = $byte & 0x0f;
        $shift = 4;
        while ($byte & 0x80) {
            $byte   = ord($pack_data[$pos++]);
            $size  |= ($byte & 0x7f) << $shift;
            $shift += 7;
        }

        if ($type === 6) { // OFS_DELTA
            $byte = ord($pack_data[$pos++]);
            $neg  = $byte & 0x7f;
            while ($byte & 0x80) {
                $byte = ord($pack_data[$pos++]);
                $neg  = (($neg + 1) << 7) | ($byte & 0x7f);
            }
            $base_offset = $obj_start - $neg;
            [$delta_data, $consumed] = gl_inflate_string(substr($pack_data, $pos));
            $pos += $consumed;
            if (!isset($ofs_cache[$base_offset]))
                return ['ok' => false, 'error' => "ofs_delta base $base_offset not cached"];
            $base = $ofs_cache[$base_offset];
            $obj_data = gl_apply_delta($base['data'], $delta_data);
            $obj_type = $base['type'];
        } elseif ($type === 7) { // REF_DELTA
            $base_sha   = bin2hex(substr($pack_data, $pos, 20)); $pos += 20;
            [$delta_data, $consumed] = gl_inflate_string(substr($pack_data, $pos));
            $pos += $consumed;
            $base = gl_read_object($repo, $base_sha);
            if (!$base) return ['ok' => false, 'error' => "ref_delta base $base_sha not found"];
            $obj_data = gl_apply_delta($base['data'], $delta_data);
            $obj_type = $base['type'];
        } else {
            [$obj_data, $consumed] = gl_inflate_string(substr($pack_data, $pos));
            $pos += $consumed;
            $obj_type = $type_names[$type] ?? 'unknown';
        }

        $ofs_cache[$obj_start] = ['type' => $obj_type, 'data' => $obj_data];
        gl_write_loose($repo, $obj_type, $obj_data);
    }

    unlink($tmp);
    return ['ok' => true, 'error' => ''];
}

/**
 * Inflate a zlib-deflate stream from a string.
 * Returns [inflated_data, bytes_consumed_from_input].
 */
function gl_inflate_string(string $input): array
{
    $ctx = inflate_init(ZLIB_ENCODING_DEFLATE);
    $out = '';
    $pos = 0;
    $len = strlen($input);
    // Feed byte-by-byte until STREAM_END to know exact consumed length
    // For efficiency, feed in small chunks and check status
    $chunk = 256;
    while ($pos < $len) {
        $take   = min($chunk, $len - $pos);
        $status = inflate_add($ctx, substr($input, $pos, $take));
        $pos   += $take;
        if ($status !== false) $out .= $status;
        if (inflate_get_status($ctx) === ZLIB_STREAM_END) break;
    }
    return [$out, $pos];
}
Ready
GitGram