<?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\version_control\GitReceivePack;
use seekquarry\yioop\library\version_control\GitRepository;
use seekquarry\yioop\library\version_control\GitUploadPack;
use seekquarry\yioop\library\UnitTest;
/**
* GitReceivePackTest checks that GitReceivePack (the library class that
* stores what a git client pushes) takes in a push over the smart side of
* Git's HTTP protocol. The test set-up builds a source
* repository by hand, writing loose objects the way git stores them: two
* commits, the second changing a long file a little, so the pack holds an
* object stored as its differences from another. GitUploadPack packs the
* source's history, as a pushing client would, and the cases hand that
* pack to GitReceivePack for an empty target repository.
*
* @author Chris Pollett
*/
class GitReceivePackTest extends UnitTest
{
/**
* LONG_FILE_LINES how many lines the long test file has, enough that
* its second version is packed as differences from its first
*/
const LONG_FILE_LINES = 400;
/**
* $source_path the folder of the repository the push comes from
* @var string
*/
public $source_path;
/**
* $target_path the folder of the repository the push goes to
* @var string
*/
public $target_path;
/**
* $commits the object names of the source's two commits, oldest first
* @var array
*/
public $commits;
/**
* $target reads the empty bare repository each case pushes into, so a
* case can check its refs and objects afterward
* @var GitRepository
*/
public $target;
/**
* setUp builds the source repository, two commits of a long file and
* a short one, and an empty bare target repository with initBare.
*/
public function setUp()
{
$this->source_path = C\TEST_DIR .
"/test_files/test_git_receive_source.git";
$this->target_path = C\TEST_DIR .
"/test_files/test_git_receive_target.git";
$this->tearDown();
mkdir($this->source_path . "/objects", 0777, true);
mkdir($this->source_path . "/refs/heads", 0777, true);
$long_text = "";
for ($i = 0; $i < self::LONG_FILE_LINES; $i++) {
$long_text .= "line $i of a file long enough to be a delta\n";
}
$this->commits = [];
$parents = [];
foreach ([$long_text, $long_text . "one more line\n"] as $number =>
$text) {
$tree = $this->writeObject("tree", "100644 long.txt\0" .
hex2bin($this->writeObject("blob", $text)) .
"100644 short.txt\0" .
hex2bin($this->writeObject("blob", "version $number\n")));
$body = "tree $tree\n";
foreach ($parents as $parent) {
$body .= "parent $parent\n";
}
$who = "Test Person <test@example.com> 1700000000 +0000";
$body .= "author $who\ncommitter $who\n\ncommit $number\n";
$commit = $this->writeObject("commit", $body);
$this->commits[] = $commit;
$parents = [$commit];
}
file_put_contents($this->source_path . "/HEAD",
"ref: refs/heads/main\n");
file_put_contents($this->source_path . "/refs/heads/main",
$this->commits[1] . "\n");
mkdir($this->target_path);
$this->target = new GitRepository($this->target_path);
$this->target->initBare("main");
}
/**
* tearDown deletes both test repositories.
*/
public function tearDown()
{
foreach ([$this->source_path, $this->target_path] as $path) {
if (is_dir($path)) {
$this->deleteTree($path);
}
}
}
/**
* deleteTree deletes a folder and everything in it.
*
* @param string $path the 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);
}
/**
* writeObject stores one object in the source repository as git's
* loose object file: the header "<type> <length>", a NUL byte and the
* body, zlib compressed, named by the SHA-1 of what is compressed.
*
* @param string $type commit, tree or blob
* @param string $body the object's content
* @return string the object's name
*/
private function writeObject($type, $body)
{
$store = $type . " " . strlen($body) . "\0" . $body;
$name = sha1($store);
$folder = $this->source_path . "/objects/" . substr($name, 0, 2);
if (!is_dir($folder)) {
mkdir($folder, 0777, true);
}
file_put_contents($folder . "/" . substr($name, 2),
gzcompress($store));
return $name;
}
/**
* sourcePack has GitUploadPack pack the source's whole history, as a
* client builds the pack it pushes, and returns the pack file's bytes.
*
* @return string the pack file
*/
private function sourcePack()
{
$upload_pack = new GitUploadPack(new GitRepository(
$this->source_path));
$reply = "";
foreach ($upload_pack->uploadPackChunks(GitUploadPack::pktLine(
"want " . $this->commits[1] . "\n") . GitUploadPack::FLUSH_PKT .
GitUploadPack::pktLine("done\n")) as $chunk) {
$reply .= $chunk;
}
return substr($reply, strpos($reply, "PACK"));
}
/**
* pushBody builds the body a pushing client posts: one command line,
* with the client's capabilities after a NUL byte, a flush, then the
* pack.
*
* @param string $old the ref's value the client expects
* @param string $new the value the ref should take
* @param string $pack the pack file, or "" for none
* @return string the request body
*/
private function pushBody($old, $new, $pack)
{
return GitUploadPack::pktLine("$old $new refs/heads/main\0" .
"report-status\n") . GitUploadPack::FLUSH_PKT . $pack;
}
/**
* pushIntoEmptyRepositoryTestCase pushes the source's history to the
* empty target. The report says the pack unpacked and the ref moved,
* the target's main branch names the newer commit, and every object,
* including the long file's second version, which the pack holds as
* its differences from the first, reads back from the target.
*/
public function pushIntoEmptyRepositoryTestCase()
{
$receive_pack = new GitReceivePack($this->target);
$this->assertTrue(str_contains($receive_pack->advertiseRefs(),
"capabilities^{}\0report-status"), "an empty repository " .
"advertises its capabilities on a line naming no object");
$report = $receive_pack->receive($this->pushBody(
GitReceivePack::ZERO_NAME, $this->commits[1],
$this->sourcePack()));
$this->assertEqual(GitUploadPack::decodePktLines($report),
["unpack ok", "ok refs/heads/main", false],
"the report says the pack unpacked and the ref moved");
$refs = $this->target->allRefs();
$this->assertEqual($refs["refs/heads/main"] ?? "",
$this->commits[1], "main names the pushed commit");
$tree = $this->target->commit($this->commits[1])["tree"];
$this->assertTrue(str_ends_with($this->target->blob(
$this->target->tree($tree)[0]["sha"] ?? "") ?? "",
"one more line\n"), "the long file's second version reads " .
"back whole");
}
/**
* staleOldValueIsRefusedTestCase pushes with an old value that is not
* what the target's ref holds. The report says to fetch first and the
* ref stays where it was.
*/
public function staleOldValueIsRefusedTestCase()
{
$receive_pack = new GitReceivePack($this->target);
$pack = $this->sourcePack();
$receive_pack->receive($this->pushBody(GitReceivePack::ZERO_NAME,
$this->commits[0], $pack));
$report = $receive_pack->receive($this->pushBody(
GitReceivePack::ZERO_NAME, $this->commits[1], ""));
$this->assertEqual(GitUploadPack::decodePktLines($report),
["unpack ok", "ng refs/heads/main fetch first", false],
"a push from a stale old value is refused");
$this->assertEqual($this->target->allRefs()["refs/heads/main"] ??
"", $this->commits[0], "and the ref did not move");
}
/**
* damagedPackMovesNoRefTestCase pushes a pack with one byte changed.
* Its checksum no longer matches, so the report says it did not
* unpack, the command fails, and the target still has no main branch.
*/
public function damagedPackMovesNoRefTestCase()
{
$pack = $this->sourcePack();
$middle = intdiv(strlen($pack), 2);
$pack[$middle] = chr(ord($pack[$middle]) ^ 1);
$report = (new GitReceivePack($this->target))->receive(
$this->pushBody(GitReceivePack::ZERO_NAME, $this->commits[1],
$pack));
$this->assertEqual(GitUploadPack::decodePktLines($report),
["unpack pack checksum mismatch",
"ng refs/heads/main unpacker error", false],
"a damaged pack is reported and its command fails");
$this->assertTrue(!isset($this->target->allRefs()["refs/heads/main"]),
"and no branch was made");
}
}