<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\GitRepository;
use seekquarry\yioop\library\GitUploadPack;
use seekquarry\yioop\library\UnitTest;
/**
* Checks that GitUploadPack speaks the smart side of Git's HTTP protocol
* correctly: that it frames and unframes pkt-lines, reads a client's upload
* request, advertises the repository's refs, walks history to the requested
* depth (reporting the right shallow boundary), and writes a packfile whose
* header, object count, contents, and trailing checksum are all well-formed.
*
* The test set-up builds a tiny real repository by hand — three commits
* each recording one file — by writing loose objects the way Git stores
* them, so the walk and the pack writer run against a GitRepository reading
* real objects rather than mock-ups.
*
* @author Chris Pollett
*/
class GitUploadPackTest extends UnitTest
{
/**
* Absolute path to the throwaway bare repository the test builds
* @var string
*/
public $repo_path;
/**
* The GitRepository reading the built repository
* @var object
*/
public $repository;
/**
* Object names of the three commits built, oldest first
* @var array
*/
public $commits;
/**
* Object name the built repository's single branch points at
* @var string
*/
public $head_commit;
/**
* Builds a small bare repository on disk with a three-commit history,
* one file added per commit, so the object walk and pack writer have
* real objects to work over.
*/
public function setUp()
{
$this->repo_path = C\WORK_DIRECTORY . "/test_git_upload_pack.git";
$this->removeRepo();
mkdir($this->repo_path . "/objects", 0777, true);
mkdir($this->repo_path . "/refs/heads", 0777, true);
$this->commits = [];
$parents = [];
$prior_tree_entries = [];
for ($i = 1; $i <= 3; $i++) {
$blob_sha = $this->writeObject("blob", "contents of file $i\n");
$prior_tree_entries["file$i.txt"] = $blob_sha;
$tree_sha = $this->writeTree($prior_tree_entries);
$commit_sha = $this->writeCommit($tree_sha, $parents,
"commit number $i");
$this->commits[] = $commit_sha;
$parents = [$commit_sha];
}
$this->head_commit = $this->commits[count($this->commits) - 1];
file_put_contents($this->repo_path . "/HEAD",
"ref: refs/heads/main\n");
file_put_contents($this->repo_path . "/refs/heads/main",
$this->head_commit . "\n");
$this->repository = new GitRepository($this->repo_path);
}
/**
* Removes the throwaway repository the test built.
*/
public function tearDown()
{
$this->removeRepo();
}
/**
* Deletes the test repository folder if it is present.
*/
private function removeRepo()
{
if (is_dir($this->repo_path)) {
$this->deleteTree($this->repo_path);
}
}
/**
* Recursively deletes a folder and everything in it.
*
* @param string $path folder to delete
*/
private function deleteTree($path)
{
foreach (scandir($path) as $name) {
if ($name == "." || $name == "..") {
continue;
}
$child = $path . "/" . $name;
if (is_dir($child)) {
$this->deleteTree($child);
} else {
unlink($child);
}
}
rmdir($path);
}
/**
* Stores a git object the loose way: prepend the type and size header,
* name the object by the checksum of that, and write the compressed
* bytes to the two-level objects folder.
*
* @param string $type the object type word
* @param string $body the object's uncompressed contents
* @return string the object's name
*/
private function writeObject($type, $body)
{
$store = $type . " " . strlen($body) . "\0" . $body;
$sha = sha1($store);
$dir = $this->repo_path . "/objects/" . substr($sha, 0, 2);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents($dir . "/" . substr($sha, 2),
gzcompress($store));
return $sha;
}
/**
* Writes a tree object listing the given files, each a regular file
* pointing at its blob, in the name order git keeps entries in.
*
* @param array $entries map from file name to blob object name
* @return string the tree's object name
*/
private function writeTree($entries)
{
ksort($entries);
$body = "";
foreach ($entries as $name => $blob_sha) {
$body .= "100644 " . $name . "\0" . hex2bin($blob_sha);
}
return $this->writeObject("tree", $body);
}
/**
* Writes a commit object for a tree, with the given parents and message
* and a fixed author and committer, so its object name is stable.
*
* @param string $tree_sha object name of the commit's tree
* @param array $parents object names of the parent commits
* @param string $message the commit message
* @return string the commit's object name
*/
private function writeCommit($tree_sha, $parents, $message)
{
$body = "tree " . $tree_sha . "\n";
foreach ($parents as $parent_sha) {
$body .= "parent " . $parent_sha . "\n";
}
$who = "Test Person <test@example.com> 1700000000 +0000";
$body .= "author " . $who . "\n";
$body .= "committer " . $who . "\n\n" . $message . "\n";
return $this->writeObject("commit", $body);
}
/**
* A pkt-line's length counts its own four length digits, and decoding
* the framing returns the original data with the flush marker shown as
* a break.
*/
public function pktLineRoundTripTestCase()
{
$framed = GitUploadPack::pktLine("hello\n");
$this->assertEqual(substr($framed, 0, 4), "000a",
"pkt-line length counts the four length digits");
$stream = GitUploadPack::pktLine("want a\n") .
GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
$decoded = GitUploadPack::decodePktLines($stream);
$this->assertEqual($decoded[0], "want a",
"first line decodes and loses its newline");
$this->assertTrue($decoded[1] === false,
"flush marker decodes to false");
$this->assertEqual($decoded[2], "done",
"line after the flush decodes");
}
/**
* The upload request parser pulls out the wanted object names and the
* requested depth, and ignores capabilities on the first want line.
*/
public function parseUploadRequestTestCase()
{
$want = str_repeat("a", 40);
$other = str_repeat("b", 40);
$body = GitUploadPack::pktLine("want $want side-band-64k\n") .
GitUploadPack::pktLine("want $other\n") .
GitUploadPack::pktLine("deepen 1\n") .
GitUploadPack::FLUSH_PKT .
GitUploadPack::pktLine("done\n");
$upload_pack = new GitUploadPack($this->repository);
$request = $upload_pack->parseUploadRequest($body);
$this->assertEqual(count($request["wants"]), 2,
"both wanted object names are read");
$this->assertTrue(in_array($want, $request["wants"]),
"the object name from the first want is kept");
$this->assertEqual($request["depth"], 1,
"the requested depth is read");
}
/**
* The advertisement names the service, offers the shallow capability,
* points HEAD at the built branch, and lists the branch ref.
*/
public function advertiseRefsTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$advertisement = $upload_pack->advertiseRefs();
$lines = GitUploadPack::decodePktLines($advertisement);
$this->assertEqual($lines[0], "# service=git-upload-pack",
"advertisement begins by naming the service");
$this->assertTrue(strpos($advertisement, "shallow") !== false,
"advertisement offers the shallow capability");
$this->assertTrue(strpos($advertisement,
"symref=HEAD:refs/heads/main") !== false,
"advertisement points HEAD at the default branch");
$this->assertTrue(strpos($advertisement,
$this->head_commit . " refs/heads/main") !== false,
"advertisement lists the branch at its commit");
}
/**
* A depth-one walk keeps only the newest commit, packs its commit,
* tree, and blobs, and reports that commit as the shallow boundary
* because its parent is left out.
*/
public function walkDepthOneTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$body = GitUploadPack::pktLine("want " . $this->head_commit .
"\n") . GitUploadPack::pktLine("deepen 1\n") .
GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
$reply = $upload_pack->uploadPack($body);
$this->assertTrue(strpos($reply,
"shallow " . $this->head_commit) !== false,
"the newest commit is reported as the shallow boundary");
$this->assertTrue(strpos($reply,
"shallow " . $this->commits[0]) === false,
"an older commit is not in the reply at depth one");
$pack = $this->extractPack($reply);
$count = $this->packObjectCount($pack);
$this->assertEqual($count, 5,
"depth one packs the newest commit, its tree, and its blobs");
}
/**
* A walk with no depth follows history to its start, packing every
* commit, tree, and blob, and reports no shallow boundary.
*/
public function walkFullHistoryTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$body = GitUploadPack::pktLine("want " . $this->head_commit .
"\n") . GitUploadPack::FLUSH_PKT .
GitUploadPack::pktLine("done\n");
$reply = $upload_pack->uploadPack($body);
$this->assertTrue(strpos($reply, "shallow ") === false,
"a full clone reports no shallow boundary");
$pack = $this->extractPack($reply);
$count = $this->packObjectCount($pack);
$this->assertEqual($count, 9,
"full history packs three commits, three trees, three blobs");
}
/**
* A "have" line names an object the client already holds; the parser
* collects those alongside the wants.
*/
public function parseHavesTestCase()
{
$want = str_repeat("a", 40);
$have = str_repeat("b", 40);
$body = GitUploadPack::pktLine("want $want\n") .
GitUploadPack::pktLine("have $have\n") .
GitUploadPack::FLUSH_PKT .
GitUploadPack::pktLine("done\n");
$upload_pack = new GitUploadPack($this->repository);
$request = $upload_pack->parseUploadRequest($body);
$this->assertEqual(count($request["haves"]), 1,
"the have object name is read");
$this->assertTrue(in_array($have, $request["haves"]),
"the have object name is the one the client sent");
}
/**
* When the client says it already has an older commit, the walk stops at
* that commit and everything that commit records — its folders and
* their files — is left out of the pack too, so a fetch past it
* sends only what the client is actually missing.
*/
public function walkStopsAtHaveTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$body = GitUploadPack::pktLine("want " . $this->head_commit .
"\n") . GitUploadPack::pktLine("have " . $this->commits[0] .
"\n") . GitUploadPack::FLUSH_PKT .
GitUploadPack::pktLine("done\n");
$reply = $upload_pack->uploadPack($body);
$pack = $this->extractPack($reply);
$count = $this->packObjectCount($pack);
$this->assertEqual($count, 6,
"a fetch past a have leaves out that commit, its tree, and " .
"the file it added");
}
/**
* A round in which nothing the client offers is recognized is answered
* with a bare "NAK" and no packfile, so the client offers older commits
* next rather than reading pack bytes where a pkt-line should start.
*/
public function negotiationRoundWithoutCommonTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$unknown = str_repeat("c", 40);
$body = GitUploadPack::pktLine("want " . $this->head_commit .
"\n") . GitUploadPack::FLUSH_PKT .
GitUploadPack::pktLine("have " . $unknown . "\n");
$reply = $upload_pack->uploadPack($body);
$this->assertTrue(strpos($reply, "PACK") === false,
"an unsettled round carries no packfile");
$this->assertEqual($reply, GitUploadPack::pktLine("NAK\n"),
"an unsettled round is answered with a bare NAK");
}
/**
* When the client offers a commit the repository also holds, the round
* names it as common and says the server is ready, which is what stops
* a client with a long history offering ever older commits.
*/
public function negotiationRoundWithCommonTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$body = GitUploadPack::pktLine("want " . $this->head_commit .
"\n") . GitUploadPack::FLUSH_PKT .
GitUploadPack::pktLine("have " . $this->commits[0] . "\n");
$reply = $upload_pack->uploadPack($body);
$this->assertTrue(strpos($reply, "PACK") === false,
"a settled round still carries no packfile");
$this->assertTrue(strpos($reply,
"ACK " . $this->commits[0] . " common") !== false,
"the recognized commit is named as held in common");
$this->assertTrue(strpos($reply,
"ACK " . $this->commits[0] . " ready") !== false,
"the round says the server is ready to send");
}
/**
* Once the client says it is done, the reply acknowledges the newest
* commit the two share and the packfile follows it.
*/
public function doneRoundAcknowledgesCommonTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$body = GitUploadPack::pktLine("want " . $this->head_commit .
"\n") . GitUploadPack::pktLine("have " . $this->commits[0] .
"\n") . GitUploadPack::FLUSH_PKT .
GitUploadPack::pktLine("done\n");
$reply = $upload_pack->uploadPack($body);
$this->assertTrue(strncmp($reply,
GitUploadPack::pktLine("ACK " . $this->commits[0] . "\n"),
strlen(GitUploadPack::pktLine("ACK " . $this->commits[0] .
"\n"))) === 0,
"the done round opens with a final acknowledgment");
$this->assertTrue(strpos($reply, "PACK") !== false,
"the packfile follows the acknowledgment");
}
/**
* The packfile a reply carries is well-formed: it starts with the pack
* signature and version two, its stated object count matches what was
* asked for, and its trailing checksum matches the bytes before it.
*/
public function packIsWellFormedTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$body = GitUploadPack::pktLine("want " . $this->head_commit .
"\n") . GitUploadPack::pktLine("deepen 1\n") .
GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
$reply = $upload_pack->uploadPack($body);
$pack = $this->extractPack($reply);
$this->assertEqual(substr($pack, 0, 4), "PACK",
"packfile starts with the pack signature");
$version = unpack("N", substr($pack, 4, 4))[1];
$this->assertEqual($version, 2, "packfile is format version two");
$trailer = substr($pack, -20);
$checked = sha1(substr($pack, 0, strlen($pack) - 20), true);
$this->assertEqual($trailer, $checked,
"packfile checksum matches its contents");
}
/**
* The first object in the pack inflates back to the exact bytes the
* repository holds for it, showing the writer stored it faithfully.
*/
public function packObjectInflatesTestCase()
{
$upload_pack = new GitUploadPack($this->repository);
$body = GitUploadPack::pktLine("want " . $this->head_commit .
"\n") . GitUploadPack::pktLine("deepen 1\n") .
GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
$reply = $upload_pack->uploadPack($body);
$pack = $this->extractPack($reply);
$first = $this->firstPackObject($pack);
$original = $this->repository->readObject($this->head_commit);
$this->assertEqual($first["type"],
GitUploadPack::OBJECT_TYPE_COMMIT,
"first packed object is the wanted commit");
$this->assertEqual($first["body"], $original["body"],
"packed commit inflates to the repository's bytes");
}
/**
* Pulls the packfile out of an upload-pack reply: everything from the
* "PACK" signature onward, after the shallow lines and acknowledgment.
*
* @param string $reply the full upload-pack reply
* @return string the packfile bytes
*/
private function extractPack($reply)
{
$start = strpos($reply, "PACK");
return substr($reply, $start);
}
/**
* Reads the object count out of a packfile's twelve-byte header.
*
* @param string $pack the packfile bytes
* @return int the number of objects the header claims
*/
private function packObjectCount($pack)
{
return unpack("N", substr($pack, 8, 4))[1];
}
/**
* Reads the first object out of a packfile, undoing the type-and-size
* header and inflating the body, so a test can compare it to the source.
*
* @param string $pack the packfile bytes
* @return array ["type" => the pack type code, "body" => the inflated
* object bytes]
*/
private function firstPackObject($pack)
{
$offset = 12;
$first = ord($pack[$offset]);
$type = ($first >> 4) & 0x7;
$offset++;
while ($first & 0x80) {
$first = ord($pack[$offset]);
$offset++;
}
$compressed = substr($pack, $offset,
strlen($pack) - 20 - $offset);
return ["type" => $type, "body" => gzuncompress($compressed)];
}
}