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 $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 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]; }