<?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\library as L;
use seekquarry\yioop\library\UnitTest;
/**
* Unit tests for the GitRepository library class, which reads a bare Git
* repository straight from disk. Each test builds a small repository by
* writing Git objects and refs by hand in PHP (so the tests need no git
* program) and then checks the reader returns what was written. The tests
* also cover the safety rule that creating a repository never overwrites
* one that is already there.
*
* @author Chris Pollett
*/
class GitRepositoryTest extends UnitTest
{
/**
* Folder, relative to this test file, holding the repository built for
* a test run
*/
const TEST_DIR = '/test_files/git_repository_test';
/**
* Absolute path of the test repository for the current test
* @var string
*/
public $repo_path;
/**
* Starts each test from an empty, freshly created bare repository.
*/
public function setUp()
{
$this->repo_path = __DIR__ . self::TEST_DIR;
$this->deleteTree($this->repo_path);
$repository = new L\GitRepository($this->repo_path);
$repository->initBare("main");
}
/**
* Removes the test repository created during a test.
*/
public function tearDown()
{
$this->deleteTree($this->repo_path);
}
/**
* Deletes a folder and everything under it, used to clear the test
* repository between tests.
*
* @param string $path folder to remove
*/
public function deleteTree($path)
{
if (!is_dir($path)) {
return;
}
foreach (scandir($path) as $name) {
if ($name == "." || $name == "..") {
continue;
}
$full = $path . "/" . $name;
if (is_dir($full)) {
$this->deleteTree($full);
} else {
unlink($full);
}
}
rmdir($path);
}
/**
* Checks that the push size caps let writes within the limits through
* and turn away writes that break the blob, pack, or repository size
* caps, while treating a cap of zero as no limit at all.
*/
public function withinPushLimitsTestCase()
{
$this->assertTrue(L\GitRepository::withinPushLimits(
5000, 90000, false, 0, 0, 0),
"no caps set lets any write through");
$this->assertTrue(L\GitRepository::withinPushLimits(
100, 0, false, 200, 0, 0),
"object under the blob cap is allowed");
$this->assertFalse(L\GitRepository::withinPushLimits(
300, 0, false, 200, 0, 0),
"object over the blob cap is refused");
$this->assertFalse(L\GitRepository::withinPushLimits(
300, 0, true, 0, 200, 0),
"pack over the push cap is refused");
$this->assertTrue(L\GitRepository::withinPushLimits(
300, 0, false, 0, 200, 0),
"loose object is not bound by the pack push cap");
$this->assertTrue(L\GitRepository::withinPushLimits(
100, 800, false, 0, 0, 1000),
"write that keeps the repository under its cap is allowed");
$this->assertFalse(L\GitRepository::withinPushLimits(
300, 800, false, 0, 0, 1000),
"write that would push the repository over its cap is refused");
}
/**
* A ref file naming another ref rather than an object is passed over,
* so that a repository carrying one can still be listed for a client.
*/
public function allRefsSymbolicTestCase()
{
$repository = new L\GitRepository($this->repo_path);
$object_name = str_repeat("a", 40);
mkdir($this->repo_path . "/refs/heads", 0777, true);
file_put_contents($this->repo_path . "/refs/heads/main",
$object_name . "\n");
mkdir($this->repo_path . "/refs/remotes/origin", 0777, true);
file_put_contents($this->repo_path . "/refs/remotes/origin/HEAD",
"ref: refs/heads/main\n");
$refs = $repository->allRefs();
$this->assertEqual($object_name, $refs["refs/heads/main"] ?? "",
"a ref naming an object is listed with that object's name");
$this->assertTrue(!isset($refs["refs/remotes/origin/HEAD"]),
"a ref naming another ref is left out of the listing");
}
/**
* Checks the folder classifier: an existing bare repository reports as a
* repository, a folder holding nothing but dot files reports as empty,
* and a folder holding other files reports as occupied.
*/
public function folderStateTestCase()
{
$repository = new L\GitRepository($this->repo_path);
$this->assertEqual("repository", $repository->folderState(),
"an existing bare repository reports as a repository");
$empty_path = $this->repo_path . "_empty";
$this->deleteTree($empty_path);
mkdir($empty_path, 0777, true);
file_put_contents($empty_path . "/.DS_Store", "");
$empty = new L\GitRepository($empty_path);
$this->assertEqual("empty", $empty->folderState(),
"a folder holding only dot files reports as empty");
$this->deleteTree($empty_path);
$occupied_path = $this->repo_path . "_occupied";
$this->deleteTree($occupied_path);
mkdir($occupied_path, 0777, true);
file_put_contents($occupied_path . "/notes.txt", "hello");
$occupied = new L\GitRepository($occupied_path);
$this->assertEqual("occupied", $occupied->folderState(),
"a folder holding other files reports as occupied");
$this->deleteTree($occupied_path);
}
/**
* Writes one Git object into the test repository as a loose object,
* the way a freshly pushed object is stored, and returns its object
* name. The object name is the hash of the type, size, and contents,
* and the file is stored compressed exactly as Git stores it.
*
* @param string $type object kind, one of blob, tree, commit, tag
* @param string $body raw object contents
* @return string the object name (hex SHA-1)
*/
public function writeLooseObject($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 ref file (a branch or tag) pointing at an object name.
*
* @param string $ref_name ref path, for example refs/heads/main
* @param string $sha object name the ref points at
*/
public function writeRef($ref_name, $sha)
{
$full = $this->repo_path . "/" . $ref_name;
$dir = dirname($full);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents($full, $sha . "\n");
}
/**
* Builds a tree object's contents from a list of entries.
*
* @param array $entries list of [mode, name, hex object name]
* @return string the raw tree object contents
*/
public function buildTreeBody($entries)
{
$body = "";
foreach ($entries as $entry) {
$body .= $entry[0] . " " . $entry[1] . "\0" .
hex2bin($entry[2]);
}
return $body;
}
/**
* A blob written into the repository is read back with its exact bytes,
* including embedded null bytes.
*/
public function readBlobTestCase()
{
$body = "first line\n\x00\x01second\n";
$sha = $this->writeLooseObject("blob", $body);
$repository = new L\GitRepository($this->repo_path);
$this->assertEqual($body, $repository->blob($sha),
"blob reads back byte for byte");
}
/**
* A commit written into the repository is parsed into its tree, parent,
* author, and message.
*/
public function readCommitTestCase()
{
$tree_sha = str_repeat("a", 40);
$parent_sha = str_repeat("b", 40);
$body = "tree $tree_sha\nparent $parent_sha\n" .
"author A U Thor <a@example.com> 100 +0000\n" .
"committer A U Thor <a@example.com> 100 +0000\n\n" .
"the commit message\n";
$sha = $this->writeLooseObject("commit", $body);
$repository = new L\GitRepository($this->repo_path);
$commit = $repository->commit($sha);
$this->assertEqual($tree_sha, $commit["tree"],
"commit tree parsed");
$this->assertEqual($parent_sha, $commit["parents"][0],
"commit parent parsed");
$this->assertEqual("the commit message\n", $commit["message"],
"commit message parsed");
}
/**
* A tree written into the repository is parsed into its entries, with
* folders marked as such.
*/
public function readTreeTestCase()
{
$blob_sha = $this->writeLooseObject("blob", "x");
$sub_tree = $this->writeLooseObject("tree", "");
$tree_body = $this->buildTreeBody([
["100644", "readme.txt", $blob_sha],
["40000", "src", $sub_tree]]);
$tree_sha = $this->writeLooseObject("tree", $tree_body);
$repository = new L\GitRepository($this->repo_path);
$entries = $repository->tree($tree_sha);
$this->assertEqual("readme.txt", $entries[0]["name"],
"first tree entry name");
$this->assertFalse($entries[0]["is_dir"],
"blob entry is not a folder");
$this->assertEqual("src", $entries[1]["name"],
"second tree entry name");
$this->assertTrue($entries[1]["is_dir"],
"tree entry is a folder");
$this->assertEqual($blob_sha, $entries[0]["sha"],
"tree entry object name");
}
/**
* An object read with the wrong name is reported as corrupt rather than
* returned, since the name must be the hash of the contents.
*/
public function corruptObjectTestCase()
{
$store = "blob 5\0hello";
$wrong_sha = str_repeat("c", 40);
$dir = $this->repo_path . "/objects/" . substr($wrong_sha, 0, 2);
mkdir($dir, 0777, true);
file_put_contents($dir . "/" . substr($wrong_sha, 2),
gzcompress($store));
$repository = new L\GitRepository($this->repo_path);
$threw = false;
try {
$repository->readObject($wrong_sha);
} catch (\Exception $error) {
$threw = true;
}
$this->assertTrue($threw, "object with mismatched name is rejected");
}
/**
* Branches and tags, whether stored as their own files, are all listed,
* and a missing object read returns nothing.
*/
public function allRefsTestCase()
{
$commit_sha = $this->writeLooseObject("commit",
"tree " . str_repeat("a", 40) . "\n\nm\n");
$this->writeRef("refs/heads/main", $commit_sha);
$this->writeRef("refs/heads/dev", $commit_sha);
$this->writeRef("refs/tags/v1", $commit_sha);
$repository = new L\GitRepository($this->repo_path);
$refs = $repository->allRefs();
$this->assertEqual($commit_sha, $refs["refs/heads/main"],
"main branch listed");
$this->assertEqual($commit_sha, $refs["refs/heads/dev"],
"dev branch listed");
$this->assertEqual($commit_sha, $refs["refs/tags/v1"],
"tag listed among refs");
}
/**
* An annotated tag is peeled to the commit it names, while a plain
* object peels to nothing.
*/
public function peeledTagTestCase()
{
$commit_sha = $this->writeLooseObject("commit",
"tree " . str_repeat("a", 40) . "\n\nm\n");
$tag_body = "object $commit_sha\ntype commit\ntag v2\n" .
"tagger A U Thor <a@example.com> 100 +0000\n\nrelease\n";
$tag_sha = $this->writeLooseObject("tag", $tag_body);
$repository = new L\GitRepository($this->repo_path);
$this->assertEqual($commit_sha, $repository->peeledTarget($tag_sha),
"annotated tag peels to its commit");
$this->assertEqual("", $repository->peeledTarget($commit_sha),
"a commit does not peel");
}
/**
* Creating a bare repository in a folder that already holds one does
* nothing and leaves the existing repository untouched.
*/
public function initBareNeverClobbersTestCase()
{
$sha = $this->writeLooseObject("blob", "precious contents\n");
$this->writeRef("refs/heads/main", $sha);
$before = file_get_contents($this->repo_path . "/HEAD");
$repository = new L\GitRepository($this->repo_path);
$this->assertFalse($repository->initBare("other"),
"init declines when a repository is already present");
$after = file_get_contents($this->repo_path . "/HEAD");
$this->assertEqual($before, $after,
"existing HEAD was not overwritten");
$this->assertEqual("precious contents\n", $repository->blob($sha),
"existing object still readable after init attempt");
}
/**
* The statistics walk counts commits, groups them by author and by
* month, and counts the newest commit's files by their ending,
* descending into folders. A small two-commit history with a handful of
* files of known kinds is built so every count can be checked exactly.
*/
public function statisticsTestCase()
{
$blob = $this->writeLooseObject("blob", "x");
$sub_body = $this->buildTreeBody([
["100644", "deep.php", $blob]]);
$sub_tree = $this->writeLooseObject("tree", $sub_body);
$tree_body = $this->buildTreeBody([
["100644", "one.php", $blob],
["100644", "two.php", $blob],
["100644", "app.js", $blob],
["100644", "README", $blob],
["40000", "src", $sub_tree]]);
$tree = $this->writeLooseObject("tree", $tree_body);
$parent_body = "tree $tree\n" .
"author Ann <ann@example.com> 1592179200 +0000\n" .
"committer Ann <ann@example.com> 1592179200 +0000\n\nfirst\n";
$parent = $this->writeLooseObject("commit", $parent_body);
$child_body = "tree $tree\nparent $parent\n" .
"author Bob <bob@example.com> 1600128000 +0000\n" .
"committer Bob <bob@example.com> 1600128000 +0000\n\nsecond\n";
$child = $this->writeLooseObject("commit", $child_body);
$repository = new L\GitRepository($this->repo_path);
$stats = $repository->statistics($child);
$this->assertEqual(2, $stats["commits"],
"both commits are counted");
$this->assertEqual(1, $stats["authors"]["Ann"],
"Ann is credited with one commit");
$this->assertEqual(1, $stats["authors"]["Bob"],
"Bob is credited with one commit");
$this->assertEqual(1, $stats["months"]["2020-06"],
"one commit falls in June 2020");
$this->assertEqual(1, $stats["months"]["2020-09"],
"one commit falls in September 2020");
$this->assertEqual(5, $stats["files"],
"all five files, including the one in a folder, are counted");
$this->assertEqual(3, $stats["extensions"]["php"],
"the three php files are counted together");
$this->assertEqual(1, $stats["extensions"]["js"],
"the one js file is counted");
$this->assertEqual(1, $stats["extensions"][""],
"the file with no ending is counted under the empty key");
}
}