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